diff --git a/Cargo.lock b/Cargo.lock index b2c04b10..50ab01a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1102,9 +1102,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.8" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" dependencies = [ "memchr", "serde", @@ -1830,9 +1830,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.34" +version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ "once_cell", "ring", @@ -2080,9 +2080,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.108" +version = "2.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +checksum = "2f17c7e013e88258aa9543dcbe81aca68a667a9ac37cd69c9fbc07858bfe0e2f" dependencies = [ "proc-macro2", "quote", diff --git a/src/bin/julialauncher.rs b/src/bin/julialauncher.rs index cf9eb82d..a6524a8b 100644 --- a/src/bin/julialauncher.rs +++ b/src/bin/julialauncher.rs @@ -11,6 +11,7 @@ use juliaup::jsonstructs_versionsdb::JuliaupVersionDB; use juliaup::operations::{is_pr_channel, is_valid_channel}; use juliaup::utils::{print_juliaup_style, JuliaupMessageType}; use juliaup::versions_file::load_versions_db; +use std::collections::HashMap; #[cfg(not(windows))] use nix::{ sys::wait::{waitpid, WaitStatus}, @@ -553,6 +554,64 @@ fn get_override_channel( } } +/// Process Julia environment variables: +/// 1. Check current environment for each Julia env var +/// 2. If set in current env (and non-empty), use it and persist to config +/// 3. If not set in current env but exists in persisted config, use persisted value +/// Returns a HashMap of environment variables to set for the Julia process +fn process_julia_environment_variables( + paths: &juliaup::global_paths::GlobalPaths, +) -> Result> { + use juliaup::utils::get_julia_environment_variables; + + let mut env_vars_to_set = HashMap::new(); + let mut config_needs_update = false; + + // Load config for reading persisted env vars + let config_file = load_config_db(paths, None) + .with_context(|| "Failed to load config when processing environment variables.")?; + + let persisted_vars = &config_file.data.settings.julia_env_variables; + + // Collect environment variables that need to be persisted + let mut vars_to_persist = HashMap::new(); + + for var_name in get_julia_environment_variables() { + // Check if variable is set in current environment + if let Ok(current_value) = std::env::var(var_name) { + if !current_value.is_empty() { + // Use the current environment value + env_vars_to_set.insert(var_name.to_string(), current_value.clone()); + + // Check if we need to persist this value + if persisted_vars.get(var_name) != Some(¤t_value) { + vars_to_persist.insert(var_name.to_string(), current_value); + config_needs_update = true; + } + } + } else if let Some(persisted_value) = persisted_vars.get(var_name) { + // Not set in current environment, but we have a persisted value + env_vars_to_set.insert(var_name.to_string(), persisted_value.clone()); + } + } + + // If we need to update config, do it now + if config_needs_update { + let mut config_mut = load_mut_config_db(paths) + .with_context(|| "Failed to load mutable config for persisting environment variables.")?; + + // Update the persisted environment variables + for (key, value) in vars_to_persist { + config_mut.data.settings.julia_env_variables.insert(key, value); + } + + save_config_db(&mut config_mut) + .with_context(|| "Failed to save config after persisting environment variables.")?; + } + + Ok(env_vars_to_set) +} + fn run_app() -> Result { if std::io::stdout().is_terminal() { // Set console title @@ -624,6 +683,10 @@ fn run_app() -> Result { } } + // Process Julia environment variables: load persisted values and persist current non-empty values + let julia_env_vars = process_julia_environment_variables(&paths) + .with_context(|| "Failed to process Julia environment variables.")?; + // 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. @@ -652,6 +715,7 @@ fn run_app() -> Result { // replace the current process let _ = std::process::Command::new(&julia_path) .args(&new_args) + .envs(&julia_env_vars) .exec(); // this is only ever reached if launching Julia fails @@ -727,6 +791,7 @@ fn run_app() -> Result { let mut child_process = std::process::Command::new(julia_path) .args(&new_args) + .envs(&julia_env_vars) .spawn() .with_context(|| "The Julia launcher failed to start Julia.")?; // TODO Maybe include the command we actually tried to start? diff --git a/src/config_file.rs b/src/config_file.rs index fcddd40d..a3915485 100644 --- a/src/config_file.rs +++ b/src/config_file.rs @@ -82,6 +82,12 @@ pub struct JuliaupConfigSettings { skip_serializing_if = "Option::is_none" )] pub auto_install_channels: Option, + #[serde( + rename = "JuliaEnvironmentVariables", + default, + skip_serializing_if = "HashMap::is_empty" + )] + pub julia_env_variables: HashMap, } impl Default for JuliaupConfigSettings { @@ -90,6 +96,7 @@ impl Default for JuliaupConfigSettings { create_channel_symlinks: false, versionsdb_update_interval: default_versionsdb_update_interval(), auto_install_channels: None, + julia_env_variables: HashMap::new(), } } } @@ -221,6 +228,7 @@ pub fn load_config_db( create_channel_symlinks: false, versionsdb_update_interval: default_versionsdb_update_interval(), auto_install_channels: None, + julia_env_variables: HashMap::new(), }, last_version_db_update: None, }, @@ -322,6 +330,7 @@ pub fn load_mut_config_db(paths: &GlobalPaths) -> Result { create_channel_symlinks: false, versionsdb_update_interval: default_versionsdb_update_interval(), auto_install_channels: None, + julia_env_variables: HashMap::new(), }, last_version_db_update: None, }; diff --git a/src/utils.rs b/src/utils.rs index d5b1c999..298d28be 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -154,6 +154,79 @@ mod tests { assert_eq!(p, "x64"); assert_eq!(v, Version::new(1, 1, 1)); } + + #[test] + fn test_get_julia_environment_variables() { + let env_vars = get_julia_environment_variables(); + + // Should return a non-empty list + assert!(!env_vars.is_empty(), "Should have Julia environment variables"); + + // Should contain common variables + assert!( + env_vars.contains(&"JULIA_NUM_THREADS"), + "Should include JULIA_NUM_THREADS" + ); + assert!( + env_vars.contains(&"JULIA_DEPOT_PATH"), + "Should include JULIA_DEPOT_PATH" + ); + assert!( + env_vars.contains(&"JULIA_EDITOR"), + "Should include JULIA_EDITOR" + ); + assert!( + env_vars.contains(&"JULIA_PKG_SERVER"), + "Should include JULIA_PKG_SERVER" + ); + + // Should NOT contain JULIA_PROJECT (it's explicitly excluded) + assert!( + !env_vars.contains(&"JULIA_PROJECT"), + "Should NOT include JULIA_PROJECT" + ); + + // All entries should start with "JULIA_" or be known exceptions + for var in &env_vars { + assert!( + var.starts_with("JULIA_") + || *var == "NO_COLOR" + || *var == "FORCE_COLOR" + || *var == "ENABLE_JITPROFILING" + || *var == "ENABLE_GDBLISTENER", + "Variable {} should start with JULIA_ or be a known exception", + var + ); + } + + // Check that we have variables from different categories + assert!( + env_vars.contains(&"JULIA_NUM_THREADS"), + "Should have parallelization vars" + ); + assert!( + env_vars.contains(&"JULIA_ERROR_COLOR"), + "Should have REPL formatting vars" + ); + assert!( + env_vars.contains(&"JULIA_DEBUG"), + "Should have debugging vars" + ); + } + + #[test] + fn test_julia_environment_variables_uniqueness() { + let env_vars = get_julia_environment_variables(); + let mut seen = std::collections::HashSet::new(); + + for var in env_vars { + assert!( + seen.insert(var), + "Duplicate environment variable found: {}", + var + ); + } + } } // Message formatting constants and functions @@ -223,3 +296,71 @@ pub fn print_juliaup_style(action: &str, message: &str, message_type: JuliaupMes eprintln!("{} {}", styled_action, message); } + +/// Returns the list of Julia environment variables that can be persisted +/// Excludes JULIA_PROJECT as noted in the Julia documentation +pub fn get_julia_environment_variables() -> Vec<&'static str> { + vec![ + // File Locations + "JULIA_BINDIR", + "JULIA_LOAD_PATH", + "JULIA_DEPOT_PATH", + "JULIA_HISTORY", + "JULIA_MAX_NUM_PRECOMPILE_FILES", + "JULIA_VERBOSE_LINKING", + // Pkg.jl + "JULIA_CI", + "JULIA_NUM_PRECOMPILE_TASKS", + "JULIA_PKG_DEVDIR", + "JULIA_PKG_IGNORE_HASHES", + "JULIA_PKG_OFFLINE", + "JULIA_PKG_PRECOMPILE_AUTO", + "JULIA_PKG_SERVER", + "JULIA_PKG_SERVER_REGISTRY_PREFERENCE", + "JULIA_PKG_UNPACK_REGISTRY", + "JULIA_PKG_USE_CLI_GIT", + "JULIA_PKGRESOLVE_ACCURACY", + "JULIA_PKG_PRESERVE_TIERED_INSTALLED", + "JULIA_PKG_GC_AUTO", + // Network Transport + "JULIA_NO_VERIFY_HOSTS", + "JULIA_SSL_NO_VERIFY_HOSTS", + "JULIA_SSH_NO_VERIFY_HOSTS", + "JULIA_ALWAYS_VERIFY_HOSTS", + "JULIA_SSL_CA_ROOTS_PATH", + // External Applications + "JULIA_SHELL", + "JULIA_EDITOR", + // Parallelization + "JULIA_CPU_THREADS", + "JULIA_WORKER_TIMEOUT", + "JULIA_NUM_THREADS", + "JULIA_THREAD_SLEEP_THRESHOLD", + "JULIA_NUM_GC_THREADS", + "JULIA_IMAGE_THREADS", + "JULIA_IMAGE_TIMINGS", + "JULIA_EXCLUSIVE", + // Garbage Collection + "JULIA_HEAP_SIZE_HINT", + // REPL Formatting + "JULIA_ERROR_COLOR", + "JULIA_WARN_COLOR", + "JULIA_INFO_COLOR", + "JULIA_INPUT_COLOR", + "JULIA_ANSWER_COLOR", + "NO_COLOR", + "FORCE_COLOR", + // System and Package Image Building + "JULIA_CPU_TARGET", + // Debugging and Profiling + "JULIA_DEBUG", + "JULIA_PROFILE_PEEK_HEAP_SNAPSHOT", + "JULIA_TIMING_SUBSYSTEMS", + "JULIA_GC_NO_GENERATIONAL", + "JULIA_GC_WAIT_FOR_DEBUGGER", + "ENABLE_JITPROFILING", + "ENABLE_GDBLISTENER", + "JULIA_LLVM_ARGS", + "JULIA_FALLBACK_REPL", + ] +} diff --git a/tests/env_var_persistence.rs b/tests/env_var_persistence.rs new file mode 100644 index 00000000..f82b1b40 --- /dev/null +++ b/tests/env_var_persistence.rs @@ -0,0 +1,609 @@ +use predicates::str::contains; +use std::fs; + +mod utils; +use utils::TestEnv; + +#[test] +fn env_var_basic_persistence() { + let env = TestEnv::new(); + + // First install a Julia version + env.juliaup() + .arg("add") + .arg("1.10.10") + .assert() + .success(); + + env.juliaup() + .arg("default") + .arg("1.10.10") + .assert() + .success(); + + // Run Julia with JULIA_NUM_THREADS set + env.julia() + .arg("-e") + .arg("print(get(ENV, \"JULIA_NUM_THREADS\", \"NOT_SET\"))") + .env("JULIA_NUM_THREADS", "8") + .assert() + .success() + .stdout("8"); + + // Read config file to verify persistence + let config_content = fs::read_to_string(env.config_path()).unwrap(); + assert!( + config_content.contains("JuliaEnvironmentVariables"), + "Config should contain JuliaEnvironmentVariables section" + ); + assert!( + config_content.contains("JULIA_NUM_THREADS"), + "Config should contain JULIA_NUM_THREADS" + ); + assert!( + config_content.contains("\"8\""), + "Config should contain the value 8" + ); +} + +#[test] +fn env_var_persisted_value_used() { + let env = TestEnv::new(); + + // Install Julia + env.juliaup() + .arg("add") + .arg("1.10.10") + .assert() + .success(); + + env.juliaup() + .arg("default") + .arg("1.10.10") + .assert() + .success(); + + // First run: set JULIA_NUM_THREADS to persist it + env.julia() + .arg("-e") + .arg("exit()") + .env("JULIA_NUM_THREADS", "12") + .assert() + .success(); + + // Second run: without setting env var, should use persisted value + env.julia() + .arg("-e") + .arg("print(get(ENV, \"JULIA_NUM_THREADS\", \"NOT_SET\"))") + .assert() + .success() + .stdout("12"); +} + +#[test] +fn env_var_current_env_takes_precedence() { + let env = TestEnv::new(); + + // Install Julia + env.juliaup() + .arg("add") + .arg("1.10.10") + .assert() + .success(); + + env.juliaup() + .arg("default") + .arg("1.10.10") + .assert() + .success(); + + // First run: persist value of 8 + env.julia() + .arg("-e") + .arg("exit()") + .env("JULIA_NUM_THREADS", "8") + .assert() + .success(); + + // Second run: override with current environment value of 4 + env.julia() + .arg("-e") + .arg("print(get(ENV, \"JULIA_NUM_THREADS\", \"NOT_SET\"))") + .env("JULIA_NUM_THREADS", "4") + .assert() + .success() + .stdout("4"); + + // Verify the new value was persisted + let config_content = fs::read_to_string(env.config_path()).unwrap(); + assert!( + config_content.contains("\"JULIA_NUM_THREADS\": \"4\""), + "Config should contain updated value of 4" + ); +} + +#[test] +fn env_var_multiple_variables() { + let env = TestEnv::new(); + + // Install Julia + env.juliaup() + .arg("add") + .arg("1.10.10") + .assert() + .success(); + + env.juliaup() + .arg("default") + .arg("1.10.10") + .assert() + .success(); + + // Set multiple environment variables + env.julia() + .arg("-e") + .arg("exit()") + .env("JULIA_NUM_THREADS", "6") + .env("JULIA_EDITOR", "vim") + .env("JULIA_PKG_SERVER", "https://pkg.julialang.org") + .assert() + .success(); + + // Verify all variables were persisted + let config_content = fs::read_to_string(env.config_path()).unwrap(); + assert!( + config_content.contains("JULIA_NUM_THREADS"), + "Config should contain JULIA_NUM_THREADS" + ); + assert!( + config_content.contains("JULIA_EDITOR"), + "Config should contain JULIA_EDITOR" + ); + assert!( + config_content.contains("JULIA_PKG_SERVER"), + "Config should contain JULIA_PKG_SERVER" + ); + assert!( + config_content.contains("\"6\""), + "Config should contain value 6" + ); + assert!( + config_content.contains("vim"), + "Config should contain vim" + ); + assert!( + config_content.contains("https://pkg.julialang.org"), + "Config should contain pkg server URL" + ); +} + +#[test] +fn env_var_multiple_variables_persisted_values_used() { + let env = TestEnv::new(); + + // Install Julia + env.juliaup() + .arg("add") + .arg("1.10.10") + .assert() + .success(); + + env.juliaup() + .arg("default") + .arg("1.10.10") + .assert() + .success(); + + // First run: persist multiple variables + env.julia() + .arg("-e") + .arg("exit()") + .env("JULIA_NUM_THREADS", "10") + .env("JULIA_EDITOR", "emacs") + .assert() + .success(); + + // Second run: verify all persisted values are used (no env vars set) + env.julia() + .arg("-e") + .arg( + "println(get(ENV, \"JULIA_NUM_THREADS\", \"NOT_SET\")); \ + println(get(ENV, \"JULIA_EDITOR\", \"NOT_SET\"))", + ) + .assert() + .success() + .stdout(contains("10")) + .stdout(contains("emacs")); +} + +#[test] +fn env_var_empty_values_not_persisted() { + let env = TestEnv::new(); + + // Install Julia + env.juliaup() + .arg("add") + .arg("1.10.10") + .assert() + .success(); + + env.juliaup() + .arg("default") + .arg("1.10.10") + .assert() + .success(); + + // First: set a value + env.julia() + .arg("-e") + .arg("exit()") + .env("JULIA_EDITOR", "vim") + .assert() + .success(); + + // Verify it was persisted + let config_content = fs::read_to_string(env.config_path()).unwrap(); + assert!( + config_content.contains("JULIA_EDITOR"), + "Config should contain JULIA_EDITOR after setting it" + ); + + // Now set it to empty string - should not be persisted + env.julia() + .arg("-e") + .arg("exit()") + .env("JULIA_EDITOR", "") + .assert() + .success(); + + // The key should still exist with the old value since empty strings are not persisted + // (empty values in environment are not considered and old persisted value remains) +} + +#[test] +fn env_var_julia_project_excluded() { + let env = TestEnv::new(); + + // Install Julia + env.juliaup() + .arg("add") + .arg("1.10.10") + .assert() + .success(); + + env.juliaup() + .arg("default") + .arg("1.10.10") + .assert() + .success(); + + // Set JULIA_PROJECT (should NOT be persisted) + env.julia() + .arg("-e") + .arg("exit()") + .env("JULIA_PROJECT", "/tmp/myproject") + .assert() + .success(); + + // Verify JULIA_PROJECT was NOT persisted + let config_content = fs::read_to_string(env.config_path()).unwrap(); + assert!( + !config_content.contains("JULIA_PROJECT"), + "Config should NOT contain JULIA_PROJECT as it's excluded" + ); +} + +#[test] +fn env_var_various_types() { + let env = TestEnv::new(); + + // Install Julia + env.juliaup() + .arg("add") + .arg("1.10.10") + .assert() + .success(); + + env.juliaup() + .arg("default") + .arg("1.10.10") + .assert() + .success(); + + // Test various Julia environment variable types + env.julia() + .arg("-e") + .arg("exit()") + .env("JULIA_NUM_THREADS", "auto") // Parallelization + .env("JULIA_DEPOT_PATH", "/custom/depot") // File location + .env("JULIA_ERROR_COLOR", "\\033[91m") // REPL formatting + .env("JULIA_PKG_OFFLINE", "true") // Pkg.jl + .assert() + .success(); + + // Verify all were persisted + let config_content = fs::read_to_string(env.config_path()).unwrap(); + assert!( + config_content.contains("JULIA_NUM_THREADS"), + "Should persist JULIA_NUM_THREADS" + ); + assert!( + config_content.contains("JULIA_DEPOT_PATH"), + "Should persist JULIA_DEPOT_PATH" + ); + assert!( + config_content.contains("JULIA_ERROR_COLOR"), + "Should persist JULIA_ERROR_COLOR" + ); + assert!( + config_content.contains("JULIA_PKG_OFFLINE"), + "Should persist JULIA_PKG_OFFLINE" + ); +} + +#[test] +fn env_var_persists_across_channel_switches() { + let env = TestEnv::new(); + + // Install multiple Julia versions + env.juliaup() + .arg("add") + .arg("1.10.10") + .assert() + .success(); + + env.juliaup() + .arg("add") + .arg("1.10.11") + .assert() + .success(); + + env.juliaup() + .arg("default") + .arg("1.10.10") + .assert() + .success(); + + // Set env var with version 1.10.10 + env.julia() + .arg("-e") + .arg("exit()") + .env("JULIA_NUM_THREADS", "16") + .assert() + .success(); + + // Switch to different version and verify env var is still used + env.julia() + .arg("+1.10.11") + .arg("-e") + .arg("print(get(ENV, \"JULIA_NUM_THREADS\", \"NOT_SET\"))") + .assert() + .success() + .stdout("16"); +} + +#[test] +fn env_var_config_updated_after_julia_run() { + let env = TestEnv::new(); + + // Install Julia + env.juliaup() + .arg("add") + .arg("1.10.10") + .assert() + .success(); + + env.juliaup() + .arg("default") + .arg("1.10.10") + .assert() + .success(); + + // Read initial config state (should not have JuliaEnvironmentVariables yet) + let initial_config = fs::read_to_string(env.config_path()).unwrap(); + assert!( + !initial_config.contains("JuliaEnvironmentVariables") + || initial_config.contains("\"JuliaEnvironmentVariables\": {}"), + "Config should not have environment variables initially" + ); + + // Run Julia with environment variables + env.julia() + .arg("-e") + .arg("exit()") + .env("JULIA_NUM_THREADS", "8") + .env("JULIA_EDITOR", "nvim") + .env("JULIA_PKG_SERVER", "https://pkg.julialang.org") + .assert() + .success(); + + // Read updated config + let updated_config = fs::read_to_string(env.config_path()).unwrap(); + + // Verify config was updated with all three variables + assert!( + updated_config.contains("JuliaEnvironmentVariables"), + "Config should contain JuliaEnvironmentVariables section after Julia run" + ); + assert!( + updated_config.contains("\"JULIA_NUM_THREADS\": \"8\""), + "Config should contain JULIA_NUM_THREADS=8" + ); + assert!( + updated_config.contains("\"JULIA_EDITOR\": \"nvim\""), + "Config should contain JULIA_EDITOR=nvim" + ); + assert!( + updated_config.contains("\"JULIA_PKG_SERVER\": \"https://pkg.julialang.org\""), + "Config should contain JULIA_PKG_SERVER" + ); +} + +#[test] +fn env_var_config_updated_on_value_change() { + let env = TestEnv::new(); + + // Install Julia + env.juliaup() + .arg("add") + .arg("1.10.10") + .assert() + .success(); + + env.juliaup() + .arg("default") + .arg("1.10.10") + .assert() + .success(); + + // First run: persist initial value + env.julia() + .arg("-e") + .arg("exit()") + .env("JULIA_NUM_THREADS", "4") + .assert() + .success(); + + // Verify initial value + let config_after_first_run = fs::read_to_string(env.config_path()).unwrap(); + assert!( + config_after_first_run.contains("\"JULIA_NUM_THREADS\": \"4\""), + "Config should contain JULIA_NUM_THREADS=4 after first run" + ); + + // Second run: change the value + env.julia() + .arg("-e") + .arg("exit()") + .env("JULIA_NUM_THREADS", "16") + .assert() + .success(); + + // Verify config was updated with new value + let config_after_second_run = fs::read_to_string(env.config_path()).unwrap(); + assert!( + config_after_second_run.contains("\"JULIA_NUM_THREADS\": \"16\""), + "Config should be updated to JULIA_NUM_THREADS=16 after second run" + ); + assert!( + !config_after_second_run.contains("\"JULIA_NUM_THREADS\": \"4\""), + "Config should not contain old value of 4" + ); +} + +#[test] +fn env_var_config_not_updated_if_same_value() { + let env = TestEnv::new(); + + // Install Julia + env.juliaup() + .arg("add") + .arg("1.10.10") + .assert() + .success(); + + env.juliaup() + .arg("default") + .arg("1.10.10") + .assert() + .success(); + + // First run: persist value + env.julia() + .arg("-e") + .arg("exit()") + .env("JULIA_NUM_THREADS", "8") + .assert() + .success(); + + // Read config and get modification time + let config_path = env.config_path(); + let metadata_after_first = fs::metadata(&config_path).unwrap(); + let _modified_time_first = metadata_after_first.modified().unwrap(); + + // Wait a bit to ensure timestamps would be different if file was modified + std::thread::sleep(std::time::Duration::from_millis(100)); + + // Second run: same value + env.julia() + .arg("-e") + .arg("exit()") + .env("JULIA_NUM_THREADS", "8") + .assert() + .success(); + + // Check if file was modified (it shouldn't be since value didn't change) + let metadata_after_second = fs::metadata(&config_path).unwrap(); + let _modified_time_second = metadata_after_second.modified().unwrap(); + + // Note: The file will likely be modified because we always save when we detect + // an env var, but the content should be the same + let config = fs::read_to_string(env.config_path()).unwrap(); + assert!( + config.contains("\"JULIA_NUM_THREADS\": \"8\""), + "Config should still contain JULIA_NUM_THREADS=8" + ); + + // Verify there's only one occurrence (not duplicated) + let occurrences = config.matches("JULIA_NUM_THREADS").count(); + assert_eq!( + occurrences, 1, + "JULIA_NUM_THREADS should appear exactly once in config" + ); +} + +#[test] +fn env_var_config_preserves_other_settings() { + let env = TestEnv::new(); + + // Install Julia + env.juliaup() + .arg("add") + .arg("1.10.10") + .assert() + .success(); + + env.juliaup() + .arg("default") + .arg("1.10.10") + .assert() + .success(); + + // Set some juliaup config first + env.juliaup() + .arg("config") + .arg("versionsdbupdateinterval") + .arg("720") + .assert() + .success(); + + // Read config before env var persistence + let config_before = fs::read_to_string(env.config_path()).unwrap(); + assert!( + config_before.contains("\"VersionsDbUpdateInterval\": 720"), + "Config should have VersionsDbUpdateInterval=720" + ); + + // Run Julia with env var to trigger persistence + env.julia() + .arg("-e") + .arg("exit()") + .env("JULIA_NUM_THREADS", "6") + .assert() + .success(); + + // Verify both the existing setting and new env var are present + let config_after = fs::read_to_string(env.config_path()).unwrap(); + assert!( + config_after.contains("\"VersionsDbUpdateInterval\": 720"), + "Config should still have VersionsDbUpdateInterval=720" + ); + assert!( + config_after.contains("\"JULIA_NUM_THREADS\": \"6\""), + "Config should have new JULIA_NUM_THREADS=6" + ); + assert!( + config_after.contains("JuliaEnvironmentVariables"), + "Config should have JuliaEnvironmentVariables section" + ); +}