diff --git a/Cargo.lock b/Cargo.lock index c4c074f7..3342c82a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,7 +229,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -641,6 +641,21 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -657,6 +672,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.31" @@ -681,6 +707,7 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-sink", @@ -1209,10 +1236,12 @@ dependencies = [ "semver", "serde", "serde_json", + "serial_test", "shellexpand", "tar", "tempfile", "thiserror 2.0.16", + "toml 0.8.23", "url", "windows", "winres", @@ -1247,6 +1276,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.27" @@ -1451,6 +1489,29 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + [[package]] name = "path-absolutize" version = "3.1.1" @@ -1891,6 +1952,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "schannel" version = "0.1.27" @@ -1900,6 +1970,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "security-framework" version = "2.11.1" @@ -1974,6 +2056,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.0.0" @@ -1995,6 +2086,31 @@ dependencies = [ "serde", ] +[[package]] +name = "serial_test" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" +dependencies = [ + "futures", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "shell-words" version = "1.1.0" @@ -2288,6 +2404,18 @@ dependencies = [ "serde", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit", +] + [[package]] name = "toml" version = "0.9.5" @@ -2295,11 +2423,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.0.0", + "toml_datetime 0.7.0", "toml_writer", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "0.7.0" @@ -2309,6 +2446,26 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.0.2" @@ -2616,7 +2773,7 @@ dependencies = [ "windows-collections", "windows-core", "windows-future", - "windows-link", + "windows-link 0.1.3", "windows-numerics", ] @@ -2637,7 +2794,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.1.3", "windows-result", "windows-strings", ] @@ -2649,7 +2806,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core", - "windows-link", + "windows-link 0.1.3", "windows-threading", ] @@ -2681,6 +2838,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-numerics" version = "0.2.0" @@ -2688,7 +2851,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ "windows-core", - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -2697,7 +2860,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -2706,7 +2869,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -2758,7 +2921,7 @@ version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -2775,7 +2938,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -2874,6 +3037,15 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + [[package]] name = "winres" version = "0.1.12" diff --git a/Cargo.toml b/Cargo.toml index 999fbc56..6ce904b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ is-terminal = "0.4" path-absolutize = "3.1.0" numeric-sort = "0.1.5" regex = "1.10" +toml = "0.8" [target.'cfg(windows)'.dependencies] windows = { version = "0.61.1", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Console", "Win32_System_Threading", "Services_Store", "Foundation", "Foundation_Collections", "Web_Http", "Web_Http_Headers", "Storage_Streams", "Management_Deployment"] } @@ -90,6 +91,7 @@ assert_cmd = "2.0" assert_fs = "1.1" indoc = "2.0" predicates = "3.1" +serial_test = "3.0" [features] selfupdate = [] diff --git a/README.md b/README.md index cb4c4f66..90e079f4 100644 --- a/README.md +++ b/README.md @@ -156,10 +156,18 @@ The Julia launcher `julia` automatically determines which specific version of Ju 1. A command line Julia version specifier, such as `julia +release`. 2. The `JULIAUP_CHANNEL` environment variable. 3. A directory override, set with the `juliaup override set` command. -3. The default Juliaup channel. +4. Automatic version selection based on the active project. +5. The default Juliaup channel. The channel is used in the order listed above, using the first available option. +### Automatic Project-based Version Selection + +When no explicit channel is specified via command line, environment variable, or directory override, Juliaup will automatically attempt to select an appropriate Julia version based on the active project's requirements: + +- If a project is specified (via `--project` or `JULIA_PROJECT`), Juliaup reads the project's `Manifest.toml` file and uses the `julia_version` field to determine which Julia version to use. +- If no suitable version is found or no project is specified, it falls back to the default channel. + ## Path used by Juliaup Juliaup will by default use the Julia depot at `~/.julia` to store Julia versions and configuration files. This can be changed by setting diff --git a/src/bin/julialauncher.rs b/src/bin/julialauncher.rs index 9755356a..fa363e4e 100644 --- a/src/bin/julialauncher.rs +++ b/src/bin/julialauncher.rs @@ -16,12 +16,15 @@ use nix::{ unistd::{fork, ForkResult}, }; use normpath::PathExt; +use semver::Version; +use std::fs; #[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; +use toml::Value; #[cfg(windows)] use windows::Win32::System::{ JobObjects::{AssignProcessToJobObject, SetInformationJobObject}, @@ -179,6 +182,771 @@ fn is_interactive() -> bool { true } +/// Determines the Julia version requirement from a project's Manifest.toml. +/// based on arguments to julia and env variables +/// +/// Project can be specified via (in priority order): +/// - `--project=path` → uses specified path (file or directory) +/// - `--project=@name` → uses depot environment (e.g., @v1.10 looks in ~/.julia/environments/v1.10) +/// - `--project` (no value) → searches upward from current directory for Project.toml +/// - `JULIA_PROJECT=path` → uses specified path +/// - `JULIA_PROJECT=@name` → uses depot environment (e.g., @v1.10) +/// - `JULIA_PROJECT=""` (empty) → searches upward from current directory (@.) +/// - Portable script (`.jl` file argument with inline project/manifest) +/// - `JULIA_LOAD_PATH` → searches entries in load path for first valid project +/// +/// Returns the version string from Manifest.toml's `julia_version`. +/// Returns `None` if no manifest is found (falls back to release channel). +/// Returns error only if project was specified but couldn't be resolved. +fn determine_project_version_spec(args: &[String]) -> Result> { + let mut project_spec_cli: Option> = None; + let mut index = 1; + while index < args.len() { + let arg = &args[index]; + // Julia accepts abbreviated forms: --project, --projec, --proje, --proj + // Note: --project without = always means "search upward" (no value) + // Only --project=path specifies a value + if ["--project", "--projec", "--proje", "--proj"].contains(&arg.as_str()) { + project_spec_cli = Some(None); + } else { + for prefix in ["--project=", "--projec=", "--proje=", "--proj="] { + if let Some(value) = arg.strip_prefix(prefix) { + project_spec_cli = Some(Some(value.to_string())); + break; + } + } + } + index += 1; + } + + // Determine project spec in priority order: + // 1. --project flag (from command line) + // 2. JULIA_PROJECT environment variable + // 3. Portable script (if a .jl file is passed as argument) + // 4. JULIA_LOAD_PATH environment variable (search for first valid project) + let project_spec = if let Some(spec) = project_spec_cli { + Some(spec.unwrap_or_else(|| "@.".to_string())) + } else if let Ok(env_spec) = std::env::var("JULIA_PROJECT") { + if env_spec.trim().is_empty() { + Some("@.".to_string()) + } else { + Some(env_spec) + } + } else if let Some(script_path) = find_script_argument(args)? { + log::debug!("AutoVersionDetect::Found script argument: {}", script_path.display()); + // Check if the script is a portable script + if is_portable_script(&script_path)? { + log::debug!("AutoVersionDetect::Script is a portable script with inline project/manifest"); + // For portable scripts, use the script file itself as the project + Some(script_path.to_string_lossy().to_string()) + } else { + log::debug!("AutoVersionDetect::Script is not a portable script, checking JULIA_LOAD_PATH"); + // Not a portable script, continue to JULIA_LOAD_PATH + if let Ok(load_path) = std::env::var("JULIA_LOAD_PATH") { + find_project_from_load_path(&load_path)? + } else { + None + } + } + } else if let Ok(load_path) = std::env::var("JULIA_LOAD_PATH") { + // Search through JULIA_LOAD_PATH for the first valid project + find_project_from_load_path(&load_path)? + } else { + None + }; + + let Some(project_spec) = project_spec else { + // No project specified - return None to allow fallback to normal channel resolution + log::debug!("AutoVersionDetect::No project specification found (no --project flag, JULIA_PROJECT, or JULIA_LOAD_PATH)"); + return Ok(None); + }; + + log::debug!( + "AutoVersionDetect::Using project specification: {}", + project_spec + ); + let project_file = resolve_project_location(&project_spec)?; + let Some(project_file) = project_file else { + // No project file found - silently fall back to release channel + log::debug!( + "AutoVersionDetect::No project file found for specification: {}", + project_spec + ); + return Ok(None); + }; + + // Check if this is a portable script (.jl file) + if project_file.extension().and_then(|s| s.to_str()) == Some("jl") { + // For portable scripts, check for external manifest first, then inline + + // Extract inline project to check for manifest = "path" field + let project_toml = extract_inline_section(&project_file, "project")?; + if !project_toml.is_empty() { + log::debug!("AutoVersionDetect::PortableScript: Extracting inline project section"); + let parsed_project: Value = toml::from_str(&project_toml).with_context(|| { + format!( + "Failed to parse inline project from portable script `{}`.", + project_file.display() + ) + })?; + + // Check if the inline project EXPLICITLY specifies an external manifest + // Only use external manifest if manifest = "path" is set + if parsed_project.get("manifest").is_some() { + if let Some(manifest_path) = determine_manifest_path(&project_file, &parsed_project) { + log::debug!("AutoVersionDetect::PortableScript: Using external manifest specified in inline project: {}", manifest_path.display()); + if let Some(version) = read_manifest_julia_version(&manifest_path)? { + log::debug!("AutoVersionDetect::PortableScript: Read Julia version from external manifest: {}", version); + return Ok(Some(version)); + } + } + } + } + + // No external manifest, try inline manifest + if let Some(version) = read_portable_script_julia_version(&project_file)? { + return Ok(Some(version)); + } + + // Portable script doesn't have a manifest or julia_version, fall back + log::debug!("AutoVersionDetect::PortableScript: No julia_version found in portable script"); + return Ok(None); + } + + // Regular Project.toml handling + let project_toml = match fs::read_to_string(&project_file) { + Ok(contents) => contents, + Err(err) => { + return Err(anyhow!( + "Failed to read Project.toml at `{}`: {}.", + project_file.display(), + err + )); + } + }; + + let parsed_project: Value = match toml::from_str(&project_toml) { + Ok(value) => value, + Err(err) => { + return Err(anyhow!( + "Failed to parse Project.toml at `{}`: {}.", + project_file.display(), + err + )); + } + }; + + if let Some(manifest_path) = determine_manifest_path(&project_file, &parsed_project) { + log::debug!( + "AutoVersionDetect::Detected manifest file: {}", + manifest_path.display() + ); + if let Some(version) = read_manifest_julia_version(&manifest_path)? { + log::debug!( + "AutoVersionDetect::Read Julia version from manifest: {}", + version + ); + return Ok(Some(version)); + } else { + log::debug!( + "AutoVersionDetect::Manifest file exists but does not contain julia_version field" + ); + } + } else { + log::debug!("AutoVersionDetect::No manifest file found for project"); + } + + // No manifest with julia_version found, fall back to release + Ok(None) +} + +/// Finds a script file in the command line arguments +/// Returns the path to the script if found +/// Mimics Julia's behavior: the first non-flag argument is the script +fn find_script_argument(args: &[String]) -> Result> { + let mut skip_next = false; + let mut seen_double_dash = false; + + for (i, arg) in args.iter().enumerate().skip(1) { + // Skip the first arg (program name) + + if skip_next { + skip_next = false; + continue; + } + + // Skip channel specification (+channel) + if i == 1 && arg.starts_with('+') { + continue; + } + + // Handle -- (end of options marker) + if arg == "--" { + seen_double_dash = true; + continue; + } + + // After --, everything is treated as arguments (not flags) + if seen_double_dash { + return Ok(Some(PathBuf::from(arg))); + } + + // Skip flags and their arguments + if arg.starts_with('-') { + // Check for short flags that take a separate argument (-e "code", -t 4, etc.) + if arg.len() == 2 { + let flag_char = arg.chars().nth(1).unwrap(); + // These single-char flags consume the next argument + if "eELJCptO".contains(flag_char) { + skip_next = true; + continue; + } + } + + // Check for long flags that take a separate argument + match arg.as_str() { + "--eval" | "--print" | "--load" | "--sysimage" | + "--cpu-target" | "--procs" | "--threads" => { + skip_next = true; + continue; + } + _ => { + // For flags with = (like --project=foo), don't skip next + // For other flags (like --version, --help, etc.), continue + continue; + } + } + } + + // First non-flag argument is the script (Julia's behavior) + // Whether it exists or is portable is the caller's problem + return Ok(Some(PathBuf::from(arg))); + } + + Ok(None) +} + +fn find_project_from_load_path(load_path: &str) -> Result> { + // Parse JULIA_LOAD_PATH similar to how Julia does it + // Split on ':' (Unix) or ';' (Windows) + let separator = if cfg!(windows) { ';' } else { ':' }; + + for entry in load_path.split(separator) { + let entry = entry.trim(); + + // Skip empty entries and special entries + if entry.is_empty() || entry == "@" || entry.starts_with("@v") || entry == "@stdlib" { + continue; + } + + // Handle @. specially - it means current directory + let entry_to_check = if entry == "@." { + "@." + } else if entry.starts_with('@') { + // Other named environments - we could support these, but for now skip + continue; + } else { + entry + }; + + // Try to resolve this as a project location + if let Some(_project_file) = resolve_project_location(entry_to_check)? { + // Found a valid project + log::debug!( + "AutoVersionDetect::Found valid project in JULIA_LOAD_PATH entry: {}", + entry_to_check + ); + return Ok(Some(entry_to_check.to_string())); + } + } + + // No valid project found in JULIA_LOAD_PATH + log::debug!("AutoVersionDetect::No valid project found in JULIA_LOAD_PATH"); + Ok(None) +} + +fn resolve_project_location(spec: &str) -> Result> { + let trimmed = spec.trim(); + if trimmed.is_empty() { + return resolve_project_location("@."); + } + + if let Some(stripped) = trimmed.strip_prefix('@') { + resolve_named_environment(stripped) + } else { + let path = PathBuf::from(trimmed); + resolve_path_to_project(&path) + } +} + +fn resolve_named_environment(name: &str) -> Result> { + let target_path = if name == "." { + std::env::current_dir().with_context(|| "Failed to determine current directory.")? + } else { + let depot_paths = match std::env::var_os("JULIA_DEPOT_PATH") { + Some(paths) if !paths.is_empty() => std::env::split_paths(&paths).collect(), + _ => { + let home = dirs::home_dir().ok_or_else(|| { + anyhow!("Could not determine the path of the user home directory.") + })?; + vec![home.join(".julia")] + } + }; + + let mut candidate: Option = None; + for depot in depot_paths { + let env_path = depot.join("environments").join(name); + if env_path.exists() { + candidate = Some(env_path); + break; + } else if candidate.is_none() { + candidate = Some(env_path); + } + } + + candidate.ok_or_else(|| { + anyhow!( + "Failed to resolve environment `@{}` because no depot paths could be determined.", + name + ) + })? + }; + + resolve_path_to_project(&target_path) +} + +fn resolve_path_to_project(path: &Path) -> Result> { + let base_path = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .with_context(|| "Failed to determine current directory.")? + .join(path) + }; + + // If the path is a file, use it directly (including .jl files for portable scripts) + if base_path.is_file() { + log::debug!( + "AutoVersionDetect::Using project file directly: {}", + base_path.display() + ); + return Ok(Some(base_path)); + } + + // If the path doesn't exist, don't search upward - return None + if !base_path.exists() { + log::debug!( + "AutoVersionDetect::Project path `{}` does not exist.", + base_path.display() + ); + return Ok(None); + } + + // If it's a directory, search upward from base_path for JuliaProject.toml or Project.toml + // JuliaProject.toml takes precedence over Project.toml + let mut current = base_path.as_path(); + loop { + // Check for JuliaProject.toml first + let julia_project_file = current.join("JuliaProject.toml"); + if julia_project_file.exists() { + log::debug!( + "AutoVersionDetect::Found JuliaProject.toml at: {}", + julia_project_file.display() + ); + return Ok(Some(julia_project_file)); + } + + // Fall back to Project.toml + let project_file = current.join("Project.toml"); + if project_file.exists() { + log::debug!( + "AutoVersionDetect::Found Project.toml at: {}", + project_file.display() + ); + return Ok(Some(project_file)); + } + + // Move to parent directory + match current.parent() { + Some(parent) => current = parent, + None => { + // Reached filesystem root without finding project file + log::debug!( + "AutoVersionDetect::No Project.toml or JuliaProject.toml found searching upward from `{}`.", + base_path.display() + ); + return Ok(None); + } + } + } +} + +fn determine_manifest_path(project_file: &Path, project: &Value) -> Option { + let project_root = project_file.parent()?; + + // If project explicitly specifies manifest location, use that + match project.get("manifest") { + Some(Value::String(path)) => { + if path.trim().is_empty() { + return None; + } else { + let manifest_path = PathBuf::from(path); + return Some(if manifest_path.is_absolute() { + manifest_path + } else { + project_root.join(manifest_path) + }); + } + } + Some(_) => { + // Invalid manifest field type, fall through to default search + } + None => {} + } + + // Search for manifest files in priority order: + // 1. JuliaManifest.toml takes precedence over Manifest.toml + // 2. Manifest.toml + // 3. Versioned manifests (Manifest-v*.toml) - use the one with highest version + // 4. Default to Manifest.toml if nothing exists (for error reporting) + + // Check for JuliaManifest.toml first + let julia_manifest_path = project_root.join("JuliaManifest.toml"); + if julia_manifest_path.exists() { + log::debug!( + "AutoVersionDetect::Using JuliaManifest.toml (takes precedence over other manifests)" + ); + return Some(julia_manifest_path); + } + + // Check for Manifest.toml second + let manifest_path = project_root.join("Manifest.toml"); + if manifest_path.exists() { + log::debug!("AutoVersionDetect::Using Manifest.toml"); + return Some(manifest_path); + } + + // Search for versioned manifests (e.g., Manifest-v1.11.toml, Manifest-v1.12.toml) + // and use the one with the greatest version + if let Some(versioned_manifest) = find_highest_versioned_manifest(project_root) { + log::debug!( + "AutoVersionDetect::Using versioned manifest: {}", + versioned_manifest.display() + ); + return Some(versioned_manifest); + } + + // Default to Manifest.toml even if it doesn't exist (for error reporting) + log::debug!( + "AutoVersionDetect::No manifest file exists, will attempt to use Manifest.toml as default" + ); + Some(project_root.join("Manifest.toml")) +} + +fn find_highest_versioned_manifest(project_root: &Path) -> Option { + let Ok(entries) = fs::read_dir(project_root) else { + return None; + }; + + let mut highest_version: Option<(Version, PathBuf)> = None; + + for entry in entries.flatten() { + let path = entry.path(); + if let Some(filename) = path.file_name().and_then(|n| n.to_str()) { + // Check if the filename matches the pattern Manifest-v.toml + if let Some(stripped) = filename.strip_prefix("Manifest-v") { + if let Some(version_str) = stripped.strip_suffix(".toml") { + // Try parsing the version, handling incomplete versions + if let Some(version) = parse_version_lenient(version_str) { + match &highest_version { + Some((current_version, _)) if &version > current_version => { + highest_version = Some((version, path)); + } + None => { + highest_version = Some((version, path)); + } + _ => {} + } + } + } + } + } + } + + highest_version.map(|(_, path)| path) +} + +// Parse a version string leniently, handling incomplete versions like "1.11" or "1" +fn parse_version_lenient(version_str: &str) -> Option { + // First try parsing as-is + if let Ok(version) = Version::parse(version_str) { + return Some(version); + } + + // If that fails, try adding missing components + let parts: Vec<&str> = version_str.split('.').collect(); + let normalized = match parts.len() { + 1 => format!("{}.0.0", parts[0]), + 2 => format!("{}.{}.0", parts[0], parts[1]), + _ => return None, + }; + + Version::parse(&normalized).ok() +} + +fn read_manifest_julia_version(path: &Path) -> Result> { + if !path.exists() { + log::debug!( + "AutoVersionDetect::Manifest file `{}` not found while attempting to resolve Julia version.", + path.display() + ); + return Ok(None); + } + + let manifest_content = fs::read_to_string(path) + .with_context(|| format!("Failed to read manifest file `{}`.", path.display()))?; + + let manifest: Value = toml::from_str(&manifest_content).with_context(|| { + format!( + "Failed to parse manifest file `{}` as TOML.", + path.display() + ) + })?; + + Ok(manifest + .get("julia_version") + .and_then(|v| v.as_str()) + .map(|s| s.to_string())) +} + +/// Checks if a .jl file is a portable script by looking for inline project/manifest markers +fn is_portable_script(path: &Path) -> Result { + if !path.exists() { + log::debug!("AutoVersionDetect::PortableScript: File does not exist: {}", path.display()); + return Ok(false); + } + + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read file `{}` to check for portable script markers.", path.display()))?; + + // Look for either #!project begin or #!manifest begin markers + for line in content.lines() { + let stripped = line.trim_start(); + if stripped.starts_with("#!project begin") || stripped.starts_with("#!manifest begin") { + log::debug!("AutoVersionDetect::PortableScript: Detected portable script markers in: {}", path.display()); + return Ok(true); + } + } + + log::debug!("AutoVersionDetect::PortableScript: No portable script markers found in: {}", path.display()); + Ok(false) +} + +/// Extracts inline TOML section from a portable script +/// The section_type should be either "project" or "manifest" +fn extract_inline_section(path: &Path, section_type: &str) -> Result { + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read portable script `{}`.", path.display()))?; + + let start_fence = format!("#!{} begin", section_type); + let end_fence = format!("#!{} end", section_type); + + let mut state = ParseState::None; + let mut multiline_mode = false; + let mut in_multiline = false; + let mut result = String::new(); + + for line in content.lines() { + let stripped = line.trim_start(); + + if matches!(state, ParseState::Done) { + break; + } + + if stripped.starts_with(&start_fence) { + state = ParseState::ReadingFirst; + continue; + } else if stripped.starts_with(&end_fence) { + state = ParseState::Done; + continue; + } else if matches!(state, ParseState::ReadingFirst) { + // First line determines the format + if stripped.starts_with("#=") { + multiline_mode = true; + state = ParseState::Reading; + + // Check if opening #= and closing =# are on the same line + if stripped.trim_end().ends_with("=#") { + // Single-line multi-line comment + let content = &stripped[2..stripped.len()-2]; + result.push_str(content); + in_multiline = false; + } else { + // Multi-line comment continues + in_multiline = true; + let content = &stripped[2..]; + result.push_str(content); + result.push('\n'); + } + } else { + // Line-by-line format + multiline_mode = false; + state = ParseState::Reading; + + // Process this first line + if stripped.starts_with('#') { + let toml_line = stripped[1..].trim_start(); + result.push_str(toml_line); + } else { + result.push_str(line); + } + result.push('\n'); + } + } else if matches!(state, ParseState::Reading) { + if multiline_mode && in_multiline { + // In multi-line comment mode, look for closing =# + if stripped.trim_end().ends_with("=#") { + // Found closing delimiter + let content = &stripped[..stripped.len()-2].trim_end(); + result.push_str(content); + in_multiline = false; + } else { + // Still inside multi-line comment + result.push_str(line); + result.push('\n'); + } + } else if !multiline_mode { + // Line-by-line comment mode, strip # from each line + if stripped.starts_with('#') { + let toml_line = stripped[1..].trim_start(); + result.push_str(toml_line); + } else { + result.push_str(line); + } + result.push('\n'); + } + } + } + + if matches!(state, ParseState::Done) { + Ok(result.trim().to_string()) + } else if matches!(state, ParseState::None) { + Ok(String::new()) + } else { + Err(anyhow!( + "Incomplete inline {} block in `{}` (missing #!{} end marker).", + section_type, + path.display(), + section_type + )) + } +} + +#[derive(Debug, PartialEq)] +enum ParseState { + None, + ReadingFirst, + Reading, + Done, +} + +/// Reads julia_version from a portable script's inline manifest +fn read_portable_script_julia_version(path: &Path) -> Result> { + log::debug!("AutoVersionDetect::PortableScript: Extracting inline manifest from: {}", path.display()); + + // Extract the inline manifest section + let manifest_toml = extract_inline_section(path, "manifest")?; + + if manifest_toml.is_empty() { + log::debug!("AutoVersionDetect::PortableScript: No inline manifest found in: {}", path.display()); + return Ok(None); + } + + log::debug!("AutoVersionDetect::PortableScript: Successfully extracted inline manifest TOML"); + + // Parse the TOML + let manifest: Value = toml::from_str(&manifest_toml).with_context(|| { + format!( + "Failed to parse inline manifest from portable script `{}`.", + path.display() + ) + })?; + + let version = manifest + .get("julia_version") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + match &version { + Some(v) => log::debug!("AutoVersionDetect::PortableScript: Read Julia version from inline manifest: {}", v), + None => log::debug!("AutoVersionDetect::PortableScript: Inline manifest exists but does not contain julia_version field"), + } + + Ok(version) +} + +fn parse_db_version(version: &str) -> Result { + let base = version + .split('+') + .next() + .ok_or_else(|| anyhow!("Invalid version string `{}`.", version))?; + Version::parse(base).with_context(|| format!("Failed to parse version `{}`.", base)) +} + +fn max_available_version(versions_db: &JuliaupVersionDB) -> Result> { + let mut max_version: Option = None; + for key in versions_db.available_versions.keys() { + if let Ok(version) = parse_db_version(key) { + max_version = match max_version { + Some(current) if current >= version => Some(current), + _ => Some(version), + }; + } + } + Ok(max_version) +} + +fn resolve_auto_channel(required: String, versions_db: &JuliaupVersionDB) -> Result { + // Check if exact version is available + if versions_db.available_channels.contains_key(&required) { + return Ok(required); + } + + // If requested version is higher than any known version, use nightly + let required_version = Version::parse(&required).with_context(|| { + format!( + "Failed to parse Julia version `{}` from manifest.", + required + ) + })?; + + let max_known_version = max_available_version(versions_db)?; + if let Some(max_version) = &max_known_version { + if &required_version > max_version { + return Ok("nightly".to_string()); + } + } else { + // No versions in database at all, use nightly + return Ok("nightly".to_string()); + } + + Err(anyhow!( + "Julia version `{}` requested by Project.toml/Manifest.toml is not available in the versions database.", + required + )) +} + +fn get_auto_channel(args: &[String], versions_db: &JuliaupVersionDB) -> Option { + determine_project_version_spec(args) + .and_then(|opt_version| { + opt_version + .map(|version| resolve_auto_channel(version, versions_db)) + .transpose() + }) + .ok() + .flatten() +} + fn handle_auto_install_prompt( channel: &str, paths: &juliaup::global_paths::GlobalPaths, @@ -319,6 +1087,7 @@ enum JuliaupChannelSource { CmdLine, EnvVar, Override, + Auto, Default, } @@ -352,53 +1121,55 @@ fn get_julia_path_from_channel( ); } - // Handle auto-installation for command line channel selection - if let JuliaupChannelSource::CmdLine = juliaup_channel_source { - if channel_valid || is_pr_channel(&resolved_channel) { - // Check the user's auto-install preference - let should_auto_install = match config_data.settings.auto_install_channels { - Some(auto_install) => auto_install, // User has explicitly set a preference - None => { - // User hasn't set a preference - prompt in interactive mode, default to false in non-interactive - if is_interactive() { - handle_auto_install_prompt(&resolved_channel, paths)? - } else { - false - } + // Handle auto-installation for command line channel selection and auto-resolved channels + if matches!( + juliaup_channel_source, + JuliaupChannelSource::CmdLine | JuliaupChannelSource::Auto + ) && (channel_valid || is_pr_channel(&resolved_channel)) + { + // Check the user's auto-install preference + let should_auto_install = match config_data.settings.auto_install_channels { + Some(auto_install) => auto_install, // User has explicitly set a preference + None => { + // User hasn't set a preference - prompt in interactive mode, default to false in non-interactive + if is_interactive() { + handle_auto_install_prompt(&resolved_channel, paths)? + } else { + false } - }; + } + }; - if should_auto_install { - // Install the channel using juliaup - let is_automatic = config_data.settings.auto_install_channels == Some(true); - spawn_juliaup_add(&resolved_channel, paths, is_automatic)?; - - // Reload the config to get the newly installed channel - let updated_config_file = load_config_db(paths, None) - .with_context(|| "Failed to reload configuration after installing channel.")?; - - let updated_channel_info = updated_config_file - .data - .installed_channels - .get(&resolved_channel); - - if let Some(channel_info) = updated_channel_info { - return get_julia_path_from_installed_channel( - versions_db, - &updated_config_file.data, - &resolved_channel, - juliaupconfig_path, - channel_info, - alias_args, - ); - } else { - return Err(anyhow!( + if should_auto_install { + // Install the channel using juliaup + let is_automatic = config_data.settings.auto_install_channels == Some(true); + spawn_juliaup_add(&resolved_channel, paths, is_automatic)?; + + // Reload the config to get the newly installed channel + let updated_config_file = load_config_db(paths, None) + .with_context(|| "Failed to reload configuration after installing channel.")?; + + let updated_channel_info = updated_config_file + .data + .installed_channels + .get(&resolved_channel); + + if let Some(channel_info) = updated_channel_info { + return get_julia_path_from_installed_channel( + versions_db, + &updated_config_file.data, + &resolved_channel, + juliaupconfig_path, + channel_info, + alias_args, + ); + } else { + return Err(anyhow!( "Channel '{resolved_channel}' was installed but could not be found in configuration." )); - } } - // If we reach here, either installation failed or user declined } + // If we reach here, either installation failed or user declined } // Original error handling for non-command-line sources or invalid channels @@ -430,6 +1201,15 @@ fn get_julia_path_from_channel( UserError { msg: format!("Invalid Juliaup channel `{resolved_channel}` from directory override. Please run `juliaup list` to get a list of valid channels and versions.") } } }, + JuliaupChannelSource::Auto => { + if channel_valid { + UserError { msg: format!("`{resolved_channel}` resolved from project manifest is not installed. Please run `juliaup add {resolved_channel}` to install channel or version.") } + } else if is_pr_channel(&resolved_channel) { + UserError { msg: format!("`{resolved_channel}` resolved from project manifest is not installed. Please run `juliaup add {resolved_channel}` to install pull request channel if available.") } + } else { + UserError { msg: format!("Invalid Juliaup channel `{resolved_channel}` resolved from project manifest. Please run `juliaup list` to get a list of valid channels and versions.") } + } + }, JuliaupChannelSource::Default => UserError {msg: format!("The Juliaup configuration is in an inconsistent state, the currently configured default channel `{resolved_channel}` is not installed.") } }; @@ -562,7 +1342,7 @@ fn run_app() -> Result { let versiondb_data = load_versions_db(&paths) .with_context(|| "The Julia launcher failed to load a versions db.")?; - // Parse command line + // Parse command line for +channel let mut channel_from_cmd_line: Option = None; let args: Vec = std::env::args().collect(); if args.len() > 1 { @@ -580,6 +1360,8 @@ fn run_app() -> Result { (channel, JuliaupChannelSource::EnvVar) } else if let Ok(Some(channel)) = get_override_channel(&config_file) { (channel, JuliaupChannelSource::Override) + } else if let Some(channel) = get_auto_channel(&args, &versiondb_data) { + (channel, JuliaupChannelSource::Auto) } else if let Some(channel) = config_file.data.default.clone() { (channel, JuliaupChannelSource::Default) } else { @@ -782,3 +1564,1063 @@ fn main() -> Result { // TODO https://github.com/rust-lang/rust/issues/111688 is finalized, we should use that instead of calling exit std::process::exit(client_status?); } + +#[cfg(test)] +mod tests { + use super::*; + use indoc::indoc; + use serial_test::serial; + use std::fs; + use tempfile::TempDir; + + // Helper to create a test directory with Project.toml + fn create_test_project(dir: &Path, project_content: &str) -> PathBuf { + let project_file = dir.join("Project.toml"); + fs::write(&project_file, project_content).unwrap(); + project_file + } + + // Helper to create a manifest file + fn create_manifest(dir: &Path, name: &str, julia_version: &str) { + let manifest_file = dir.join(name); + fs::write( + &manifest_file, + format!(r#"julia_version = "{}""#, julia_version), + ) + .unwrap(); + } + + // Platform-specific path separator for JULIA_LOAD_PATH + #[cfg(windows)] + const LOAD_PATH_SEPARATOR: &str = ";"; + #[cfg(not(windows))] + const LOAD_PATH_SEPARATOR: &str = ":"; + + #[test] + #[serial] + fn test_resolve_project_location_named_environment_dot() { + // Test @. resolves to current directory's Project.toml + let original_dir = std::env::current_dir().unwrap(); + let temp_dir = TempDir::new().unwrap(); + let project_file = create_test_project(temp_dir.path(), "name = \"TestProject\""); + + std::env::set_current_dir(temp_dir.path()).unwrap(); + let result = resolve_project_location("@.").unwrap(); + + assert!(result.is_some()); + // Canonicalize both paths for comparison (handles symlinks on macOS) + assert_eq!( + result.unwrap().canonicalize().unwrap(), + project_file.canonicalize().unwrap() + ); + + // Restore original directory + std::env::set_current_dir(original_dir).unwrap(); + } + + #[test] + fn test_determine_project_version_spec_from_project_flag() { + // Test --project flag with explicit path + let temp_dir = TempDir::new().unwrap(); + create_test_project(temp_dir.path(), "name = \"TestProject\""); + create_manifest(temp_dir.path(), "Manifest.toml", "1.10.5"); + + let args = vec![ + "julia".to_string(), + format!("--project={}", temp_dir.path().display()), + "-e".to_string(), + "1+1".to_string(), + ]; + + let result = determine_project_version_spec(&args).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap(), "1.10.5"); + } + + #[test] + #[serial] + fn test_determine_project_version_spec_from_project_flag_no_value() { + // Test --project (without value) searches upward from current directory + let original_dir = std::env::current_dir().unwrap(); + let temp_dir = TempDir::new().unwrap(); + create_test_project(temp_dir.path(), "name = \"TestProject\""); + create_manifest(temp_dir.path(), "Manifest.toml", "1.11.0"); + + std::env::set_current_dir(temp_dir.path()).unwrap(); + + let args = vec![ + "julia".to_string(), + "--project".to_string(), + "-e".to_string(), + "1+1".to_string(), + ]; + + let result = determine_project_version_spec(&args).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap(), "1.11.0"); + + // Restore original directory + std::env::set_current_dir(original_dir).unwrap(); + } + + #[test] + #[serial] + fn test_determine_project_version_spec_from_env_var() { + // Test JULIA_PROJECT environment variable + let original_project = std::env::var("JULIA_PROJECT").ok(); + + let temp_dir = TempDir::new().unwrap(); + create_test_project(temp_dir.path(), "name = \"TestProject\""); + create_manifest(temp_dir.path(), "Manifest.toml", "1.9.4"); + + // Set JULIA_PROJECT environment variable + std::env::set_var("JULIA_PROJECT", temp_dir.path()); + + let args = vec!["julia".to_string(), "-e".to_string(), "1+1".to_string()]; + + let result = determine_project_version_spec(&args).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap(), "1.9.4"); + + // Restore original + match original_project { + Some(val) => std::env::set_var("JULIA_PROJECT", val), + None => std::env::remove_var("JULIA_PROJECT"), + } + } + + #[test] + #[serial] + fn test_determine_project_version_spec_from_env_var_empty_searches_upward() { + // Test JULIA_PROJECT="" (empty) searches upward like @. + let original_dir = std::env::current_dir().unwrap(); + let original_project = std::env::var("JULIA_PROJECT").ok(); + + let temp_dir = TempDir::new().unwrap(); + create_test_project(temp_dir.path(), "name = \"TestProject\""); + create_manifest(temp_dir.path(), "Manifest.toml", "1.8.5"); + + std::env::set_current_dir(temp_dir.path()).unwrap(); + std::env::set_var("JULIA_PROJECT", ""); + + let args = vec!["julia".to_string(), "-e".to_string(), "1+1".to_string()]; + + let result = determine_project_version_spec(&args).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap(), "1.8.5"); + + // Restore originals + match original_project { + Some(val) => std::env::set_var("JULIA_PROJECT", val), + None => std::env::remove_var("JULIA_PROJECT"), + } + std::env::set_current_dir(original_dir).unwrap(); + } + + #[test] + #[serial] + fn test_determine_project_version_spec_from_env_var_named_environment() { + // Test JULIA_PROJECT=@. resolves to current directory + let original_dir = std::env::current_dir().unwrap(); + let original_project = std::env::var("JULIA_PROJECT").ok(); + + let temp_dir = TempDir::new().unwrap(); + create_test_project(temp_dir.path(), "name = \"TestProject\""); + create_manifest(temp_dir.path(), "Manifest.toml", "1.10.2"); + + std::env::set_current_dir(temp_dir.path()).unwrap(); + std::env::set_var("JULIA_PROJECT", "@."); + + let args = vec!["julia".to_string(), "-e".to_string(), "1+1".to_string()]; + + let result = determine_project_version_spec(&args).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap(), "1.10.2"); + + // Restore originals + match original_project { + Some(val) => std::env::set_var("JULIA_PROJECT", val), + None => std::env::remove_var("JULIA_PROJECT"), + } + std::env::set_current_dir(original_dir).unwrap(); + } + + #[test] + #[serial] + fn test_determine_project_version_spec_flag_overrides_env() { + // Test that --project flag takes precedence over JULIA_PROJECT env var + let original_project = std::env::var("JULIA_PROJECT").ok(); + + let temp_dir1 = TempDir::new().unwrap(); + create_test_project(temp_dir1.path(), "name = \"Project1\""); + create_manifest(temp_dir1.path(), "Manifest.toml", "1.9.0"); + + let temp_dir2 = TempDir::new().unwrap(); + create_test_project(temp_dir2.path(), "name = \"Project2\""); + create_manifest(temp_dir2.path(), "Manifest.toml", "1.10.0"); + + // Set env var to temp_dir1 + std::env::set_var("JULIA_PROJECT", temp_dir1.path()); + + // But use --project to point to temp_dir2 + let args = vec![ + "julia".to_string(), + format!("--project={}", temp_dir2.path().display()), + "-e".to_string(), + "1+1".to_string(), + ]; + + let result = determine_project_version_spec(&args).unwrap(); + assert!(result.is_some()); + // Should use the version from temp_dir2 (flag), not temp_dir1 (env) + assert_eq!(result.unwrap(), "1.10.0"); + + // Restore original + match original_project { + Some(val) => std::env::set_var("JULIA_PROJECT", val), + None => std::env::remove_var("JULIA_PROJECT"), + } + } + + #[test] + fn test_determine_project_version_spec_no_project_specified() { + // Test that None is returned when no project is specified + let args = vec!["julia".to_string(), "-e".to_string(), "1+1".to_string()]; + + // Make sure JULIA_PROJECT and JULIA_LOAD_PATH are not set + std::env::remove_var("JULIA_PROJECT"); + std::env::remove_var("JULIA_LOAD_PATH"); + + let result = determine_project_version_spec(&args).unwrap(); + assert!(result.is_none()); + } + + #[test] + #[serial] + fn test_determine_project_version_spec_from_load_path() { + // Test JULIA_LOAD_PATH environment variable + // Save original JULIA_LOAD_PATH and JULIA_PROJECT + let original_load_path = std::env::var("JULIA_LOAD_PATH").ok(); + let original_project = std::env::var("JULIA_PROJECT").ok(); + + // Clear JULIA_PROJECT to ensure it doesn't interfere + std::env::remove_var("JULIA_PROJECT"); + + let temp_dir = TempDir::new().unwrap(); + create_test_project(temp_dir.path(), "name = \"TestProject\""); + create_manifest(temp_dir.path(), "Manifest.toml", "1.12.0"); + + // Set JULIA_LOAD_PATH to include our test project + std::env::set_var( + "JULIA_LOAD_PATH", + format!( + "@{}{}{}@stdlib", + LOAD_PATH_SEPARATOR, + temp_dir.path().display(), + LOAD_PATH_SEPARATOR + ), + ); + + let args = vec!["julia".to_string(), "-e".to_string(), "1+1".to_string()]; + + let result = determine_project_version_spec(&args).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap(), "1.12.0"); + + // Restore originals + match original_load_path { + Some(val) => std::env::set_var("JULIA_LOAD_PATH", val), + None => std::env::remove_var("JULIA_LOAD_PATH"), + } + match original_project { + Some(val) => std::env::set_var("JULIA_PROJECT", val), + None => std::env::remove_var("JULIA_PROJECT"), + } + } + + #[test] + #[serial] + fn test_determine_project_version_spec_load_path_searches_first_valid() { + // Test that JULIA_LOAD_PATH returns the first valid project + // Save original JULIA_LOAD_PATH and JULIA_PROJECT + let original_load_path = std::env::var("JULIA_LOAD_PATH").ok(); + let original_project = std::env::var("JULIA_PROJECT").ok(); + + // Clear JULIA_PROJECT to ensure it doesn't interfere + std::env::remove_var("JULIA_PROJECT"); + + let temp_dir1 = TempDir::new().unwrap(); + create_test_project(temp_dir1.path(), "name = \"Project1\""); + create_manifest(temp_dir1.path(), "Manifest.toml", "1.11.5"); + + let temp_dir2 = TempDir::new().unwrap(); + create_test_project(temp_dir2.path(), "name = \"Project2\""); + create_manifest(temp_dir2.path(), "Manifest.toml", "1.10.3"); + + // Set JULIA_LOAD_PATH with temp_dir1 first + std::env::set_var( + "JULIA_LOAD_PATH", + format!( + "{}{}{}", + temp_dir1.path().display(), + LOAD_PATH_SEPARATOR, + temp_dir2.path().display() + ), + ); + + let args = vec!["julia".to_string(), "-e".to_string(), "1+1".to_string()]; + + let result = determine_project_version_spec(&args).unwrap(); + assert!(result.is_some()); + // Should use version from temp_dir1 (first in LOAD_PATH) + assert_eq!(result.unwrap(), "1.11.5"); + + // Restore originals + match original_load_path { + Some(val) => std::env::set_var("JULIA_LOAD_PATH", val), + None => std::env::remove_var("JULIA_LOAD_PATH"), + } + match original_project { + Some(val) => std::env::set_var("JULIA_PROJECT", val), + None => std::env::remove_var("JULIA_PROJECT"), + } + } + + #[test] + #[serial] + fn test_determine_project_version_spec_project_overrides_load_path() { + // Test that JULIA_PROJECT takes precedence over JULIA_LOAD_PATH + // Save originals + let original_load_path = std::env::var("JULIA_LOAD_PATH").ok(); + let original_project = std::env::var("JULIA_PROJECT").ok(); + + let temp_dir1 = TempDir::new().unwrap(); + create_test_project(temp_dir1.path(), "name = \"Project1\""); + create_manifest(temp_dir1.path(), "Manifest.toml", "1.9.2"); + + let temp_dir2 = TempDir::new().unwrap(); + create_test_project(temp_dir2.path(), "name = \"Project2\""); + create_manifest(temp_dir2.path(), "Manifest.toml", "1.10.4"); + + // Set JULIA_LOAD_PATH to temp_dir1 + std::env::set_var("JULIA_LOAD_PATH", temp_dir1.path().to_str().unwrap()); + // Set JULIA_PROJECT to temp_dir2 (should take precedence) + std::env::set_var("JULIA_PROJECT", temp_dir2.path().to_str().unwrap()); + + let args = vec!["julia".to_string(), "-e".to_string(), "1+1".to_string()]; + + let result = determine_project_version_spec(&args).unwrap(); + assert!(result.is_some()); + // Should use version from temp_dir2 (JULIA_PROJECT), not temp_dir1 (JULIA_LOAD_PATH) + assert_eq!(result.unwrap(), "1.10.4"); + + // Restore originals + match original_load_path { + Some(val) => std::env::set_var("JULIA_LOAD_PATH", val), + None => std::env::remove_var("JULIA_LOAD_PATH"), + } + match original_project { + Some(val) => std::env::set_var("JULIA_PROJECT", val), + None => std::env::remove_var("JULIA_PROJECT"), + } + } + + #[test] + #[serial] + fn test_determine_project_version_spec_relative_paths() { + // Test that relative paths work like Julia (relative to current directory) + // Similar to: JULIA_LOAD_PATH="Pkg.jl" julia --project=DataFrames.jl -e '...' + let original_dir = std::env::current_dir().unwrap(); + let original_load_path = std::env::var("JULIA_LOAD_PATH").ok(); + let original_project = std::env::var("JULIA_PROJECT").ok(); + let parent_dir = TempDir::new().unwrap(); + + // Clear JULIA_PROJECT to ensure it doesn't interfere + std::env::remove_var("JULIA_PROJECT"); + + // Create two project directories + let pkg_dir = parent_dir.path().join("Pkg.jl"); + fs::create_dir(&pkg_dir).unwrap(); + create_test_project(&pkg_dir, "name = \"Pkg\""); + create_manifest(&pkg_dir, "Manifest.toml", "1.9.0"); + + let df_dir = parent_dir.path().join("DataFrames.jl"); + fs::create_dir(&df_dir).unwrap(); + create_test_project(&df_dir, "name = \"DataFrames\""); + create_manifest(&df_dir, "Manifest.toml", "1.11.0"); + + // Change to parent directory so relative paths work + std::env::set_current_dir(parent_dir.path()).unwrap(); + + // Set JULIA_LOAD_PATH to Pkg.jl + std::env::set_var("JULIA_LOAD_PATH", "Pkg.jl"); + + // Test 1: --project=DataFrames.jl should override JULIA_LOAD_PATH + let args = vec![ + "julia".to_string(), + "--project=DataFrames.jl".to_string(), + "-e".to_string(), + "1+1".to_string(), + ]; + let result = determine_project_version_spec(&args).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap(), "1.11.0"); + + // Test 2: Without --project, should use JULIA_LOAD_PATH (Pkg.jl) + let args2 = vec!["julia".to_string(), "-e".to_string(), "1+1".to_string()]; + let result2 = determine_project_version_spec(&args2).unwrap(); + assert!(result2.is_some()); + assert_eq!(result2.unwrap(), "1.9.0"); + + // Restore originals + match original_load_path { + Some(val) => std::env::set_var("JULIA_LOAD_PATH", val), + None => std::env::remove_var("JULIA_LOAD_PATH"), + } + match original_project { + Some(val) => std::env::set_var("JULIA_PROJECT", val), + None => std::env::remove_var("JULIA_PROJECT"), + } + std::env::set_current_dir(original_dir).unwrap(); + } + + #[test] + fn test_resolve_path_to_project_direct_file() { + // Test resolving a direct path to a project file + let temp_dir = TempDir::new().unwrap(); + let project_file = create_test_project(temp_dir.path(), "name = \"TestProject\""); + + let result = resolve_path_to_project(&project_file).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap(), project_file); + } + + #[test] + fn test_resolve_path_to_project_directory() { + // Test resolving a directory to its Project.toml + let temp_dir = TempDir::new().unwrap(); + let project_file = create_test_project(temp_dir.path(), "name = \"TestProject\""); + + let result = resolve_path_to_project(temp_dir.path()).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap(), project_file); + } + + #[test] + fn test_resolve_path_to_project_search_upward() { + // Test searching upward for Project.toml + let temp_dir = TempDir::new().unwrap(); + let project_file = create_test_project(temp_dir.path(), "name = \"TestProject\""); + + // Create a subdirectory + let subdir = temp_dir.path().join("subdir"); + fs::create_dir(&subdir).unwrap(); + + let result = resolve_path_to_project(&subdir).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap(), project_file); + } + + #[test] + fn test_resolve_path_to_project_julia_project_precedence() { + // Test that JuliaProject.toml takes precedence over Project.toml + let temp_dir = TempDir::new().unwrap(); + create_test_project(temp_dir.path(), "name = \"TestProject\""); + let julia_project_file = temp_dir.path().join("JuliaProject.toml"); + fs::write(&julia_project_file, "name = \"JuliaTestProject\"").unwrap(); + + let result = resolve_path_to_project(temp_dir.path()).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap(), julia_project_file); + } + + #[test] + fn test_resolve_path_to_project_not_found() { + // Test when no project file exists + let temp_dir = TempDir::new().unwrap(); + let result = resolve_path_to_project(temp_dir.path()).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_determine_manifest_path_default() { + // Test default Manifest.toml detection + let temp_dir = TempDir::new().unwrap(); + let project_file = create_test_project(temp_dir.path(), "name = \"TestProject\""); + create_manifest(temp_dir.path(), "Manifest.toml", "1.10.0"); + + let project_toml: Value = toml::from_str("name = \"TestProject\"").unwrap(); + let result = determine_manifest_path(&project_file, &project_toml); + + assert!(result.is_some()); + assert_eq!(result.unwrap().file_name().unwrap(), "Manifest.toml"); + } + + #[test] + fn test_determine_manifest_path_julia_manifest_precedence() { + // Test that JuliaManifest.toml takes precedence over Manifest.toml + let temp_dir = TempDir::new().unwrap(); + let project_file = create_test_project(temp_dir.path(), "name = \"TestProject\""); + create_manifest(temp_dir.path(), "Manifest.toml", "1.10.0"); + create_manifest(temp_dir.path(), "JuliaManifest.toml", "1.11.0"); + + let project_toml: Value = toml::from_str("name = \"TestProject\"").unwrap(); + let result = determine_manifest_path(&project_file, &project_toml); + + assert!(result.is_some()); + assert_eq!(result.unwrap().file_name().unwrap(), "JuliaManifest.toml"); + } + + #[test] + fn test_determine_manifest_path_versioned_manifest() { + // Test versioned manifest detection + let temp_dir = TempDir::new().unwrap(); + let project_file = create_test_project(temp_dir.path(), "name = \"TestProject\""); + create_manifest(temp_dir.path(), "Manifest-v1.11.toml", "1.11.0"); + + let project_toml: Value = toml::from_str("name = \"TestProject\"").unwrap(); + let result = determine_manifest_path(&project_file, &project_toml); + + assert!(result.is_some()); + assert_eq!(result.unwrap().file_name().unwrap(), "Manifest-v1.11.toml"); + } + + #[test] + fn test_determine_manifest_path_multiple_versioned_manifests() { + // Test that the highest versioned manifest is selected + let temp_dir = TempDir::new().unwrap(); + let project_file = create_test_project(temp_dir.path(), "name = \"TestProject\""); + create_manifest(temp_dir.path(), "Manifest-v1.10.toml", "1.10.0"); + create_manifest(temp_dir.path(), "Manifest-v1.11.toml", "1.11.0"); + create_manifest(temp_dir.path(), "Manifest-v1.12.toml", "1.12.0"); + + let project_toml: Value = toml::from_str("name = \"TestProject\"").unwrap(); + let result = determine_manifest_path(&project_file, &project_toml); + + assert!(result.is_some()); + assert_eq!(result.unwrap().file_name().unwrap(), "Manifest-v1.12.toml"); + } + + #[test] + fn test_determine_manifest_path_standard_over_versioned() { + // Test that standard Manifest.toml takes precedence over versioned manifests + let temp_dir = TempDir::new().unwrap(); + let project_file = create_test_project(temp_dir.path(), "name = \"TestProject\""); + create_manifest(temp_dir.path(), "Manifest.toml", "1.13.0"); + create_manifest(temp_dir.path(), "Manifest-v1.11.toml", "1.11.0"); + create_manifest(temp_dir.path(), "Manifest-v1.12.toml", "1.12.0"); + + let project_toml: Value = toml::from_str("name = \"TestProject\"").unwrap(); + let result = determine_manifest_path(&project_file, &project_toml); + + assert!(result.is_some()); + assert_eq!(result.unwrap().file_name().unwrap(), "Manifest.toml"); + } + + #[test] + fn test_determine_manifest_path_explicit_manifest_field() { + // Test explicit manifest field in Project.toml + let temp_dir = TempDir::new().unwrap(); + let project_file = create_test_project( + temp_dir.path(), + indoc! {r#" + name = "TestProject" + manifest = "custom/Manifest.toml" + "#}, + ); + + let custom_dir = temp_dir.path().join("custom"); + fs::create_dir(&custom_dir).unwrap(); + create_manifest(&custom_dir, "Manifest.toml", "1.10.0"); + + let project_toml: Value = toml::from_str(indoc! {r#" + name = "TestProject" + manifest = "custom/Manifest.toml" + "#}) + .unwrap(); + let result = determine_manifest_path(&project_file, &project_toml); + + assert!(result.is_some()); + assert!(result.unwrap().ends_with("custom/Manifest.toml")); + } + + #[test] + fn test_read_manifest_julia_version() { + // Test reading julia_version from manifest + let temp_dir = TempDir::new().unwrap(); + create_manifest(temp_dir.path(), "Manifest.toml", "1.10.5"); + + let manifest_path = temp_dir.path().join("Manifest.toml"); + let result = read_manifest_julia_version(&manifest_path).unwrap(); + + assert!(result.is_some()); + assert_eq!(result.unwrap(), "1.10.5"); + } + + #[test] + fn test_read_manifest_julia_version_missing_file() { + // Test reading from non-existent manifest + let temp_dir = TempDir::new().unwrap(); + let manifest_path = temp_dir.path().join("NonExistent.toml"); + + let result = read_manifest_julia_version(&manifest_path).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_read_manifest_julia_version_missing_field() { + // Test reading manifest without julia_version field + let temp_dir = TempDir::new().unwrap(); + let manifest_path = temp_dir.path().join("Manifest.toml"); + fs::write(&manifest_path, "[deps]\nExample = \"1.0.0\"").unwrap(); + + let result = read_manifest_julia_version(&manifest_path).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_find_highest_versioned_manifest() { + // Test finding highest versioned manifest + let temp_dir = TempDir::new().unwrap(); + create_manifest(temp_dir.path(), "Manifest-v1.8.0.toml", "1.8.0"); + create_manifest(temp_dir.path(), "Manifest-v1.10.5.toml", "1.10.5"); + create_manifest(temp_dir.path(), "Manifest-v1.11.2.toml", "1.11.2"); + + let result = find_highest_versioned_manifest(temp_dir.path()); + assert!(result.is_some()); + assert_eq!( + result.unwrap().file_name().unwrap(), + "Manifest-v1.11.2.toml" + ); + } + + #[test] + fn test_find_highest_versioned_manifest_none() { + // Test when no versioned manifests exist + let temp_dir = TempDir::new().unwrap(); + create_manifest(temp_dir.path(), "Manifest.toml", "1.10.0"); + + let result = find_highest_versioned_manifest(temp_dir.path()); + assert!(result.is_none()); + } + + #[test] + fn test_find_highest_versioned_manifest_invalid_names() { + // Test that invalid versioned manifest names are ignored + let temp_dir = TempDir::new().unwrap(); + create_manifest(temp_dir.path(), "Manifest-v1.11.toml", "1.11.0"); + fs::write(temp_dir.path().join("Manifest-vInvalid.toml"), "invalid").unwrap(); + fs::write(temp_dir.path().join("Manifest-v.toml"), "invalid").unwrap(); + + let result = find_highest_versioned_manifest(temp_dir.path()); + assert!(result.is_some()); + assert_eq!(result.unwrap().file_name().unwrap(), "Manifest-v1.11.toml"); + } + + // Helper to create a portable script + fn create_portable_script(dir: &Path, name: &str, julia_version: &str) -> PathBuf { + let script_path = dir.join(name); + let script_content = format!( + indoc! {r#" + #!/usr/bin/env julia + + #!project begin + # name = "PortableScriptTest" + # uuid = "f7e12c4d-9a2b-4c3f-8e5d-6a7b8c9d0e1f" + # version = "0.1.0" + #!project end + + #!manifest begin + # julia_version = "{}" + # manifest_format = "2.0" + # project_hash = "abc123" + #!manifest end + + println("Hello from portable script!") + "#}, + julia_version + ); + fs::write(&script_path, script_content).unwrap(); + script_path + } + + fn create_portable_script_multiline(dir: &Path, name: &str, julia_version: &str) -> PathBuf { + let script_path = dir.join(name); + let script_content = format!( + indoc! {r#" + #!/usr/bin/env julia + + #!project begin + #= + name = "PortableScriptMultilineTest" + uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + version = "0.1.0" + =# + #!project end + + #!manifest begin + #= + julia_version = "{}" + manifest_format = "2.0" + project_hash = "xyz789" + =# + #!manifest end + + println("Hello from multiline portable script!") + "#}, + julia_version + ); + fs::write(&script_path, script_content).unwrap(); + script_path + } + + fn create_portable_script_with_external_manifest(dir: &Path, name: &str, manifest_path: &Path) -> PathBuf { + let script_path = dir.join(name); + let script_content = format!( + indoc! {r#" + #!/usr/bin/env julia + + #!project begin + # name = "PortableScriptExternalManifest" + # uuid = "12345678-1234-1234-1234-123456789012" + # version = "0.1.0" + # manifest = "{}" + #!project end + + println("Hello from portable script with external manifest!") + "#}, + manifest_path.display() + ); + fs::write(&script_path, script_content).unwrap(); + script_path + } + + #[test] + fn test_is_portable_script() { + // Test detecting a portable script + let temp_dir = TempDir::new().unwrap(); + let script_path = create_portable_script(temp_dir.path(), "test.jl", "1.13.0"); + + let result = is_portable_script(&script_path).unwrap(); + assert!(result); + } + + #[test] + fn test_is_portable_script_not_portable() { + // Test detecting a non-portable script + let temp_dir = TempDir::new().unwrap(); + let script_path = temp_dir.path().join("regular.jl"); + fs::write(&script_path, "println(\"Hello, World!\")").unwrap(); + + let result = is_portable_script(&script_path).unwrap(); + assert!(!result); + } + + #[test] + fn test_extract_inline_section_line_by_line() { + // Test extracting inline manifest with line-by-line comments + let temp_dir = TempDir::new().unwrap(); + let script_path = create_portable_script(temp_dir.path(), "test.jl", "1.13.0"); + + let result = extract_inline_section(&script_path, "manifest").unwrap(); + assert!(result.contains("julia_version = \"1.13.0\"")); + assert!(result.contains("manifest_format = \"2.0\"")); + assert!(result.contains("project_hash = \"abc123\"")); + } + + #[test] + fn test_extract_inline_section_multiline() { + // Test extracting inline manifest with multiline comments + let temp_dir = TempDir::new().unwrap(); + let script_path = create_portable_script_multiline(temp_dir.path(), "test.jl", "1.14.0"); + + let result = extract_inline_section(&script_path, "manifest").unwrap(); + assert!(result.contains("julia_version = \"1.14.0\"")); + assert!(result.contains("manifest_format = \"2.0\"")); + assert!(result.contains("project_hash = \"xyz789\"")); + } + + #[test] + fn test_extract_inline_section_project() { + // Test extracting inline project section + let temp_dir = TempDir::new().unwrap(); + let script_path = create_portable_script(temp_dir.path(), "test.jl", "1.13.0"); + + let result = extract_inline_section(&script_path, "project").unwrap(); + assert!(result.contains("name = \"PortableScriptTest\"")); + assert!(result.contains("uuid = \"f7e12c4d-9a2b-4c3f-8e5d-6a7b8c9d0e1f\"")); + assert!(result.contains("version = \"0.1.0\"")); + } + + #[test] + fn test_extract_inline_section_missing() { + // Test extracting non-existent inline section + let temp_dir = TempDir::new().unwrap(); + let script_path = temp_dir.path().join("regular.jl"); + fs::write(&script_path, "println(\"Hello\")").unwrap(); + + let result = extract_inline_section(&script_path, "manifest").unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_read_portable_script_julia_version() { + // Test reading julia_version from a portable script + let temp_dir = TempDir::new().unwrap(); + let script_path = create_portable_script(temp_dir.path(), "test.jl", "1.13.0"); + + let result = read_portable_script_julia_version(&script_path).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap(), "1.13.0"); + } + + #[test] + fn test_read_portable_script_julia_version_multiline() { + // Test reading julia_version from a multiline portable script + let temp_dir = TempDir::new().unwrap(); + let script_path = create_portable_script_multiline(temp_dir.path(), "test.jl", "1.14.0"); + + let result = read_portable_script_julia_version(&script_path).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap(), "1.14.0"); + } + + #[test] + fn test_read_portable_script_julia_version_missing() { + // Test reading julia_version when not present + let temp_dir = TempDir::new().unwrap(); + let script_path = temp_dir.path().join("test.jl"); + let script_content = indoc! {r#" + #!/usr/bin/env julia + + #!project begin + # name = "Test" + #!project end + + #!manifest begin + # manifest_format = "2.0" + #!manifest end + + println("Hello") + "#}; + fs::write(&script_path, script_content).unwrap(); + + let result = read_portable_script_julia_version(&script_path).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_find_script_argument() { + // Test finding a script file in arguments + let args = vec![ + "julia".to_string(), + "test.jl".to_string(), + "arg1".to_string(), + ]; + let result = find_script_argument(&args).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap().to_str().unwrap(), "test.jl"); + } + + #[test] + fn test_find_script_argument_with_channel() { + // Test finding a script file with +channel + let args = vec![ + "julia".to_string(), + "+nightly".to_string(), + "test.jl".to_string(), + "arg1".to_string(), + ]; + let result = find_script_argument(&args).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap().to_str().unwrap(), "test.jl"); + } + + #[test] + fn test_find_script_argument_with_flags() { + // Test finding a script file after flags + let args = vec![ + "julia".to_string(), + "-O3".to_string(), + "--color=yes".to_string(), + "test.jl".to_string(), + ]; + let result = find_script_argument(&args).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap().to_str().unwrap(), "test.jl"); + } + + #[test] + fn test_find_script_argument_with_eval() { + // Test that -e flag doesn't get confused with scripts + let args = vec![ + "julia".to_string(), + "-e".to_string(), + "println(1)".to_string(), + ]; + let result = find_script_argument(&args).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_find_script_argument_no_script() { + // Test when there's no script file + let args = vec![ + "julia".to_string(), + "--version".to_string(), + ]; + let result = find_script_argument(&args).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_find_script_argument_with_double_dash() { + // Test -- (end of options marker) + let args = vec![ + "julia".to_string(), + "-O3".to_string(), + "--".to_string(), + "--weird-name.jl".to_string(), + ]; + let result = find_script_argument(&args).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap().to_str().unwrap(), "--weird-name.jl"); + } + + #[test] + fn test_find_script_argument_nonexistent_file() { + // Test that we return non-existent files (Julia's behavior) + let args = vec![ + "julia".to_string(), + "nonexistent_script.jl".to_string(), + ]; + let result = find_script_argument(&args).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap().to_str().unwrap(), "nonexistent_script.jl"); + } + + #[test] + fn test_find_script_argument_with_sysimage() { + // Test -J / --sysimage flag that takes an argument + let args = vec![ + "julia".to_string(), + "-J".to_string(), + "custom.so".to_string(), + "script.jl".to_string(), + ]; + let result = find_script_argument(&args).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap().to_str().unwrap(), "script.jl"); + } + + #[test] + fn test_find_script_argument_with_threads() { + // Test -t / --threads flag that takes an argument + let args = vec![ + "julia".to_string(), + "-t".to_string(), + "4".to_string(), + "script.jl".to_string(), + ]; + let result = find_script_argument(&args).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap().to_str().unwrap(), "script.jl"); + } + + #[test] + fn test_determine_project_version_spec_portable_script() { + // Test that portable scripts are detected and their version is used + let temp_dir = TempDir::new().unwrap(); + let script_path = create_portable_script(temp_dir.path(), "test.jl", "1.13.5"); + + let args = vec![ + "julia".to_string(), + script_path.to_string_lossy().to_string(), + ]; + + let result = determine_project_version_spec(&args).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap(), "1.13.5"); + } + + #[test] + fn test_determine_project_version_spec_portable_script_multiline() { + // Test multiline portable script detection + let temp_dir = TempDir::new().unwrap(); + let script_path = create_portable_script_multiline(temp_dir.path(), "test.jl", "1.14.2"); + + let args = vec![ + "julia".to_string(), + script_path.to_string_lossy().to_string(), + ]; + + let result = determine_project_version_spec(&args).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap(), "1.14.2"); + } + + #[test] + fn test_determine_project_version_spec_portable_script_external_manifest() { + // Test portable script with external manifest specified via manifest = "path" + let temp_dir = TempDir::new().unwrap(); + + // Create external manifest file + let manifest_path = temp_dir.path().join("Manifest.toml"); + create_manifest(temp_dir.path(), "Manifest.toml", "1.15.3"); + + // Create portable script that references the external manifest + let script_path = create_portable_script_with_external_manifest( + temp_dir.path(), + "test.jl", + &manifest_path + ); + + let args = vec![ + "julia".to_string(), + script_path.to_string_lossy().to_string(), + ]; + + let result = determine_project_version_spec(&args).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap(), "1.15.3"); + } + + #[test] + fn test_determine_project_version_spec_regular_script() { + // Test that regular (non-portable) scripts don't get detected + let temp_dir = TempDir::new().unwrap(); + let script_path = temp_dir.path().join("regular.jl"); + fs::write(&script_path, "println(\"Hello, World!\")").unwrap(); + + let args = vec![ + "julia".to_string(), + script_path.to_string_lossy().to_string(), + ]; + + let result = determine_project_version_spec(&args).unwrap(); + // Should return None because it's not a portable script + assert!(result.is_none()); + } + + #[test] + fn test_determine_project_version_spec_project_overrides_portable() { + // Test that --project flag overrides portable script + let temp_dir = TempDir::new().unwrap(); + let script_path = create_portable_script(temp_dir.path(), "test.jl", "1.13.0"); + + // Create a separate project with different version + create_test_project(temp_dir.path(), "name = \"OtherProject\""); + create_manifest(temp_dir.path(), "Manifest.toml", "1.10.0"); + + let args = vec![ + "julia".to_string(), + format!("--project={}", temp_dir.path().display()), + script_path.to_string_lossy().to_string(), + ]; + + let result = determine_project_version_spec(&args).unwrap(); + assert!(result.is_some()); + // Should use the version from --project, not the portable script + assert_eq!(result.unwrap(), "1.10.0"); + } +} diff --git a/tests/project_auto_channel.rs b/tests/project_auto_channel.rs new file mode 100644 index 00000000..c81050ad --- /dev/null +++ b/tests/project_auto_channel.rs @@ -0,0 +1,63 @@ +use std::fs; +use std::path::PathBuf; + +mod utils; +use utils::TestEnv; + +fn write_project(project_dir: &PathBuf, manifest_version: &str, compat: Option<&str>) { + fs::create_dir_all(project_dir).unwrap(); + + fs::write( + project_dir.join("Project.toml"), + format!( + r#" +name = "AutoProject" +uuid = "00000000-0000-0000-0000-000000000001" +version = "0.1.0" + +{} +"#, + compat + .map(|c| format!("[compat]\njulia = \"{}\"", c)) + .unwrap_or_default() + ), + ) + .unwrap(); + + fs::write( + project_dir.join("Manifest.toml"), + format!( + r#" +julia_version = "{}" +"#, + manifest_version + ), + ) + .unwrap(); +} + +fn install_channel(env: &TestEnv, channel: &str) { + env.juliaup().arg("add").arg(channel).assert().success(); +} + +#[test] +#[ignore] +fn end_to_end_manifest_selection() { + let env = TestEnv::new(); + install_channel(&env, "1.8.2"); + + let project_dir = env.depot_path().join("manifest_project"); + write_project(&project_dir, "1.8.2", None); + + env.julia() + .arg("+auto") + .arg(format!( + "--project={}", + project_dir.as_os_str().to_string_lossy() + )) + .arg("-e") + .arg("print(VERSION)") + .assert() + .success() + .stdout("1.8.2"); +}