diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index d48bd9a67..38fd467a1 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -517,6 +517,28 @@ impl Config { .context("deserialize merged ECC 2.0 config") } + #[cfg(test)] + fn config_root() -> PathBuf { + // Each test thread gets its own scratch root, so `Config::save()` can + // never overwrite the developer's real config. + struct TestConfigRoot(PathBuf); + + impl Drop for TestConfigRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + thread_local! { + static ROOT: TestConfigRoot = TestConfigRoot( + std::env::temp_dir().join(format!("ecc2-test-config-{}", uuid::Uuid::new_v4())), + ); + } + + ROOT.with(|root| root.0.clone()) + } + + #[cfg(not(test))] fn config_root() -> PathBuf { dirs::config_dir().unwrap_or_else(|| { dirs::home_dir() @@ -525,6 +547,12 @@ impl Config { }) } + #[cfg(test)] + fn legacy_global_config_path() -> PathBuf { + Self::config_root().join(".claude").join("ecc2.toml") + } + + #[cfg(not(test))] fn legacy_global_config_path() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) @@ -1095,6 +1123,32 @@ focus_metrics = "e" let _ = std::fs::remove_dir_all(tempdir); } + #[test] + fn config_path_under_test_is_sandboxed_away_from_user_config() { + let path = Config::config_path(); + assert!( + path.starts_with(std::env::temp_dir()), + "tests must never resolve the real user config, got {}", + path.display() + ); + } + + #[test] + fn legacy_config_path_under_test_is_sandboxed_away_from_user_home() { + let path = Config::legacy_global_config_path(); + assert!( + path.starts_with(std::env::temp_dir()), + "tests must never read the real legacy user config, got {}", + path.display() + ); + assert!(path.ends_with(".claude/ecc2.toml")); + } + + #[test] + fn config_path_under_test_is_stable_within_a_test() { + assert_eq!(Config::config_path(), Config::config_path()); + } + #[test] fn primary_config_path_uses_xdg_style_location() { let path = Config::config_path();