diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e5d2924..71ffb060 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,23 +40,23 @@ jobs: include: - label: x86_64-pc-windows-msvc-windowsstore target: x86_64-pc-windows-msvc - bins: --bin juliaup --bin julialauncher + bins: --bin juliaup --bin julialauncher --bin juliapkg os: windows - features: windowsstore,binjulialauncher + features: windowsstore,binjulialauncher,juliapkg rustflags: toolchain: stable - label: x86_64-pc-windows-msvc-windowsappinstaller target: x86_64-pc-windows-msvc - bins: --bin juliaup --bin julialauncher + bins: --bin juliaup --bin julialauncher --bin juliapkg os: windows - features: windowsappinstaller,binjulialauncher + features: windowsappinstaller,binjulialauncher,juliapkg rustflags: toolchain: stable - label: x86_64-pc-windows-gnu-msi target: x86_64-pc-windows-gnu - bins: --bin juliaup --bin julialauncher + bins: --bin juliaup --bin julialauncher --bin juliapkg os: windows - features: binjulialauncher + features: binjulialauncher,juliapkg rustflags: toolchain: stable-gnu - label: x86_64-pc-windows-gnu-portable @@ -68,9 +68,9 @@ jobs: toolchain: stable-gnu - label: i686-pc-windows-gnu-msi target: i686-pc-windows-gnu - bins: --bin juliaup --bin julialauncher + bins: --bin juliaup --bin julialauncher --bin juliapkg os: windows - features: binjulialauncher + features: binjulialauncher,juliapkg rustflags: toolchain: stable-i686-gnu - label: i686-pc-windows-gnu-portable @@ -82,9 +82,9 @@ jobs: toolchain: stable-i686-gnu - label: x86_64-apple-darwin target: x86_64-apple-darwin - bins: --bin juliaup --bin julialauncher --bin juliainstaller + bins: --bin juliaup --bin julialauncher --bin juliainstaller --bin juliapkg os: macos - features: selfupdate,binjuliainstaller,binjulialauncher + features: selfupdate,binjuliainstaller,binjulialauncher,juliapkg rustflags: toolchain: stable - label: x86_64-apple-darwin-portable @@ -96,16 +96,16 @@ jobs: toolchain: stable - label: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu - bins: --bin juliaup --bin julialauncher --bin juliainstaller + bins: --bin juliaup --bin julialauncher --bin juliainstaller --bin juliapkg os: ubuntu - features: selfupdate,binjuliainstaller,binjulialauncher + features: selfupdate,binjuliainstaller,binjulialauncher,juliapkg rustflags: toolchain: stable - label: x86_64-unknown-linux-musl target: x86_64-unknown-linux-musl - bins: --bin juliaup --bin julialauncher --bin juliainstaller + bins: --bin juliaup --bin julialauncher --bin juliainstaller --bin juliapkg os: ubuntu - features: selfupdate,binjuliainstaller,binjulialauncher + features: selfupdate,binjuliainstaller,binjulialauncher,juliapkg rustflags: -C target-feature=+crt-static toolchain: stable - label: x86_64-unknown-linux-musl-portable @@ -117,37 +117,37 @@ jobs: toolchain: stable - label: x86_64-unknown-freebsd target: x86_64-unknown-freebsd - bins: --bin juliaup --bin julialauncher --bin juliainstaller + bins: --bin juliaup --bin julialauncher --bin juliainstaller --bin juliapkg os: ubuntu - features: selfupdate,binjuliainstaller,binjulialauncher + features: selfupdate,binjuliainstaller,binjulialauncher,juliapkg rustflags: toolchain: stable - label: i686-pc-windows-msvc-windowsstore target: i686-pc-windows-msvc - bins: --bin juliaup --bin julialauncher + bins: --bin juliaup --bin julialauncher --bin juliapkg os: windows - features: windowsstore,binjulialauncher + features: windowsstore,binjulialauncher,juliapkg rustflags: toolchain: stable - label: i686-pc-windows-msvc-windowsappinstaller target: i686-pc-windows-msvc - bins: --bin juliaup --bin julialauncher + bins: --bin juliaup --bin julialauncher --bin juliapkg os: windows - features: windowsappinstaller,binjulialauncher + features: windowsappinstaller,binjulialauncher,juliapkg rustflags: toolchain: stable - label: i686-unknown-linux-gnu target: i686-unknown-linux-gnu - bins: --bin juliaup --bin julialauncher --bin juliainstaller + bins: --bin juliaup --bin julialauncher --bin juliainstaller --bin juliapkg os: ubuntu - features: selfupdate,binjuliainstaller,binjulialauncher + features: selfupdate,binjuliainstaller,binjulialauncher,juliapkg rustflags: toolchain: stable - label: i686-unknown-linux-musl target: i686-unknown-linux-musl - bins: --bin juliaup --bin julialauncher --bin juliainstaller + bins: --bin juliaup --bin julialauncher --bin juliainstaller --bin juliapkg os: ubuntu - features: selfupdate,binjuliainstaller,binjulialauncher + features: selfupdate,binjuliainstaller,binjulialauncher,juliapkg rustflags: -C target-feature=+crt-static toolchain: stable - label: i686-unknown-linux-musl-portable @@ -159,16 +159,16 @@ jobs: toolchain: stable - label: aarch64-unknown-linux-gnu target: aarch64-unknown-linux-gnu - bins: --bin juliaup --bin julialauncher --bin juliainstaller + bins: --bin juliaup --bin julialauncher --bin juliainstaller --bin juliapkg os: ubuntu - features: selfupdate,binjuliainstaller,binjulialauncher + features: selfupdate,binjuliainstaller,binjulialauncher,juliapkg rustflags: toolchain: stable - label: aarch64-unknown-linux-musl target: aarch64-unknown-linux-musl - bins: --bin juliaup --bin julialauncher --bin juliainstaller + bins: --bin juliaup --bin julialauncher --bin juliainstaller --bin juliapkg os: ubuntu - features: selfupdate,binjuliainstaller,binjulialauncher + features: selfupdate,binjuliainstaller,binjulialauncher,juliapkg rustflags: -C target-feature=+crt-static toolchain: stable - label: aarch64-unknown-linux-musl-portable @@ -180,9 +180,9 @@ jobs: toolchain: stable - label: aarch64-apple-darwin target: aarch64-apple-darwin - bins: --bin juliaup --bin julialauncher --bin juliainstaller + bins: --bin juliaup --bin julialauncher --bin juliainstaller --bin juliapkg os: macos - features: selfupdate,binjuliainstaller,binjulialauncher + features: selfupdate,binjuliainstaller,binjulialauncher,juliapkg rustflags: toolchain: stable - label: aarch64-apple-darwin-portable diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eec9f4ae..167efcf7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,77 +39,77 @@ jobs: - label: x86_64-pc-windows-msvc-windowsstore target: x86_64-pc-windows-msvc os: windows - features: windowsstore,binjuliainstaller,binjulialauncher + features: windowsstore,binjuliainstaller,binjulialauncher,juliapkg rustflags: - label: x86_64-pc-windows-msvc-windowsappinstaller target: x86_64-pc-windows-msvc os: windows - features: windowsappinstaller,binjuliainstaller,binjulialauncher + features: windowsappinstaller,binjuliainstaller,binjulialauncher,juliapkg rustflags: - label: x86_64-pc-windows-gnu-msi target: x86_64-pc-windows-gnu os: windows - features: windowsappinstaller,binjuliainstaller,binjulialauncher + features: windowsappinstaller,binjuliainstaller,binjulialauncher,juliapkg rustflags: - label: i686-pc-windows-gnu-msi target: i686-pc-windows-gnu os: windows - features: windowsappinstaller,binjuliainstaller,binjulialauncher + features: windowsappinstaller,binjuliainstaller,binjulialauncher,juliapkg rustflags: - label: x86_64-apple-darwin target: x86_64-apple-darwin os: macos - features: selfupdate,binjuliainstaller,binjulialauncher + features: selfupdate,binjuliainstaller,binjulialauncher,juliapkg rustflags: - label: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu os: ubuntu - features: selfupdate,binjuliainstaller,binjulialauncher + features: selfupdate,binjuliainstaller,binjulialauncher,juliapkg rustflags: - label: x86_64-unknown-linux-musl target: x86_64-unknown-linux-musl os: ubuntu - features: selfupdate,binjuliainstaller,binjulialauncher + features: selfupdate,binjuliainstaller,binjulialauncher,juliapkg rustflags: -C target-feature=+crt-static - label: x86_64-unknown-freebsd target: x86_64-unknown-freebsd os: ubuntu - features: selfupdate,binjuliainstaller,binjulialauncher + features: selfupdate,binjuliainstaller,binjulialauncher,juliapkg rustflags: - label: i686-pc-windows-msvc-windowsstore target: i686-pc-windows-msvc os: windows - features: windowsstore,binjuliainstaller,binjulialauncher + features: windowsstore,binjuliainstaller,binjulialauncher,juliapkg rustflags: - label: i686-pc-windows-msvc-windowsappinstaller target: i686-pc-windows-msvc os: windows - features: windowsappinstaller,binjuliainstaller,binjulialauncher + features: windowsappinstaller,binjuliainstaller,binjulialauncher,juliapkg rustflags: - label: i686-unknown-linux-gnu target: i686-unknown-linux-gnu os: ubuntu - features: selfupdate,binjuliainstaller,binjulialauncher + features: selfupdate,binjuliainstaller,binjulialauncher,juliapkg rustflags: - label: i686-unknown-linux-musl target: i686-unknown-linux-musl os: ubuntu - features: selfupdate,binjuliainstaller,binjulialauncher + features: selfupdate,binjuliainstaller,binjulialauncher,juliapkg rustflags: -C target-feature=+crt-static - label: aarch64-unknown-linux-gnu target: aarch64-unknown-linux-gnu os: ubuntu - features: selfupdate,binjuliainstaller,binjulialauncher + features: selfupdate,binjuliainstaller,binjulialauncher,juliapkg rustflags: - label: aarch64-unknown-linux-musl target: aarch64-unknown-linux-musl os: ubuntu - features: selfupdate,binjuliainstaller,binjulialauncher + features: selfupdate,binjuliainstaller,binjulialauncher,juliapkg rustflags: -C target-feature=+crt-static - label: aarch64-apple-darwin target: aarch64-apple-darwin os: macos - features: selfupdate,binjuliainstaller,binjulialauncher + features: selfupdate,binjuliainstaller,binjulialauncher,juliapkg rustflags: steps: - uses: actions/checkout@v4 @@ -201,7 +201,7 @@ jobs: target: ${{matrix.target}} - name: Test if: ${{ ! contains(matrix.target, 'freebsd') }} - run: cargo test --target ${{matrix.target}} --features ${{matrix.features}} + run: cargo test --target ${{matrix.target}} --features ${{matrix.features}} --lib env: CARGO_TARGET_x86_64-unknown-linux-musl: ${{matrix.rustflags}} CARGO_TARGET_i686-unknown-linux-musl: ${{matrix.rustflags}} @@ -220,4 +220,33 @@ jobs: run: | . "${HOME}/.cargo/env" export RUST_BACKTRACE=full - cargo test --target ${{matrix.target}} --features ${{matrix.features}} + cargo test --target ${{matrix.target}} --features ${{matrix.features}} --lib + + test-juliapkg: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + julia-channel: [release, lts] + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + - name: Build juliaup with juliapkg + run: cargo build --release --features dummy,binjulialauncher,juliapkg + - name: Install Julia ${{ matrix.julia-channel }} via juliaup + run: | + ./target/release/juliaup add ${{ matrix.julia-channel }} + ./target/release/juliaup default ${{ matrix.julia-channel }} + shell: bash + - name: Run juliapkg tests with Julia ${{ matrix.julia-channel }} + run: cargo test --release --features dummy,binjulialauncher,juliapkg --test juliapkg_tests -- --nocapture + env: + RUST_BACKTRACE: 1 + - name: Run juliapkg parity tests with Julia ${{ matrix.julia-channel }} (Linux only) + if: runner.os == 'Linux' + run: cargo test --release --features dummy,binjulialauncher,juliapkg --test juliapkg_parity_test -- --nocapture + env: + RUST_BACKTRACE: 1 diff --git a/Cargo.toml b/Cargo.toml index 66c94b0f..ec047eef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -99,6 +99,7 @@ dummy = [] binjuliainstaller = [] binjulialauncher = [] winpkgidentityext = [] +juliapkg = [] [package.metadata.msix] winstoremsix = { file = "deploy/msix/PackagingLayout.xml", variables = [ @@ -134,3 +135,8 @@ path = "src/bin/juliaup.rs" name = "juliainstaller" path = "src/bin/juliainstaller.rs" required-features = ["binjuliainstaller"] + +[[bin]] +name = "juliapkg" +path = "src/bin/juliapkg.rs" +required-features = ["juliapkg"] diff --git a/src/bin/julialauncher.rs b/src/bin/julialauncher.rs index bd544167..b6151aa7 100644 --- a/src/bin/julialauncher.rs +++ b/src/bin/julialauncher.rs @@ -1,518 +1,6 @@ -use anyhow::{anyhow, Context, Result}; -use console::{style, Term}; -use is_terminal::IsTerminal; -use itertools::Itertools; -use juliaup::config_file::{load_config_db, JuliaupConfig, JuliaupConfigChannel}; -use juliaup::global_paths::get_paths; -use juliaup::jsonstructs_versionsdb::JuliaupVersionDB; -use juliaup::operations::{is_pr_channel, is_valid_channel}; -use juliaup::versions_file::load_versions_db; -#[cfg(not(windows))] -use nix::{ - sys::wait::{waitpid, WaitStatus}, - unistd::{fork, ForkResult}, -}; -use normpath::PathExt; -#[cfg(not(windows))] -use std::os::unix::process::CommandExt; -#[cfg(windows)] -use std::os::windows::io::{AsRawHandle, RawHandle}; -use std::path::Path; -use std::path::PathBuf; -#[cfg(windows)] -use windows::Win32::System::{ - JobObjects::{AssignProcessToJobObject, SetInformationJobObject}, - Threading::GetCurrentProcess, -}; - -#[derive(thiserror::Error, Debug)] -#[error("{msg}")] -pub struct UserError { - msg: String, -} - -fn get_juliaup_path() -> Result { - let my_own_path = std::env::current_exe() - .with_context(|| "std::env::current_exe() did not find its own path.")? - .canonicalize() - .with_context(|| "Failed to canonicalize the path to the Julia launcher.")?; - - let juliaup_path = my_own_path - .parent() - .unwrap() // unwrap OK here because this can't happen - .join(format!("juliaup{}", std::env::consts::EXE_SUFFIX)); - - Ok(juliaup_path) -} - -fn do_initial_setup(juliaupconfig_path: &Path) -> Result<()> { - if !juliaupconfig_path.exists() { - let juliaup_path = get_juliaup_path().with_context(|| "Failed to obtain juliaup path.")?; - - std::process::Command::new(juliaup_path) - .arg("46029ef5-0b73-4a71-bff3-d0d05de42aac") // This is our internal command to do the initial setup - .status() - .with_context(|| "Failed to start juliaup for the initial setup.")?; - } - Ok(()) -} - -fn run_versiondb_update( - config_file: &juliaup::config_file::JuliaupReadonlyConfigFile, -) -> Result<()> { - use chrono::Utc; - use std::process::Stdio; - - let versiondb_update_interval = config_file.data.settings.versionsdb_update_interval; - - if versiondb_update_interval > 0 { - let should_run = - if let Some(last_versiondb_update) = config_file.data.last_version_db_update { - let update_time = - last_versiondb_update + chrono::Duration::minutes(versiondb_update_interval); - Utc::now() >= update_time - } else { - true - }; - - if should_run { - let juliaup_path = - get_juliaup_path().with_context(|| "Failed to obtain juliaup path.")?; - - std::process::Command::new(juliaup_path) - .args(["0cf1528f-0b15-46b1-9ac9-e5bf5ccccbcf"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .stdin(Stdio::null()) - .spawn() - .with_context(|| "Failed to start juliaup for version db update.")?; - }; - } - - Ok(()) -} - -#[cfg(feature = "selfupdate")] -fn run_selfupdate(config_file: &juliaup::config_file::JuliaupReadonlyConfigFile) -> Result<()> { - use chrono::Utc; - use std::process::Stdio; - - if let Some(val) = config_file.self_data.startup_selfupdate_interval { - let should_run = if let Some(last_selfupdate) = config_file.self_data.last_selfupdate { - let update_time = last_selfupdate + chrono::Duration::minutes(val); - - if Utc::now() >= update_time { - true - } else { - false - } - } else { - true - }; - - if should_run { - let juliaup_path = - get_juliaup_path().with_context(|| "Failed to obtain juliaup path.")?; - - std::process::Command::new(juliaup_path) - .args(["self", "update"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .stdin(Stdio::null()) - .spawn() - .with_context(|| "Failed to start juliaup for self update.")?; - }; - } - - Ok(()) -} - -#[cfg(not(feature = "selfupdate"))] -fn run_selfupdate(_config_file: &juliaup::config_file::JuliaupReadonlyConfigFile) -> Result<()> { - Ok(()) -} - -fn check_channel_uptodate( - channel: &str, - current_version: &str, - versions_db: &JuliaupVersionDB, -) -> Result<()> { - let latest_version = &versions_db - .available_channels - .get(channel) - .ok_or_else(|| UserError { - msg: format!( - "The channel `{}` does not exist in the versions database.", - channel - ), - })? - .version; - - if latest_version != current_version { - eprintln!("The latest version of Julia in the `{}` channel is {}. You currently have `{}` installed. Run:", channel, latest_version, current_version); - eprintln!(); - eprintln!(" juliaup update"); - eprintln!(); - eprintln!( - "in your terminal shell to install Julia {} and update the `{}` channel to that version.", - latest_version, channel - ); - } - Ok(()) -} - -enum JuliaupChannelSource { - CmdLine, - EnvVar, - Override, - Default, -} - -fn get_julia_path_from_channel( - versions_db: &JuliaupVersionDB, - config_data: &JuliaupConfig, - channel: &str, - juliaupconfig_path: &Path, - juliaup_channel_source: JuliaupChannelSource, -) -> Result<(PathBuf, Vec)> { - let channel_valid = is_valid_channel(versions_db, &channel.to_string())?; - let channel_info = config_data - .installed_channels - .get(channel) - .ok_or_else(|| match juliaup_channel_source { - JuliaupChannelSource::CmdLine => { - if channel_valid { - UserError { msg: format!("`{}` is not installed. Please run `juliaup add {}` to install channel or version.", channel, channel) } - } else if is_pr_channel(&channel.to_string()) { - UserError { msg: format!("`{}` is not installed. Please run `juliaup add {}` to install pull request channel if available.", channel, channel) } - } else { - UserError { msg: format!("Invalid Juliaup channel `{}`. Please run `juliaup list` to get a list of valid channels and versions.", channel) } - } - }.into(), - JuliaupChannelSource::EnvVar=> { - if channel_valid { - UserError { msg: format!("`{}` from environment variable JULIAUP_CHANNEL is not installed. Please run `juliaup add {}` to install channel or version.", channel, channel) } - } else if is_pr_channel(&channel.to_string()) { - UserError { msg: format!("`{}` from environment variable JULIAUP_CHANNEL is not installed. Please run `juliaup add {}` to install pull request channel if available.", channel, channel) } - } else { - UserError { msg: format!("Invalid Juliaup channel `{}` from environment variable JULIAUP_CHANNEL. Please run `juliaup list` to get a list of valid channels and versions.", channel) } - } - }.into(), - JuliaupChannelSource::Override=> { - if channel_valid { - UserError { msg: format!("`{}` from directory override is not installed. Please run `juliaup add {}` to install channel or version.", channel, channel) } - } else if is_pr_channel(&channel.to_string()){ - UserError { msg: format!("`{}` from directory override is not installed. Please run `juliaup add {}` to install pull request channel if available.", channel, channel) } - } else { - UserError { msg: format!("Invalid Juliaup channel `{}` from directory override. Please run `juliaup list` to get a list of valid channels and versions.", channel) } - } - }.into(), - JuliaupChannelSource::Default => UserError {msg: format!("The Juliaup configuration is in an inconsistent state, the currently configured default channel `{}` is not installed.", channel) } - })?; - - match channel_info { - JuliaupConfigChannel::LinkedChannel { command, args } => { - return Ok(( - PathBuf::from(command), - args.as_ref().map_or_else(Vec::new, |v| v.clone()), - )) - } - JuliaupConfigChannel::SystemChannel { version } => { - let path = &config_data - .installed_versions.get(version) - .ok_or_else(|| anyhow!("The juliaup configuration is in an inconsistent state, the channel {} is pointing to Julia version {}, which is not installed.", channel, version))?.path; - - check_channel_uptodate(channel, version, versions_db).with_context(|| { - format!( - "The Julia launcher failed while checking whether the channel {} is up-to-date.", - channel - ) - })?; - let absolute_path = juliaupconfig_path - .parent() - .unwrap() // unwrap OK because there should always be a parent - .join(path) - .join("bin") - .join(format!("julia{}", std::env::consts::EXE_SUFFIX)) - .normalize() - .with_context(|| { - format!( - "Failed to normalize path for Julia binary, starting from `{}`.", - juliaupconfig_path.display() - ) - })?; - return Ok((absolute_path.into_path_buf(), Vec::new())); - } - JuliaupConfigChannel::DirectDownloadChannel { - path, - url: _, - local_etag, - server_etag, - version: _, - } => { - if local_etag != server_etag { - if channel.starts_with("nightly") { - // Nightly is updateable several times per day so this message will show - // more often than not unless folks update a couple of times a day. - // Also, folks using nightly are typically more experienced and need - // less detailed prompting - eprintln!( - "A new `nightly` version is available. Install with `juliaup update`." - ); - } else { - eprintln!( - "A new version of Julia for the `{}` channel is available. Run:", - channel - ); - eprintln!(); - eprintln!(" juliaup update"); - eprintln!(); - eprintln!("to install the latest Julia for the `{}` channel.", channel); - } - } - - let absolute_path = juliaupconfig_path - .parent() - .unwrap() - .join(path) - .join("bin") - .join(format!("julia{}", std::env::consts::EXE_SUFFIX)) - .normalize() - .with_context(|| { - format!( - "Failed to normalize path for Julia binary, starting from `{}`.", - juliaupconfig_path.display() - ) - })?; - return Ok((absolute_path.into_path_buf(), Vec::new())); - } - } -} - -fn get_override_channel( - config_file: &juliaup::config_file::JuliaupReadonlyConfigFile, -) -> Result> { - let curr_dir = std::env::current_dir()?.canonicalize()?; - - let juliaup_override = config_file - .data - .overrides - .iter() - .filter(|i| curr_dir.starts_with(&i.path)) - .sorted_by_key(|i| i.path.len()) - .last(); - - match juliaup_override { - Some(val) => Ok(Some(val.channel.clone())), - None => Ok(None), - } -} - -fn run_app() -> Result { - if std::io::stdout().is_terminal() { - // Set console title - let term = Term::stdout(); - term.set_title("Julia"); - } - - let paths = get_paths().with_context(|| "Trying to load all global paths.")?; - - do_initial_setup(&paths.juliaupconfig) - .with_context(|| "The Julia launcher failed to run the initial setup steps.")?; - - let config_file = load_config_db(&paths, None) - .with_context(|| "The Julia launcher failed to load a configuration file.")?; - - let versiondb_data = load_versions_db(&paths) - .with_context(|| "The Julia launcher failed to load a versions db.")?; - - // Parse command line - let mut channel_from_cmd_line: Option = None; - let args: Vec = std::env::args().collect(); - if args.len() > 1 { - let first_arg = &args[1]; - - if let Some(stripped) = first_arg.strip_prefix('+') { - channel_from_cmd_line = Some(stripped.to_string()); - } - } - - let (julia_channel_to_use, juliaup_channel_source) = - if let Some(channel) = channel_from_cmd_line { - (channel, JuliaupChannelSource::CmdLine) - } else if let Ok(channel) = std::env::var("JULIAUP_CHANNEL") { - (channel, JuliaupChannelSource::EnvVar) - } else if let Ok(Some(channel)) = get_override_channel(&config_file) { - (channel, JuliaupChannelSource::Override) - } else if let Some(channel) = config_file.data.default.clone() { - (channel, JuliaupChannelSource::Default) - } else { - return Err(anyhow!( - "The Julia launcher failed to figure out which juliaup channel to use." - )); - }; - - let (julia_path, julia_args) = get_julia_path_from_channel( - &versiondb_data, - &config_file.data, - &julia_channel_to_use, - &paths.juliaupconfig, - juliaup_channel_source, - ) - .with_context(|| { - format!( - "The Julia launcher failed to determine the command for the `{}` channel.", - julia_channel_to_use - ) - })?; - - let mut new_args: Vec = Vec::new(); - - for i in julia_args { - new_args.push(i); - } - - for (i, v) in args.iter().skip(1).enumerate() { - if i > 0 || !v.starts_with('+') { - new_args.push(v.clone()); - } - } - - // On *nix platforms we replace the current process with the Julia one. - // This simplifies use in e.g. debuggers, but requires that we fork off - // a subprocess to do the selfupdate and versiondb update. - #[cfg(not(windows))] - match unsafe { fork() } { - // NOTE: It is unsafe to perform async-signal-unsafe operations from - // forked multithreaded programs, so for complex functionality like - // selfupdate to work julialauncher needs to remain single-threaded. - // Ref: https://docs.rs/nix/latest/nix/unistd/fn.fork.html#safety - Ok(ForkResult::Parent { child, .. }) => { - // wait for the daemon-spawning child to finish - match waitpid(child, None) { - Ok(WaitStatus::Exited(_, code)) => { - if code != 0 { - panic!("Could not fork (child process exited with code: {})", code) - } - } - Ok(_) => { - panic!("Could not fork (child process did not exit normally)"); - } - Err(e) => { - panic!("Could not fork (error waiting for child process, {})", e); - } - } - - // replace the current process - let _ = std::process::Command::new(&julia_path) - .args(&new_args) - .exec(); - - // this is only ever reached if launching Julia fails - panic!( - "Could not launch Julia. Verify that there is a valid Julia binary at \"{}\".", - julia_path.to_string_lossy() - ) - } - Ok(ForkResult::Child) => { - // double-fork to prevent zombies - match unsafe { fork() } { - Ok(ForkResult::Parent { child: _, .. }) => { - // we don't do anything here so that this process can be - // reaped immediately - } - Ok(ForkResult::Child) => { - // this is where we perform the actual work. we don't do - // any typical daemon-y things (like detaching the TTY) - // so that any error output is still visible. - - // We set a Ctrl-C handler here that just doesn't do anything, as we want the Julia child - // process to handle things. - ctrlc::set_handler(|| ()) - .with_context(|| "Failed to set the Ctrl-C handler.")?; - - run_versiondb_update(&config_file) - .with_context(|| "Failed to run version db update")?; - - run_selfupdate(&config_file).with_context(|| "Failed to run selfupdate.")?; - } - Err(_) => panic!("Could not double-fork"), - } - - Ok(0) - } - Err(_) => panic!("Could not fork"), - } - - // On other platforms (i.e., Windows) we just spawn a subprocess - #[cfg(windows)] - { - // We set a Ctrl-C handler here that just doesn't do anything, as we want the Julia child - // process to handle things. - ctrlc::set_handler(|| ()).with_context(|| "Failed to set the Ctrl-C handler.")?; - - let mut job_attr: windows::Win32::Security::SECURITY_ATTRIBUTES = - windows::Win32::Security::SECURITY_ATTRIBUTES::default(); - let mut job_info: windows::Win32::System::JobObjects::JOBOBJECT_EXTENDED_LIMIT_INFORMATION = - windows::Win32::System::JobObjects::JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); - - job_attr.bInheritHandle = false.into(); - job_info.BasicLimitInformation.LimitFlags = - windows::Win32::System::JobObjects::JOB_OBJECT_LIMIT_BREAKAWAY_OK - | windows::Win32::System::JobObjects::JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK - | windows::Win32::System::JobObjects::JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - - let job_handle = unsafe { - windows::Win32::System::JobObjects::CreateJobObjectW( - Some(&job_attr), - windows::core::PCWSTR::null(), - ) - }?; - unsafe { - SetInformationJobObject( - job_handle, - windows::Win32::System::JobObjects::JobObjectExtendedLimitInformation, - &job_info as *const _ as *const std::os::raw::c_void, - std::mem::size_of_val(&job_info) as u32, - ) - }?; - - unsafe { AssignProcessToJobObject(job_handle, GetCurrentProcess()) }?; - - let mut child_process = std::process::Command::new(julia_path) - .args(&new_args) - .spawn() - .with_context(|| "The Julia launcher failed to start Julia.")?; // TODO Maybe include the command we actually tried to start? - - // We ignore any error here, as that is what libuv also does, see the documentation - // at https://github.com/libuv/libuv/blob/5ff1fc724f7f53d921599dbe18e6f96b298233f1/src/win/process.c#L1077 - let _ = unsafe { - AssignProcessToJobObject( - job_handle, - std::mem::transmute::( - child_process.as_raw_handle(), - ), - ) - }; - - run_versiondb_update(&config_file).with_context(|| "Failed to run version db update")?; - - run_selfupdate(&config_file).with_context(|| "Failed to run selfupdate.")?; - - let status = child_process - .wait() - .with_context(|| "Failed to wait for Julia process to finish.")?; - - let code = match status.code() { - Some(code) => code, - None => { - anyhow::bail!("There is no exit code, that should not be possible on Windows."); - } - }; - - Ok(code) - } -} +use anyhow::Result; +use console::style; +use juliaup::julia_launcher::{run_julia_launcher, UserError}; fn main() -> Result { let client_status: std::prelude::v1::Result; @@ -529,7 +17,7 @@ fn main() -> Result { .write_style("JULIAUP_LOG_STYLE"); env_logger::init_from_env(env); - client_status = run_app(); + client_status = run_julia_launcher(std::env::args().collect(), Some("Julia")); if let Err(err) = &client_status { if let Some(e) = err.downcast_ref::() { diff --git a/src/bin/juliapkg.rs b/src/bin/juliapkg.rs new file mode 100644 index 00000000..764351ec --- /dev/null +++ b/src/bin/juliapkg.rs @@ -0,0 +1,522 @@ +use anyhow::Result; +use clap::{Parser, Subcommand, ValueEnum}; +use juliaup::cli::CompletionShell; + +// IMPORTANT: This CLI wrapper for Julia's Pkg does NOT include the following REPL-only commands: +// +// 1. `activate` - This command changes the active environment within a REPL session. +// In a CLI context, each invocation is stateless. Users should use Julia's --project +// flag or JULIA_PROJECT environment variable to specify the project instead. +// +// 2. `undo` - This command undoes the last change within a REPL session. +// In a CLI context, there is no session state to undo. Each command invocation +// is independent and stateless. +// +// 3. `redo` - This command redoes an undone change within a REPL session. +// In a CLI context, there is no session state to redo. Each command invocation +// is independent and stateless. +// +// These commands are fundamentally REPL-specific as they rely on persistent session state +// that doesn't exist in one-time CLI invocations. DO NOT add these commands to this CLI. + +#[derive(Parser)] +#[command(name = "juliapkg")] +#[command(about = "Julia package manager", long_about = None)] +#[command(allow_external_subcommands = true)] +#[command(override_usage = "juliapkg [OPTIONS] [COMMAND] + juliapkg [OPTIONS] [COMMAND] [ARGS]... + + Julia options can be passed before the command: + + Select Julia channel (e.g., +1.10, +release) + --project[=] Set project directory + [...] Other Julia flags are also supported")] +struct Juliapkg { + #[command(subcommand)] + command: Option, +} + +#[derive(Clone, ValueEnum)] +enum PreserveLevel { + Installed, + All, + Direct, + Semver, + None, + TieredInstalled, + Tiered, +} + +#[derive(Clone, ValueEnum)] +enum UpdatePreserveLevel { + All, + Direct, + None, +} + +#[derive(Subcommand)] +enum JuliapkgCommand { + /// Add packages to project + Add { + /// Package specifications to add + packages: Vec, + + /// Preserve level for existing dependencies + #[arg(long, value_enum)] + preserve: Option, + + /// Add packages as weak dependencies + #[arg(short = 'w', long)] + weak: bool, + + /// Add packages as extra dependencies + #[arg(short = 'e', long)] + extra: bool, + }, + + /// Run the build script for packages + Build { + /// Packages to build (all if empty) + packages: Vec, + + /// Redirect build output to stdout/stderr + #[arg(short = 'v', long)] + verbose: bool, + }, + + /// Edit compat entries in the current Project + Compat { + /// Package name + package: Option, + + /// Compat string + compat_string: Option, + }, + + /// Clone the full package repo locally for development + #[command(visible_alias = "dev")] + Develop { + /// Package specifications or paths to develop + packages: Vec, + + /// Clone package to local project dev folder + #[arg(short = 'l', long)] + local: bool, + + /// Clone package to shared dev folder (default) + #[arg(long)] + shared: bool, + + /// Preserve level for existing dependencies + #[arg(long, value_enum)] + preserve: Option, + }, + + /// Free pinned or developed packages + Free { + /// Packages to free (all if empty) + packages: Vec, + + /// Free all packages + #[arg(long)] + all: bool, + }, + + /// Generate files for packages + Generate { + /// Package name + package: String, + }, + + /// Garbage collect packages not used for a significant time + Gc { + /// Show verbose output + #[arg(short = 'v', long)] + verbose: bool, + + /// Delete all packages that cannot be reached from any existing environment + #[arg(long)] + all: bool, + }, + + /// Download and install all artifacts in the manifest + Instantiate { + /// Instantiate project in verbose mode + #[arg(short = 'v', long)] + verbose: bool, + + /// Use manifest mode + #[arg(short = 'm', long)] + manifest: bool, + + /// Use project mode + #[arg(short = 'p', long)] + project: bool, + }, + + /// Pin packages + Pin { + /// Packages to pin (all if empty) + packages: Vec, + + /// Pin all packages + #[arg(short = 'a', long)] + all: bool, + }, + + /// Precompile packages + Precompile { + /// Packages to precompile (all if empty) + packages: Vec, + }, + + /// Remove packages from project + #[command(visible_alias = "rm")] + Remove { + /// Packages to remove + packages: Vec, + + /// Use project mode + #[arg(short = 'p', long)] + project: bool, + + /// Use manifest mode + #[arg(short = 'm', long)] + manifest: bool, + + /// Remove all packages + #[arg(long)] + all: bool, + }, + + /// Registry operations + Registry { + #[command(subcommand)] + command: Option, + }, + + /// Resolve versions in the manifest + Resolve, + + /// Show project status + #[command(visible_alias = "st")] + Status { + /// Packages to show status for (all if empty) + packages: Vec, + + /// Show project compatibility status + #[arg(short = 'c', long)] + compat: bool, + + /// Show extension dependencies + #[arg(short = 'e', long)] + extensions: bool, + + /// Show manifest status instead of project status + #[arg(short = 'm', long)] + manifest: bool, + + /// Show diff between manifest and project + #[arg(short = 'd', long)] + diff: bool, + + /// Show status of outdated packages + #[arg(short = 'o', long)] + outdated: bool, + }, + + /// Run tests for packages + Test { + /// Packages to test (all if empty) + packages: Vec, + + /// Run tests with coverage enabled + #[arg(long)] + coverage: bool, + }, + + /// Update packages in manifest + #[command(visible_alias = "up")] + Update { + /// Packages to update (all if empty) + packages: Vec, + + /// Use project mode + #[arg(short = 'p', long)] + project: bool, + + /// Use manifest mode + #[arg(short = 'm', long)] + manifest: bool, + + /// Only update within major version + #[arg(long)] + major: bool, + + /// Only update within minor version + #[arg(long)] + minor: bool, + + /// Only update within patch version + #[arg(long)] + patch: bool, + + /// Do not update + #[arg(long)] + fixed: bool, + + /// Preserve level for existing dependencies + #[arg(long, value_enum)] + preserve: Option, + }, + + /// Explains why a package is in the dependency graph + #[command(name = "why")] + Why { + /// Package name to explain + package: String, + }, + + /// Generate shell completion scripts + #[command(name = "completions")] + Completions { + #[arg(value_enum, value_name = "SHELL")] + shell: CompletionShell, + }, +} + +#[derive(Subcommand)] +enum RegistryCommand { + /// Add package registries + Add { + /// Registry name or URL + registry: String, + }, + + /// Remove package registries + #[command(visible_alias = "rm")] + Remove { + /// Registry name or UUID + registry: String, + }, + + /// Update package registries + #[command(visible_alias = "up")] + Update { + /// Registries to update (all if empty) + registries: Vec, + }, + + /// Information about installed registries + #[command(visible_alias = "st")] + Status, +} + +/// Parsed arguments structure +struct ParsedArgs { + julia_flags: Vec, + channel: Option, + pkg_args: Vec, +} + +/// Parse command line arguments into Julia flags and Pkg commands +fn parse_arguments(args: &[String]) -> ParsedArgs { + let mut julia_flags = Vec::new(); + let mut pkg_cmd_start = None; + let mut channel = None; + + let mut i = 1; + while i < args.len() { + let arg = &args[i]; + + // Check for channel specifier + if arg.starts_with('+') && arg.len() > 1 && channel.is_none() && pkg_cmd_start.is_none() { + channel = Some(arg[1..].to_string()); + i += 1; + } + // Check for help flag + else if arg == "--help" || arg == "-h" { + if pkg_cmd_start.is_none() { + // This is a help flag for juliapkg itself + pkg_cmd_start = Some(i); + break; + } + // Otherwise let it be part of pkg command + pkg_cmd_start = Some(i); + break; + } + // Check if this is a flag + else if arg.starts_with('-') && pkg_cmd_start.is_none() { + julia_flags.push(arg.clone()); + // If it's a flag with a value (e.g., --project=...), it's already included + // If it's a flag that expects a value next (e.g., --project ...), get the next arg + if !arg.contains('=') && i + 1 < args.len() { + let next_arg = &args[i + 1]; + if !next_arg.starts_with('-') && !next_arg.starts_with('+') { + julia_flags.push(next_arg.clone()); + i += 1; // Skip the next arg since we've consumed it + } + } + i += 1; + } + // This is the start of pkg commands + else { + pkg_cmd_start = Some(i); + break; + } + } + + let pkg_args = if let Some(start) = pkg_cmd_start { + args[start..].to_vec() + } else { + vec![] + }; + + ParsedArgs { + julia_flags, + channel, + pkg_args, + } +} + +/// Show help message and exit +fn show_help() -> Result { + match Juliapkg::try_parse_from(["juliapkg", "--help"]) { + Ok(_) => {} + Err(e) => { + // Clap returns an error for --help but prints to stderr + // We print to stdout for consistency with other CLIs + println!("{}", e); + } + } + Ok(std::process::ExitCode::from(0)) +} + +/// Validate Pkg command with clap +fn validate_pkg_command(pkg_args: &[String]) -> Result<()> { + let mut parse_args = vec!["juliapkg".to_string()]; + parse_args.extend(pkg_args.iter().cloned()); + + match Juliapkg::try_parse_from(&parse_args) { + Ok(_) => Ok(()), + Err(e) => { + // Check if this is a help request + if e.kind() == clap::error::ErrorKind::DisplayHelp + || e.kind() == clap::error::ErrorKind::DisplayVersion + { + println!("{}", e); + std::process::exit(0); + } + eprintln!("{}", e); + std::process::exit(1); + } + } +} + +/// Build the final Julia command arguments +fn build_julia_args(args: &[String], parsed: &ParsedArgs) -> Vec { + let mut new_args = Vec::new(); + + // Add the executable name + new_args.push(args[0].clone()); + + // Add channel if specified + if let Some(ch) = &parsed.channel { + new_args.push(format!("+{}", ch)); + } + + // Define default flags for juliapkg + let defaults = [ + ("--startup-file", "no"), + ("--project", "."), + ("--threads", "auto"), + ]; + + // Add Julia flags + new_args.extend(parsed.julia_flags.clone()); + + // Add default flags if not already specified + for (flag, value) in defaults { + // Check if this flag is already specified + let already_specified = if flag == "--threads" { + // Check for both --threads and -t + parsed + .julia_flags + .iter() + .any(|f| f.starts_with("--threads") || f.starts_with("-t")) + } else { + parsed.julia_flags.iter().any(|f| f.starts_with(flag)) + }; + + if !already_specified { + new_args.push(format!("{}={}", flag, value)); + } + } + + // Add the Pkg command execution + let pkg_cmd_str = parsed.pkg_args.join(" "); + new_args.push("-e".to_string()); + new_args.push(format!("using Pkg; isdefined(Pkg.REPLMode, :PRINTED_REPL_WARNING) && (Pkg.REPLMode.PRINTED_REPL_WARNING[] = true); Pkg.REPLMode.pkgstr(\"{}\")", pkg_cmd_str)); + + new_args +} + +fn main() -> Result { + let args: Vec = std::env::args().collect(); + + // Handle the case where only juliapkg is called + if args.len() == 1 { + return show_help(); + } + + // Parse arguments + let parsed = parse_arguments(&args); + + // Handle help flag in arguments + if parsed.pkg_args.first().map(|s| s.as_str()) == Some("--help") + || parsed.pkg_args.first().map(|s| s.as_str()) == Some("-h") + { + return show_help(); + } + + // If there are no pkg commands, show help + if parsed.pkg_args.is_empty() { + return show_help(); + } + + // Check if this is the completions command + if parsed.pkg_args.first().map(|s| s.as_str()) == Some("completions") { + // Parse the completions command + let mut parse_args = vec!["juliapkg".to_string()]; + parse_args.extend(parsed.pkg_args.iter().cloned()); + + match Juliapkg::try_parse_from(&parse_args) { + Ok(cli) => { + if let Some(JuliapkgCommand::Completions { shell }) = cli.command { + if let Err(e) = juliaup::command_completions::generate_completion_for_command::< + Juliapkg, + >(shell, "juliapkg") + { + eprintln!("Error generating completions: {}", e); + return Ok(std::process::ExitCode::from(1)); + } + return Ok(std::process::ExitCode::from(0)); + } + } + Err(e) => { + eprintln!("{}", e); + return Ok(std::process::ExitCode::from(1)); + } + } + } + + // Validate the Pkg command + validate_pkg_command(&parsed.pkg_args)?; + + // Build the final Julia command arguments + let new_args = build_julia_args(&args, &parsed); + + // Replace the current process args and call the shared launcher + std::env::set_var("JULIA_PROGRAM_OVERRIDE", "juliapkg"); + let exit_code = juliaup::julia_launcher::run_julia_launcher(new_args, None)?; + Ok(std::process::ExitCode::from(exit_code as u8)) +} diff --git a/src/julia_launcher.rs b/src/julia_launcher.rs new file mode 100644 index 00000000..f4291ced --- /dev/null +++ b/src/julia_launcher.rs @@ -0,0 +1,514 @@ +use crate::config_file::{load_config_db, JuliaupConfig, JuliaupConfigChannel}; +use crate::global_paths::get_paths; +use crate::jsonstructs_versionsdb::JuliaupVersionDB; +use crate::operations::{is_pr_channel, is_valid_channel}; +use crate::versions_file::load_versions_db; +use anyhow::{anyhow, Context, Result}; +use console::Term; +use is_terminal::IsTerminal; +use itertools::Itertools; +#[cfg(not(windows))] +use nix::{ + sys::wait::{waitpid, WaitStatus}, + unistd::{fork, ForkResult}, +}; +use normpath::PathExt; +#[cfg(not(windows))] +use std::os::unix::process::CommandExt; +#[cfg(windows)] +use std::os::windows::io::{AsRawHandle, RawHandle}; +use std::path::Path; +use std::path::PathBuf; +#[cfg(windows)] +use windows::Win32::System::{ + JobObjects::{AssignProcessToJobObject, SetInformationJobObject}, + Threading::GetCurrentProcess, +}; + +#[derive(thiserror::Error, Debug)] +#[error("{msg}")] +pub struct UserError { + pub msg: String, +} + +fn get_juliaup_path() -> Result { + let my_own_path = std::env::current_exe() + .with_context(|| "std::env::current_exe() did not find its own path.")? + .canonicalize() + .with_context(|| "Failed to canonicalize the path to the Julia launcher.")?; + + let juliaup_path = my_own_path + .parent() + .unwrap() // unwrap OK here because this can't happen + .join(format!("juliaup{}", std::env::consts::EXE_SUFFIX)); + + Ok(juliaup_path) +} + +fn do_initial_setup(juliaupconfig_path: &Path) -> Result<()> { + if !juliaupconfig_path.exists() { + let juliaup_path = get_juliaup_path().with_context(|| "Failed to obtain juliaup path.")?; + + std::process::Command::new(juliaup_path) + .arg("46029ef5-0b73-4a71-bff3-d0d05de42aac") // This is our internal command to do the initial setup + .status() + .with_context(|| "Failed to start juliaup for the initial setup.")?; + } + Ok(()) +} + +fn run_versiondb_update(config_file: &crate::config_file::JuliaupReadonlyConfigFile) -> Result<()> { + use chrono::Utc; + use std::process::Stdio; + + let versiondb_update_interval = config_file.data.settings.versionsdb_update_interval; + + if versiondb_update_interval > 0 { + let should_run = + if let Some(last_versiondb_update) = config_file.data.last_version_db_update { + let update_time = + last_versiondb_update + chrono::Duration::minutes(versiondb_update_interval); + Utc::now() >= update_time + } else { + true + }; + + if should_run { + let juliaup_path = + get_juliaup_path().with_context(|| "Failed to obtain juliaup path.")?; + + std::process::Command::new(juliaup_path) + .args(["0cf1528f-0b15-46b1-9ac9-e5bf5ccccbcf"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .stdin(Stdio::null()) + .spawn() + .with_context(|| "Failed to start juliaup for version db update.")?; + }; + } + + Ok(()) +} + +#[cfg(feature = "selfupdate")] +fn run_selfupdate(config_file: &crate::config_file::JuliaupReadonlyConfigFile) -> Result<()> { + use chrono::Utc; + use std::process::Stdio; + + if let Some(val) = config_file.self_data.startup_selfupdate_interval { + let should_run = if let Some(last_selfupdate) = config_file.self_data.last_selfupdate { + let update_time = last_selfupdate + chrono::Duration::minutes(val); + + if Utc::now() >= update_time { + true + } else { + false + } + } else { + true + }; + + if should_run { + let juliaup_path = + get_juliaup_path().with_context(|| "Failed to obtain juliaup path.")?; + + std::process::Command::new(juliaup_path) + .args(["self", "update"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .stdin(Stdio::null()) + .spawn() + .with_context(|| "Failed to start juliaup for self update.")?; + }; + } + + Ok(()) +} + +#[cfg(not(feature = "selfupdate"))] +fn run_selfupdate(_config_file: &crate::config_file::JuliaupReadonlyConfigFile) -> Result<()> { + Ok(()) +} + +fn check_channel_uptodate( + channel: &str, + current_version: &str, + versions_db: &JuliaupVersionDB, +) -> Result<()> { + let latest_version = &versions_db + .available_channels + .get(channel) + .ok_or_else(|| UserError { + msg: format!( + "The channel `{}` does not exist in the versions database.", + channel + ), + })? + .version; + + if latest_version != current_version { + eprintln!("The latest version of Julia in the `{}` channel is {}. You currently have `{}` installed. Run:", channel, latest_version, current_version); + eprintln!(); + eprintln!(" juliaup update"); + eprintln!(); + eprintln!( + "in your terminal shell to install Julia {} and update the `{}` channel to that version.", + latest_version, channel + ); + } + Ok(()) +} + +enum JuliaupChannelSource { + CmdLine, + EnvVar, + Override, + Default, +} + +fn get_julia_path_from_channel( + versions_db: &JuliaupVersionDB, + config_data: &JuliaupConfig, + channel: &str, + juliaupconfig_path: &Path, + juliaup_channel_source: JuliaupChannelSource, +) -> Result<(PathBuf, Vec)> { + let channel_valid = is_valid_channel(versions_db, &channel.to_string())?; + let channel_info = config_data + .installed_channels + .get(channel) + .ok_or_else(|| match juliaup_channel_source { + JuliaupChannelSource::CmdLine => { + if channel_valid { + UserError { msg: format!("`{}` is not installed. Please run `juliaup add {}` to install channel or version.", channel, channel) } + } else if is_pr_channel(&channel.to_string()) { + UserError { msg: format!("`{}` is not installed. Please run `juliaup add {}` to install pull request channel if available.", channel, channel) } + } else { + UserError { msg: format!("Invalid Juliaup channel `{}`. Please run `juliaup list` to get a list of valid channels and versions.", channel) } + } + }.into(), + JuliaupChannelSource::EnvVar=> { + if channel_valid { + UserError { msg: format!("`{}` from environment variable JULIAUP_CHANNEL is not installed. Please run `juliaup add {}` to install channel or version.", channel, channel) } + } else if is_pr_channel(&channel.to_string()) { + UserError { msg: format!("`{}` from environment variable JULIAUP_CHANNEL is not installed. Please run `juliaup add {}` to install pull request channel if available.", channel, channel) } + } else { + UserError { msg: format!("Invalid Juliaup channel `{}` from environment variable JULIAUP_CHANNEL. Please run `juliaup list` to get a list of valid channels and versions.", channel) } + } + }.into(), + JuliaupChannelSource::Override=> { + if channel_valid { + UserError { msg: format!("`{}` from directory override is not installed. Please run `juliaup add {}` to install channel or version.", channel, channel) } + } else if is_pr_channel(&channel.to_string()){ + UserError { msg: format!("`{}` from directory override is not installed. Please run `juliaup add {}` to install pull request channel if available.", channel, channel) } + } else { + UserError { msg: format!("Invalid Juliaup channel `{}` from directory override. Please run `juliaup list` to get a list of valid channels and versions.", channel) } + } + }.into(), + JuliaupChannelSource::Default => UserError {msg: format!("The Juliaup configuration is in an inconsistent state, the currently configured default channel `{}` is not installed.", channel) } + })?; + + match channel_info { + JuliaupConfigChannel::LinkedChannel { command, args } => { + return Ok(( + PathBuf::from(command), + args.as_ref().map_or_else(Vec::new, |v| v.clone()), + )) + } + JuliaupConfigChannel::SystemChannel { version } => { + let path = &config_data + .installed_versions.get(version) + .ok_or_else(|| anyhow!("The juliaup configuration is in an inconsistent state, the channel {} is pointing to Julia version {}, which is not installed.", channel, version))?.path; + + check_channel_uptodate(channel, version, versions_db).with_context(|| { + format!( + "The Julia launcher failed while checking whether the channel {} is up-to-date.", + channel + ) + })?; + let absolute_path = juliaupconfig_path + .parent() + .unwrap() // unwrap OK because there should always be a parent + .join(path) + .join("bin") + .join(format!("julia{}", std::env::consts::EXE_SUFFIX)) + .normalize() + .with_context(|| { + format!( + "Failed to normalize path for Julia binary, starting from `{}`.", + juliaupconfig_path.display() + ) + })?; + return Ok((absolute_path.into_path_buf(), Vec::new())); + } + JuliaupConfigChannel::DirectDownloadChannel { + path, + url: _, + local_etag, + server_etag, + version: _, + } => { + if local_etag != server_etag { + if channel.starts_with("nightly") { + // Nightly is updateable several times per day so this message will show + // more often than not unless folks update a couple of times a day. + // Also, folks using nightly are typically more experienced and need + // less detailed prompting + eprintln!( + "A new `nightly` version is available. Install with `juliaup update`." + ); + } else { + eprintln!( + "A new version of Julia for the `{}` channel is available. Run:", + channel + ); + eprintln!(); + eprintln!(" juliaup update"); + eprintln!(); + eprintln!("to install the latest Julia for the `{}` channel.", channel); + } + } + + let absolute_path = juliaupconfig_path + .parent() + .unwrap() + .join(path) + .join("bin") + .join(format!("julia{}", std::env::consts::EXE_SUFFIX)) + .normalize() + .with_context(|| { + format!( + "Failed to normalize path for Julia binary, starting from `{}`.", + juliaupconfig_path.display() + ) + })?; + return Ok((absolute_path.into_path_buf(), Vec::new())); + } + } +} + +fn get_override_channel( + config_file: &crate::config_file::JuliaupReadonlyConfigFile, +) -> Result> { + let curr_dir = std::env::current_dir()?.canonicalize()?; + + let juliaup_override = config_file + .data + .overrides + .iter() + .filter(|i| curr_dir.starts_with(&i.path)) + .sorted_by_key(|i| i.path.len()) + .last(); + + match juliaup_override { + Some(val) => Ok(Some(val.channel.clone())), + None => Ok(None), + } +} + +pub fn run_julia_launcher(args: Vec, console_title: Option<&str>) -> Result { + if std::io::stdout().is_terminal() { + // Set console title + if let Some(title) = console_title { + let term = Term::stdout(); + term.set_title(title); + } + } + + let paths = get_paths().with_context(|| "Trying to load all global paths.")?; + + do_initial_setup(&paths.juliaupconfig) + .with_context(|| "The Julia launcher failed to run the initial setup steps.")?; + + let config_file = load_config_db(&paths, None) + .with_context(|| "The Julia launcher failed to load a configuration file.")?; + + let versiondb_data = load_versions_db(&paths) + .with_context(|| "The Julia launcher failed to load a versions db.")?; + + // Parse command line + let mut channel_from_cmd_line: Option = None; + if args.len() > 1 { + let first_arg = &args[1]; + + if let Some(stripped) = first_arg.strip_prefix('+') { + channel_from_cmd_line = Some(stripped.to_string()); + } + } + + let (julia_channel_to_use, juliaup_channel_source) = + if let Some(channel) = channel_from_cmd_line { + (channel, JuliaupChannelSource::CmdLine) + } else if let Ok(channel) = std::env::var("JULIAUP_CHANNEL") { + (channel, JuliaupChannelSource::EnvVar) + } else if let Ok(Some(channel)) = get_override_channel(&config_file) { + (channel, JuliaupChannelSource::Override) + } else if let Some(channel) = config_file.data.default.clone() { + (channel, JuliaupChannelSource::Default) + } else { + return Err(anyhow!( + "The Julia launcher failed to figure out which juliaup channel to use." + )); + }; + + let (julia_path, julia_args) = get_julia_path_from_channel( + &versiondb_data, + &config_file.data, + &julia_channel_to_use, + &paths.juliaupconfig, + juliaup_channel_source, + ) + .with_context(|| { + format!( + "The Julia launcher failed to determine the command for the `{}` channel.", + julia_channel_to_use + ) + })?; + + let mut new_args: Vec = Vec::new(); + + for i in julia_args { + new_args.push(i); + } + + for (i, v) in args.iter().skip(1).enumerate() { + if i > 0 || !v.starts_with('+') { + new_args.push(v.clone()); + } + } + + // On *nix platforms we replace the current process with the Julia one. + // This simplifies use in e.g. debuggers, but requires that we fork off + // a subprocess to do the selfupdate and versiondb update. + #[cfg(not(windows))] + match unsafe { fork() } { + // NOTE: It is unsafe to perform async-signal-unsafe operations from + // forked multithreaded programs, so for complex functionality like + // selfupdate to work julialauncher needs to remain single-threaded. + // Ref: https://docs.rs/nix/latest/nix/unistd/fn.fork.html#safety + Ok(ForkResult::Parent { child, .. }) => { + // wait for the daemon-spawning child to finish + match waitpid(child, None) { + Ok(WaitStatus::Exited(_, code)) => { + if code != 0 { + panic!("Could not fork (child process exited with code: {})", code) + } + } + Ok(_) => { + panic!("Could not fork (child process did not exit normally)"); + } + Err(e) => { + panic!("Could not fork (error waiting for child process, {})", e); + } + } + + // replace the current process + let _ = std::process::Command::new(&julia_path) + .args(&new_args) + .exec(); + + // this is only ever reached if launching Julia fails + panic!( + "Could not launch Julia. Verify that there is a valid Julia binary at \"{}\".", + julia_path.to_string_lossy() + ) + } + Ok(ForkResult::Child) => { + // double-fork to prevent zombies + match unsafe { fork() } { + Ok(ForkResult::Parent { child: _, .. }) => { + // we don't do anything here so that this process can be + // reaped immediately + } + Ok(ForkResult::Child) => { + // this is where we perform the actual work. we don't do + // any typical daemon-y things (like detaching the TTY) + // so that any error output is still visible. + + // We set a Ctrl-C handler here that just doesn't do anything, as we want the Julia child + // process to handle things. + ctrlc::set_handler(|| ()) + .with_context(|| "Failed to set the Ctrl-C handler.")?; + + run_versiondb_update(&config_file) + .with_context(|| "Failed to run version db update")?; + + run_selfupdate(&config_file).with_context(|| "Failed to run selfupdate.")?; + } + Err(_) => panic!("Could not double-fork"), + } + + Ok(0) + } + Err(_) => panic!("Could not fork"), + } + + // On other platforms (i.e., Windows) we just spawn a subprocess + #[cfg(windows)] + { + // We set a Ctrl-C handler here that just doesn't do anything, as we want the Julia child + // process to handle things. + ctrlc::set_handler(|| ()).with_context(|| "Failed to set the Ctrl-C handler.")?; + + let mut job_attr: windows::Win32::Security::SECURITY_ATTRIBUTES = + windows::Win32::Security::SECURITY_ATTRIBUTES::default(); + let mut job_info: windows::Win32::System::JobObjects::JOBOBJECT_EXTENDED_LIMIT_INFORMATION = + windows::Win32::System::JobObjects::JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + + job_attr.bInheritHandle = false.into(); + job_info.BasicLimitInformation.LimitFlags = + windows::Win32::System::JobObjects::JOB_OBJECT_LIMIT_BREAKAWAY_OK + | windows::Win32::System::JobObjects::JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK + | windows::Win32::System::JobObjects::JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + + let job_handle = unsafe { + windows::Win32::System::JobObjects::CreateJobObjectW( + Some(&job_attr), + windows::core::PCWSTR::null(), + ) + }?; + unsafe { + SetInformationJobObject( + job_handle, + windows::Win32::System::JobObjects::JobObjectExtendedLimitInformation, + &job_info as *const _ as *const std::os::raw::c_void, + std::mem::size_of_val(&job_info) as u32, + ) + }?; + + unsafe { AssignProcessToJobObject(job_handle, GetCurrentProcess()) }?; + + let mut child_process = std::process::Command::new(julia_path) + .args(&new_args) + .spawn() + .with_context(|| "The Julia launcher failed to start Julia.")?; // TODO Maybe include the command we actually tried to start? + + // We ignore any error here, as that is what libuv also does, see the documentation + // at https://github.com/libuv/libuv/blob/5ff1fc724f7f53d921599dbe18e6f96b298233f1/src/win/process.c#L1077 + let _ = unsafe { + AssignProcessToJobObject( + job_handle, + std::mem::transmute::( + child_process.as_raw_handle(), + ), + ) + }; + + run_versiondb_update(&config_file).with_context(|| "Failed to run version db update")?; + + run_selfupdate(&config_file).with_context(|| "Failed to run selfupdate.")?; + + let status = child_process + .wait() + .with_context(|| "Failed to wait for Julia process to finish.")?; + + let code = match status.code() { + Some(code) => code, + None => { + anyhow::bail!("There is no exit code, that should not be possible on Windows."); + } + }; + + Ok(code) + } +} diff --git a/src/lib.rs b/src/lib.rs index 92675479..a6c51c9a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,6 +29,7 @@ pub mod jsonstructs_versionsdb; pub mod operations; pub mod utils; pub mod versions_file; +pub mod julia_launcher; include!(concat!(env!("OUT_DIR"), "/bundled_version.rs")); include!(concat!(env!("OUT_DIR"), "/various_constants.rs")); diff --git a/tests/juliapkg_parity_test.rs b/tests/juliapkg_parity_test.rs new file mode 100644 index 00000000..92bb1cf0 --- /dev/null +++ b/tests/juliapkg_parity_test.rs @@ -0,0 +1,206 @@ +use assert_cmd::Command; +use tempfile::TempDir; + +fn juliapkg() -> Command { + Command::cargo_bin("juliapkg").unwrap() +} + +fn julia() -> Command { + Command::new("julia") +} + +fn setup_test_project() -> TempDir { + let temp_dir = TempDir::new().unwrap(); + std::fs::write( + temp_dir.path().join("Project.toml"), + r#"name = "TestProject""#, + ) + .unwrap(); + temp_dir +} + +/// Compare juliapkg and julia pkg"..." outputs for various commands +/// We strip ANSI codes and normalize paths for comparison +fn normalize_output(s: &str) -> String { + // Remove ANSI escape codes + let re = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap(); + let s = re.replace_all(s, ""); + + // Remove warning about REPL mode (julia might show it) + // and registry initialization messages (juliapkg might show them on first run) + let lines: Vec<&str> = s + .lines() + .filter(|line| !line.contains("REPL mode is intended for interactive use")) + .filter(|line| !line.contains("@ Pkg.REPLMode")) + .filter(|line| !line.contains("Installing known registries")) + .filter(|line| !line.contains("Added `General` registry")) + .collect(); + + lines.join("\n").trim().to_string() +} + +#[test] +fn test_status_parity() { + let temp_dir = setup_test_project(); + + // Run with juliapkg + let juliapkg_output = juliapkg() + .current_dir(&temp_dir) + .arg("status") + .output() + .unwrap(); + + // Run with julia + let julia_output = julia() + .current_dir(&temp_dir) + .args(["--project=.", "--startup-file=no", "--threads=auto", "-e"]) + .arg("using Pkg; isdefined(Pkg.REPLMode, :PRINTED_REPL_WARNING) && (Pkg.REPLMode.PRINTED_REPL_WARNING[] = true); Pkg.REPLMode.pkgstr(\"status\")") + .output() + .unwrap(); + + assert_eq!( + normalize_output(&String::from_utf8_lossy(&juliapkg_output.stdout)), + normalize_output(&String::from_utf8_lossy(&julia_output.stdout)) + ); +} + +#[test] +fn test_help_subcommands() { + // Test that help works for all subcommands + let subcommands = vec![ + "add", + "build", + "compat", + "develop", + "free", + "gc", + "generate", + "instantiate", + "pin", + "precompile", + "remove", + "registry", + "resolve", + "status", + "test", + "update", + "why", + ]; + + for cmd in subcommands { + // Test basic help + juliapkg().args([cmd, "--help"]).assert().success(); + + // Test help with Julia flags before + juliapkg() + .args(["--project=/tmp", cmd, "--help"]) + .assert() + .success(); + + // Test help with multiple Julia flags + juliapkg() + .args(["--threads=4", "--color=no", cmd, "--help"]) + .assert() + .success(); + } +} + +#[test] +#[cfg(target_os = "linux")] +fn test_command_parity_with_flags() { + let temp_dir = setup_test_project(); + + // Test various commands - the args are the same as the pkg command string + let test_commands = vec![ + vec!["status", "--manifest"], + vec!["status", "--outdated"], + vec!["gc", "--all"], + vec!["build"], + vec!["resolve"], + vec!["precompile"], + vec!["registry", "status"], + ]; + + for cmd_args in test_commands { + let pkg_cmd = cmd_args.join(" "); + // Run with juliapkg + let juliapkg_output = juliapkg() + .current_dir(&temp_dir) + .args(&cmd_args) + .output() + .unwrap(); + + // Run with julia + let julia_output = julia() + .current_dir(&temp_dir) + .args(&["--project=.", "--startup-file=no", "--threads=auto", "-e"]) + .arg(format!("using Pkg; isdefined(Pkg.REPLMode, :PRINTED_REPL_WARNING) && (Pkg.REPLMode.PRINTED_REPL_WARNING[] = true); Pkg.REPLMode.pkgstr(\"{}\")", pkg_cmd)) + .output() + .unwrap(); + + assert_eq!( + normalize_output(&String::from_utf8_lossy(&juliapkg_output.stdout)), + normalize_output(&String::from_utf8_lossy(&julia_output.stdout)), + "Mismatch for command: {:?}", + cmd_args + ); + + // Also check stderr is similar (both should have no errors for these commands) + assert_eq!( + juliapkg_output.status.success(), + julia_output.status.success(), + "Status mismatch for command: {:?}", + cmd_args + ); + } +} + +#[test] +fn test_julia_flags_passthrough() { + let temp_dir = setup_test_project(); + + // Test that various Julia flags are passed through correctly + let flag_tests = vec![ + vec!["--threads=2", "status"], + vec!["--project=/tmp", "status"], + vec!["--color=no", "status"], + vec!["--startup-file=yes", "status"], + ]; + + for args in flag_tests { + // Just ensure the command succeeds - we can't easily test the flags are applied + // without more complex setup, but at least we know they don't break parsing + juliapkg() + .current_dir(&temp_dir) + .args(&args) + .assert() + .success(); + } +} + +#[test] +fn test_complex_package_specs() { + let temp_dir = setup_test_project(); + + // Test various package specification formats are accepted + // We don't actually add them (would require network), just test parsing + let specs = vec![ + vec!["add", "--help"], // Should show help, not try to add + vec!["add", "JSON@0.21.1", "--help"], // Help should take precedence + vec!["develop", "/path/to/package", "--help"], + vec![ + "add", + "https://github.com/JuliaLang/Example.jl#master", + "--help", + ], + ]; + + for args in specs { + juliapkg() + .current_dir(&temp_dir) + .args(&args) + .assert() + .success() + .stdout(predicates::str::contains("help")); + } +} diff --git a/tests/juliapkg_tests.rs b/tests/juliapkg_tests.rs new file mode 100644 index 00000000..9e9c2856 --- /dev/null +++ b/tests/juliapkg_tests.rs @@ -0,0 +1,877 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use std::env; +use std::fs; +use tempfile::TempDir; + +/// Helper to create a juliapkg command +fn juliapkg() -> Command { + let mut cmd = Command::cargo_bin("juliapkg").unwrap(); + // Ensure we're using test environment + cmd.env("JULIA_DEPOT_PATH", env::temp_dir()); + cmd +} + +/// Helper to create a test project directory with Project.toml +fn setup_test_project() -> TempDir { + let temp_dir = TempDir::new().unwrap(); + let project_file = temp_dir.path().join("Project.toml"); + fs::write(&project_file, r#"name = "TestProject""#).unwrap(); + temp_dir +} + +#[test] +fn test_help_command() { + let mut cmd = juliapkg(); + cmd.arg("--help"); + cmd.assert() + .success() + .stdout(predicate::str::contains("Julia package manager")) + .stdout(predicate::str::contains("Commands:")) + .stdout(predicate::str::contains("add")) + .stdout(predicate::str::contains("registry")); +} + +#[test] +fn test_subcommand_help() { + let mut cmd = juliapkg(); + cmd.args(["add", "--help"]); + cmd.assert() + .success() + .stdout(predicate::str::contains("Add packages to project")) + .stdout(predicate::str::contains("Package specifications to add")); +} + +#[test] +fn test_registry_subcommand_help() { + let mut cmd = juliapkg(); + cmd.args(["registry", "--help"]); + cmd.assert() + .success() + .stdout(predicate::str::contains("Registry operations")) + .stdout(predicate::str::contains("Add package registries")) + .stdout(predicate::str::contains("Remove package registries")) + .stdout(predicate::str::contains( + "Information about installed registries", + )) + .stdout(predicate::str::contains("Update package registries")); +} + +#[test] +fn test_status_command() { + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.arg("status"); + + cmd.assert() + .success() + .stdout(predicate::str::contains("Status")) + .stdout(predicate::str::contains("Project.toml")); +} + +#[test] +fn test_status_with_version_selector() { + // Test with +1.11 version selector (if available) + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["+1.11", "status"]); + + // Should either succeed or fail gracefully if version not installed + let output = cmd.output().unwrap(); + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Status")); + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("not installed") || stderr.contains("Invalid")); + } +} + +#[test] +fn test_version_selector_after_command() { + // In the new implementation, version selector must come before the command + // This test now expects the command to be interpreted differently + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["status", "+1.11"]); + + // With new implementation, "+1.11" is passed as an argument to status + // which Julia's Pkg will likely reject or ignore + let output = cmd.output().unwrap(); + + // Just check that the command runs (may succeed or fail gracefully) + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stdout.contains("Status") + || stderr.contains("ERROR") + || stderr.contains("invalid") + || stderr.contains("not") + ); +} + +#[test] +fn test_color_output_default() { + // Test that --color=yes produces ANSI escape codes + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["--color=yes", "status"]); + + cmd.assert() + .success() + .stdout(predicate::str::contains("\x1b[")) // ANSI escape codes + .stdout(predicate::str::contains("Status")); +} + +#[test] +fn test_color_output_disabled() { + // Test --color=no flag + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["--color=no", "status"]); + + // For now, color flag is handled by Julia itself, so we just check success + // The simplified juliapkg may not fully honor --color=no since it's passed to Julia + cmd.assert() + .success() + .stdout(predicate::str::contains("Status")); +} + +#[test] +fn test_project_flag_default() { + // Default should use current directory project + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.arg("status"); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + // Check that status shows project information - may show TestProject or Project path + assert!( + stdout.contains("TestProject") + || stderr.contains("TestProject") + || stdout.contains("Project") + || stderr.contains("Project") + || stdout.contains("Status") + || stderr.contains("Status") + ); +} + +#[test] +fn test_project_flag_override() { + // Test overriding the project flag + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["--project=@v1.11", "status"]); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + // When using @v1.11, should either show the environment path or at least Status output + assert!( + stdout.contains(".julia/environments") + || stderr.contains(".julia/environments") + || stdout.contains("@v1") + || stderr.contains("@v1") + || stdout.contains("Status") + || stderr.contains("Status") + ); +} + +#[test] +fn test_add_command_single_package() { + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["add", "JSON3"]); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stdout.contains("Updating") + || stdout.contains("No Changes") + || stderr.contains("Updating") + || stderr.contains("No Changes") + ); +} + +#[test] +fn test_add_command_multiple_packages() { + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["add", "JSON3", "DataFrames"]); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stdout.contains("Updating") + || stdout.contains("No Changes") + || stderr.contains("Updating") + || stderr.contains("No Changes") + ); +} + +#[test] +fn test_remove_command() { + let temp_dir = setup_test_project(); + + // First add a package + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["add", "JSON3"]); + let output = cmd.output().unwrap(); + assert!(output.status.success()); + + // Then remove it + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["remove", "JSON3"]); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stdout.contains("Updating") + || stdout.contains("No Changes") + || stderr.contains("Updating") + || stderr.contains("No Changes") + ); +} + +#[test] +fn test_rm_alias() { + // Test that 'rm' works as an alias for 'remove' + let temp_dir = setup_test_project(); + + // First add a package + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["add", "JSON3"]); + let output = cmd.output().unwrap(); + assert!(output.status.success()); + + // Then remove it using 'rm' alias + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["rm", "JSON3"]); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stdout.contains("Updating") + || stdout.contains("No Changes") + || stderr.contains("Updating") + || stderr.contains("No Changes") + ); +} + +#[test] +fn test_update_command() { + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.arg("update"); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stdout.contains("Updating") + || stdout.contains("No Changes") + || stderr.contains("Updating") + || stderr.contains("No Changes") + ); +} + +#[test] +fn test_up_alias() { + // Test that 'up' works as an alias for 'update' + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.arg("up"); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stdout.contains("Updating") + || stdout.contains("No Changes") + || stderr.contains("Updating") + || stderr.contains("No Changes") + ); +} + +#[test] +fn test_develop_command() { + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["develop", "--local", "SomePackage"]); + + // This will likely fail but should fail gracefully + let output = cmd.output().unwrap(); + assert!( + !output.status.success() || String::from_utf8_lossy(&output.stdout).contains("Updating") + ); +} + +#[test] +fn test_dev_alias() { + // Test that 'dev' works as an alias for 'develop' + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["dev", "--local", "SomePackage"]); + + // This will likely fail but should fail gracefully + let output = cmd.output().unwrap(); + assert!( + !output.status.success() || String::from_utf8_lossy(&output.stdout).contains("Updating") + ); +} + +#[test] +fn test_gc_command() { + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.arg("gc"); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stdout.contains("Active manifests") + || stdout.contains("Deleted") + || stdout.contains("Collecting") + || stderr.contains("Active manifests") + || stderr.contains("Deleted") + || stderr.contains("Collecting") + ); +} + +#[test] +fn test_gc_with_all_flag() { + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["gc", "--all"]); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stdout.contains("Active manifests") + || stdout.contains("Deleted") + || stdout.contains("Collecting") + || stderr.contains("Active manifests") + || stderr.contains("Deleted") + || stderr.contains("Collecting") + ); +} + +#[test] +fn test_instantiate_command() { + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.arg("instantiate"); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); +} + +#[test] +fn test_precompile_command() { + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.arg("precompile"); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); +} + +#[test] +fn test_build_command() { + let temp_dir = setup_test_project(); + + // First add IJulia which has a deps/build.jl script + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["add", "IJulia"]); + let output = cmd.output().unwrap(); + assert!(output.status.success(), "Failed to add IJulia package"); + + // Now test the build command on a package with actual build script + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["build", "IJulia"]); + + let output = cmd.output().unwrap(); + assert!(output.status.success(), "Build command failed for IJulia"); +} + +#[test] +fn test_test_command() { + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.arg("test"); + + // This may fail if no tests are defined, but should fail gracefully + let _ = cmd.output().unwrap(); +} + +#[test] +fn test_pin_command() { + let temp_dir = setup_test_project(); + + // First add a package + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["add", "JSON3"]); + let output = cmd.output().unwrap(); + assert!(output.status.success()); + + // Then pin it + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["pin", "JSON3"]); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stdout.contains("Updating") + || stdout.contains("Pinning") + || stderr.contains("Updating") + || stderr.contains("Pinning") + ); +} + +#[test] +fn test_free_command() { + let temp_dir = setup_test_project(); + + // First add and pin a package + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["add", "JSON3"]); + let output = cmd.output().unwrap(); + assert!(output.status.success()); + + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["pin", "JSON3"]); + let output = cmd.output().unwrap(); + assert!(output.status.success()); + + // Then free it + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["free", "JSON3"]); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stdout.contains("Updating") + || stdout.contains("Freeing") + || stdout.contains("No Changes") + || stderr.contains("Updating") + || stderr.contains("Freeing") + || stderr.contains("No Changes") + ); +} + +#[test] +fn test_resolve_command() { + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.arg("resolve"); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stdout.contains("Resolving") + || stdout.contains("No Changes") + || stderr.contains("Resolving") + || stderr.contains("No Changes") + ); +} + +#[test] +fn test_generate_command() { + let temp_dir = TempDir::new().unwrap(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["generate", "MyNewPackage"]); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stdout.contains("Generating") || stderr.contains("Generating")); +} + +#[test] +fn test_registry_add_command() { + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["registry", "add", "General"]); + + // This may already be added, but should handle gracefully + let output = cmd.output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // This test may fail in CI if Julia isn't installed or configured + assert!( + output.status.success() + || stdout.contains("already added") + || stderr.contains("already added") + || stderr.contains("Julia launcher failed") + || stderr.contains("Invalid Juliaup channel") + ); +} + +#[test] +fn test_registry_status_command() { + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["registry", "status"]); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stdout.contains("Registry") || stderr.contains("Registry")); +} + +#[test] +fn test_registry_st_alias() { + // Test that 'st' works as an alias for 'status' in registry subcommand + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["registry", "st"]); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stdout.contains("Registry") || stderr.contains("Registry")); +} + +#[test] +fn test_registry_update_command() { + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["registry", "update"]); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stdout.contains("Updating") + || stdout.contains("Registry") + || stdout.contains("No Changes") + || stderr.contains("Updating") + || stderr.contains("Registry") + || stderr.contains("No Changes") + ); +} + +#[test] +fn test_registry_up_alias() { + // Test that 'up' works as an alias for 'update' in registry subcommand + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["registry", "up"]); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stdout.contains("Updating") + || stdout.contains("Registry") + || stdout.contains("No Changes") + || stderr.contains("Updating") + || stderr.contains("Registry") + || stderr.contains("No Changes") + ); +} + +#[test] +fn test_compat_command() { + let temp_dir = setup_test_project(); + + // First add a package + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["add", "JSON3"]); + let output = cmd.output().unwrap(); + assert!(output.status.success()); + + // Then set compat + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["compat", "JSON3", "1"]); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stdout.contains("Compat") + || stdout.contains("Updating") + || stdout.contains("No Changes") + || stderr.contains("Compat") + || stderr.contains("Updating") + || stderr.contains("No Changes") + ); +} + +#[test] +fn test_why_command() { + let temp_dir = setup_test_project(); + + // First add a package with dependencies + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["add", "DataFrames"]); + let output = cmd.output().unwrap(); + assert!(output.status.success()); + + // Then check why a dependency is included + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["why", "Tables"]); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stdout.contains("DataFrames") + || stdout.contains("Tables") + || stdout.contains("not") + || stderr.contains("DataFrames") + || stderr.contains("Tables") + || stderr.contains("not") + ); +} + +#[test] +fn test_status_with_flags() { + let temp_dir = setup_test_project(); + + // Test --diff flag + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["status", "--diff"]); + let output = cmd.output().unwrap(); + assert!(output.status.success()); + + // Test --outdated flag + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["status", "--outdated"]); + let output = cmd.output().unwrap(); + assert!(output.status.success()); + + // Test --manifest flag + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["status", "--manifest"]); + let output = cmd.output().unwrap(); + assert!(output.status.success()); +} + +#[test] +fn test_st_alias_with_flags() { + // Test that 'st' alias works with flags + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["st", "--outdated"]); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stdout.contains("Status") || stderr.contains("Status")); +} + +#[test] +fn test_startup_file_default() { + // Default should have --startup-file=no + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + // Add a command that would show if startup file is loaded + cmd.arg("status"); + + // If startup file was loaded, we might see extra output + // This test mainly ensures the command succeeds + let output = cmd.output().unwrap(); + assert!(output.status.success()); +} + +#[test] +fn test_julia_flags_passthrough() { + // Test that Julia flags are properly passed through + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["--threads=2", "status"]); + + // Should not error on the threads flag + let output = cmd.output().unwrap(); + assert!(output.status.success()); +} + +#[test] +fn test_invalid_channel() { + // Test with an invalid channel selector + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.args(["+nonexistent", "status"]); + + cmd.assert() + .failure() + .stderr(predicate::str::contains("not installed").or(predicate::str::contains("Invalid"))); +} + +#[test] +fn test_no_warning_message() { + // Ensure the REPL mode warning is suppressed + let temp_dir = setup_test_project(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.arg("status"); + + cmd.assert() + .success() + .stdout(predicate::str::contains("Status")) + .stderr(predicate::str::contains("REPL mode is intended for interactive use").not()); +} + +#[test] +fn test_empty_project() { + // Test with a completely empty project + let temp_dir = TempDir::new().unwrap(); + let mut cmd = juliapkg(); + cmd.current_dir(&temp_dir); + cmd.arg("status"); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stdout.contains("Status") || stderr.contains("Status")); +} + +#[test] +fn test_help_priority() { + // Even with package specs, --help should show help, not execute + let help_priority = vec![ + vec!["add", "SomePackage", "--help"], + vec!["remove", "SomePackage", "--help"], + vec!["test", "SomePackage", "--help"], + vec!["--project=/tmp", "add", "Pkg", "--help"], + ]; + + for cmd in help_priority { + juliapkg() + .args(&cmd) + .assert() + .success() + .stdout(predicate::str::contains("Usage:")) + .stdout(predicate::str::contains("help")); + } +} + +#[test] +fn test_complex_flag_combinations() { + let temp_dir = setup_test_project(); + + // These complex combinations should all parse correctly + juliapkg() + .current_dir(&temp_dir) + .args([ + "--project=/tmp", + "--threads=4", + "--color=no", + "status", + "--manifest", + ]) + .assert() + .success(); + + // Help should work even with complex flag combinations + juliapkg() + .args(["--project=/tmp", "--threads=auto", "add", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Add packages")); +} + +#[test] +fn test_help_with_julia_flags() { + // Test that help works for all major commands with Julia flags + let commands = vec!["add", "build", "status", "test", "update"]; + + for cmd in commands { + // Help with single Julia flag + juliapkg() + .args(["--project=/tmp", cmd, "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Usage:")); + + // Help with multiple Julia flags + juliapkg() + .args(["--threads=4", "--color=no", cmd, "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Usage:")); + } +}