diff --git a/Cargo.lock b/Cargo.lock index 803a446..2820dcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1667,6 +1667,7 @@ dependencies = [ "assert_fs", "async-trait", "clap", + "etcetera", "git-conventional", "inquire", "jj-lib", diff --git a/Cargo.toml b/Cargo.toml index d76de0c..f671812 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ test-utils = [] [dependencies] async-trait = "0.1.89" +etcetera = "0.11.0" clap = { version = "4.5.57", features = ["derive"] } git-conventional = "0.12.9" inquire = "0.9.2" diff --git a/src/jj/lib_executor.rs b/src/jj/lib_executor.rs index af8fd5a..db07f51 100644 --- a/src/jj/lib_executor.rs +++ b/src/jj/lib_executor.rs @@ -5,8 +5,9 @@ use std::path::{Path, PathBuf}; +use etcetera::BaseStrategy; use jj_lib::{ - config::StackedConfig, + config::{ConfigSource, StackedConfig}, ref_name::WorkspaceName, repo::{Repo, StoreFactories}, settings::UserSettings, @@ -37,9 +38,62 @@ impl JjLib { } fn load_settings() -> Result { - let config = StackedConfig::with_defaults(); + let mut config = StackedConfig::with_defaults(); + for path in Self::user_config_paths() { + if path.is_dir() { + config + .load_dir(ConfigSource::User, &path) + .map_err(|_| Error::FailedReadingConfig)?; + } else if path.exists() { + config + .load_file(ConfigSource::User, path) + .map_err(|_| Error::FailedReadingConfig)?; + } + } UserSettings::from_config(config).map_err(|_| Error::FailedReadingConfig) } + + /// Resolves user config file paths following the same logic as the jj CLI: + /// 1. `$JJ_CONFIG` (colon-separated list), if set + /// 2. `~/.jjconfig.toml` (if it exists, or no platform config dir is available) + /// 3. Platform config dir `/jj/config.toml` (e.g. `~/.config/jj/config.toml`) + /// 4. Platform config dir `/jj/conf.d/` (if it exists) + fn user_config_paths() -> Vec { + if let Ok(paths) = std::env::var("JJ_CONFIG") { + return std::env::split_paths(&paths) + .filter(|p| !p.as_os_str().is_empty()) + .collect(); + } + + let home_dir = etcetera::home_dir().ok(); + let config_dir = etcetera::choose_base_strategy() + .ok() + .map(|s| s.config_dir()); + + let home_config = home_dir.as_ref().map(|h| h.join(".jjconfig.toml")); + let platform_config = config_dir + .as_ref() + .map(|c| c.join("jj").join("config.toml")); + let platform_conf_d = config_dir.as_ref().map(|c| c.join("jj").join("conf.d")); + + let mut paths = Vec::new(); + + if let Some(path) = home_config + && (path.exists() || platform_config.is_none()) + { + paths.push(path); + } + if let Some(path) = platform_config { + paths.push(path); + } + if let Some(path) = platform_conf_d + && path.exists() + { + paths.push(path); + } + + paths + } } #[async_trait::async_trait(?Send)] @@ -312,4 +366,155 @@ mod tests { fn accepts_executor(_: impl JjExecutor) {} accepts_executor(lib); } + + mod user_config_paths_tests { + use super::*; + use std::sync::{LazyLock, Mutex}; + + /// Serialize all tests that mutate environment variables to prevent + /// data races: env vars are process-global in Rust's test runner. + static ENV_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); + + /// RAII guard that restores an environment variable on drop. + struct EnvGuard { + key: &'static str, + original: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: impl AsRef) -> Self { + let original = std::env::var(key).ok(); + // SAFETY: tests hold ENV_LOCK, so no concurrent env mutation + unsafe { std::env::set_var(key, value) }; + Self { key, original } + } + + fn remove(key: &'static str) -> Self { + let original = std::env::var(key).ok(); + // SAFETY: tests hold ENV_LOCK, so no concurrent env mutation + unsafe { std::env::remove_var(key) }; + Self { key, original } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.original { + // SAFETY: tests hold ENV_LOCK, so no concurrent env mutation + Some(v) => unsafe { std::env::set_var(self.key, v) }, + None => unsafe { std::env::remove_var(self.key) }, + } + } + } + + #[test] + fn jj_config_single_path_returned_directly() { + let _lock = ENV_LOCK.lock().unwrap(); + let temp = assert_fs::TempDir::new().unwrap(); + let config_path = temp.path().join("config.toml"); + + let _jj = EnvGuard::set("JJ_CONFIG", &config_path); + + assert_eq!(JjLib::user_config_paths(), vec![config_path]); + } + + #[test] + fn jj_config_multiple_paths_all_returned() { + let _lock = ENV_LOCK.lock().unwrap(); + let temp = assert_fs::TempDir::new().unwrap(); + let path_a = temp.path().join("a.toml"); + let path_b = temp.path().join("b.toml"); + let combined = std::env::join_paths([&path_a, &path_b]).unwrap(); + + let _jj = EnvGuard::set("JJ_CONFIG", &combined); + + assert_eq!(JjLib::user_config_paths(), vec![path_a, path_b]); + } + + #[test] + fn jj_config_empty_segments_are_filtered_out() { + let _lock = ENV_LOCK.lock().unwrap(); + let temp = assert_fs::TempDir::new().unwrap(); + let config_path = temp.path().join("config.toml"); + // Wrap the path with empty segments (Unix colon separators) + let value = format!(":{}:", config_path.display()); + + let _jj = EnvGuard::set("JJ_CONFIG", &value); + + assert_eq!(JjLib::user_config_paths(), vec![config_path]); + } + + #[test] + fn platform_config_toml_always_included_when_xdg_set() { + let _lock = ENV_LOCK.lock().unwrap(); + let temp = assert_fs::TempDir::new().unwrap(); + + let _jj = EnvGuard::remove("JJ_CONFIG"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", temp.path()); + + let paths = JjLib::user_config_paths(); + + assert!(paths.contains(&temp.path().join("jj").join("config.toml"))); + } + + #[test] + fn home_config_included_when_it_exists() { + let _lock = ENV_LOCK.lock().unwrap(); + let temp = assert_fs::TempDir::new().unwrap(); + let home_config = temp.path().join(".jjconfig.toml"); + std::fs::write(&home_config, "").unwrap(); + + let _jj = EnvGuard::remove("JJ_CONFIG"); + let _home = EnvGuard::set("HOME", temp.path()); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", temp.path().join("xdg")); + + let paths = JjLib::user_config_paths(); + + assert!(paths.contains(&home_config)); + } + + #[test] + fn home_config_excluded_when_missing_and_platform_config_is_available() { + let _lock = ENV_LOCK.lock().unwrap(); + let temp = assert_fs::TempDir::new().unwrap(); + // ~/.jjconfig.toml intentionally not created + + let _jj = EnvGuard::remove("JJ_CONFIG"); + let _home = EnvGuard::set("HOME", temp.path()); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", temp.path().join("xdg")); + + let paths = JjLib::user_config_paths(); + + assert!(!paths.contains(&temp.path().join(".jjconfig.toml"))); + } + + #[test] + fn conf_d_included_when_directory_exists() { + let _lock = ENV_LOCK.lock().unwrap(); + let temp = assert_fs::TempDir::new().unwrap(); + let conf_d = temp.path().join("jj").join("conf.d"); + std::fs::create_dir_all(&conf_d).unwrap(); + + let _jj = EnvGuard::remove("JJ_CONFIG"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", temp.path()); + + let paths = JjLib::user_config_paths(); + + assert!(paths.contains(&conf_d)); + } + + #[test] + fn conf_d_excluded_when_directory_is_missing() { + let _lock = ENV_LOCK.lock().unwrap(); + let temp = assert_fs::TempDir::new().unwrap(); + // conf.d intentionally not created + + let _jj = EnvGuard::remove("JJ_CONFIG"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", temp.path()); + + let paths = JjLib::user_config_paths(); + + assert!(!paths.contains(&temp.path().join("jj").join("conf.d"))); + } + } }