diff --git a/src/config.rs b/src/config.rs index 7ffea86d..76dffb8b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -127,6 +127,34 @@ pub fn limits() -> LimitsConfig { Config::load().map(|c| c.limits).unwrap_or_default() } +/// Repo-level config — only fields that make sense at repo scope. +/// Absent sections deserialize as None (not overridden). +#[derive(Debug, Deserialize, Default)] +struct RepoConfig { + #[serde(default)] + hooks: Option, + #[serde(default)] + filters: Option, +} + +/// Walk up from `start` to find `.rtk.toml`, stopping at `.git` boundary. +fn find_repo_config_from(start: &std::path::Path) -> Option { + let mut dir = start.to_path_buf(); + loop { + let candidate = dir.join(".rtk.toml"); + if candidate.exists() { + return Some(candidate); + } + // Stop at .git boundary (repo root) + if dir.join(".git").exists() { + return None; + } + if !dir.pop() { + return None; + } + } +} + /// Check if telemetry is enabled in config. Returns None if config can't be loaded. pub fn telemetry_enabled() -> Option { Config::load().ok().map(|c| c.telemetry.enabled) @@ -134,14 +162,45 @@ pub fn telemetry_enabled() -> Option { impl Config { pub fn load() -> Result { - let path = get_config_path()?; + let cwd = std::env::current_dir().unwrap_or_default(); + Self::load_from_dir(&cwd) + } - if path.exists() { - let content = std::fs::read_to_string(&path)?; - let config: Config = toml::from_str(&content)?; - Ok(config) + /// Load config: user-level first, then merge repo-level `.rtk.toml` if found. + pub fn load_from_dir(cwd: &std::path::Path) -> Result { + let user_path = get_config_path()?; + let mut config = if user_path.exists() { + let content = std::fs::read_to_string(&user_path)?; + toml::from_str(&content)? } else { - Ok(Config::default()) + Config::default() + }; + + if let Some(repo_path) = find_repo_config_from(cwd) { + if let Ok(repo_toml) = std::fs::read_to_string(&repo_path) { + config.merge_repo(&repo_toml); + } + } + + Ok(config) + } + + /// Merge repo-level config on top of self. Repo wins for present fields. + /// Only `[hooks]` and `[filters]` are repo-scoped. + fn merge_repo(&mut self, repo_toml: &str) { + let repo: RepoConfig = match toml::from_str(repo_toml) { + Ok(r) => r, + Err(e) => { + eprintln!("rtk: invalid .rtk.toml: {}", e); + return; + } + }; + + if let Some(hooks) = repo.hooks { + self.hooks = hooks; + } + if let Some(filters) = repo.filters { + self.filters = filters; } } @@ -170,20 +229,27 @@ fn get_config_path() -> Result { } pub fn show_config() -> Result<()> { - let path = get_config_path()?; - println!("Config: {}", path.display()); - println!(); + let user_path = get_config_path()?; + println!("User config: {}", user_path.display()); + if user_path.exists() { + println!(" (found)"); + } else { + println!(" (not found, using defaults)"); + } - if path.exists() { - let config = Config::load()?; - println!("{}", toml::to_string_pretty(&config)?); + let cwd = std::env::current_dir().unwrap_or_default(); + if let Some(repo_path) = find_repo_config_from(&cwd) { + println!("Repo config: {}", repo_path.display()); } else { - println!("(default config, file not created)"); - println!(); - let config = Config::default(); - println!("{}", toml::to_string_pretty(&config)?); + println!("Repo config: (none)"); } + println!(); + println!("Effective config (merged):"); + println!(); + let config = Config::load()?; + println!("{}", toml::to_string_pretty(&config)?); + Ok(()) } @@ -217,4 +283,132 @@ history_days = 90 let config: Config = toml::from_str(toml).expect("valid toml"); assert!(config.hooks.exclude_commands.is_empty()); } + + // --- find_repo_config_from tests --- + + #[test] + fn test_find_repo_config_returns_none_when_no_rtk_toml() { + let tmp = tempfile::tempdir().expect("create temp dir"); + let result = find_repo_config_from(tmp.path()); + assert!(result.is_none()); + } + + #[test] + fn test_find_repo_config_finds_rtk_toml_in_current_dir() { + let tmp = tempfile::tempdir().expect("create temp dir"); + std::fs::create_dir_all(tmp.path().join(".git")).unwrap(); + std::fs::write( + tmp.path().join(".rtk.toml"), + "[hooks]\nexclude_commands = [\"curl\"]\n", + ) + .unwrap(); + let result = find_repo_config_from(tmp.path()); + assert_eq!(result, Some(tmp.path().join(".rtk.toml"))); + } + + #[test] + fn test_find_repo_config_walks_up_to_git_root() { + let tmp = tempfile::tempdir().expect("create temp dir"); + let subdir = tmp.path().join("app").join("billing"); + std::fs::create_dir_all(&subdir).unwrap(); + std::fs::create_dir_all(tmp.path().join(".git")).unwrap(); + std::fs::write( + tmp.path().join(".rtk.toml"), + "[hooks]\nexclude_commands = [\"curl\"]\n", + ) + .unwrap(); + let result = find_repo_config_from(&subdir); + assert_eq!(result, Some(tmp.path().join(".rtk.toml"))); + } + + #[test] + fn test_find_repo_config_stops_at_git_boundary() { + let tmp = tempfile::tempdir().expect("create temp dir"); + let inner = tmp.path().join("inner_repo"); + std::fs::create_dir_all(inner.join(".git")).unwrap(); + std::fs::write( + tmp.path().join(".rtk.toml"), + "[hooks]\nexclude_commands = [\"curl\"]\n", + ) + .unwrap(); + let result = find_repo_config_from(&inner); + assert!( + result.is_none(), + ".rtk.toml above .git boundary should not be found" + ); + } + + // --- merge_repo tests --- + + #[test] + fn test_merge_repo_hooks_overrides_user() { + let mut user = Config::default(); + user.hooks.exclude_commands = vec!["git".to_string()]; + + let repo_toml = r#" +[hooks] +exclude_commands = ["curl"] +"#; + user.merge_repo(repo_toml); + assert_eq!(user.hooks.exclude_commands, vec!["curl"]); + } + + #[test] + fn test_merge_repo_absent_section_keeps_user() { + let mut user = Config::default(); + user.hooks.exclude_commands = vec!["git".to_string()]; + user.tracking.history_days = 30; + + let repo_toml = r#" +[filters] +ignore_dirs = ["dist"] +ignore_files = [] +"#; + user.merge_repo(repo_toml); + assert_eq!(user.hooks.exclude_commands, vec!["git"]); + assert_eq!(user.filters.ignore_dirs, vec!["dist"]); + assert_eq!(user.tracking.history_days, 30); + } + + #[test] + fn test_merge_repo_ignores_user_only_sections() { + let mut user = Config::default(); + user.tracking.history_days = 30; + user.display.max_width = 80; + + let repo_toml = r#" +[tracking] +history_days = 999 + +[display] +max_width = 200 + +[hooks] +exclude_commands = ["curl"] +"#; + user.merge_repo(repo_toml); + assert_eq!(user.tracking.history_days, 30, "tracking is user-only"); + assert_eq!(user.display.max_width, 80, "display is user-only"); + assert_eq!( + user.hooks.exclude_commands, + vec!["curl"], + "hooks is repo-scoped" + ); + } + + // --- load_from_dir integration test --- + + #[test] + fn test_load_with_repo_config_integration() { + let tmp = tempfile::tempdir().expect("create temp dir"); + std::fs::create_dir_all(tmp.path().join(".git")).unwrap(); + std::fs::write( + tmp.path().join(".rtk.toml"), + "[hooks]\nexclude_commands = [\"curl\"]\n", + ) + .unwrap(); + + let config = Config::load_from_dir(tmp.path()).unwrap(); + assert_eq!(config.hooks.exclude_commands, vec!["curl"]); + } }