From ea0fb3c0fcdbe2ceec5fea2d32cbab06469f6aa3 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 22:01:57 -0700 Subject: [PATCH] feat: add layered ecc2 toml config loading --- ecc2/src/config/mod.rs | 203 +++++++++++++++++++++++++++++++++-------- 1 file changed, 163 insertions(+), 40 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index f83d7a32..8d8bbe62 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -76,11 +76,6 @@ pub struct PaneNavigationConfig { pub move_right: String, } -#[derive(Debug, Default, Deserialize)] -struct ProjectWorktreeConfigOverride { - max_parallel_worktrees: Option, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PaneNavigationAction { FocusSlot(usize), @@ -144,10 +139,7 @@ impl Config { }; pub fn config_path() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".claude") - .join("ecc2.toml") + Self::config_root().join("ecc2").join("config.toml") } pub fn cost_metrics_path(&self) -> PathBuf { @@ -171,48 +163,105 @@ impl Config { } pub fn load() -> Result { - let config_path = Self::config_path(); - let project_path = std::env::current_dir() + let global_paths = Self::global_config_paths(); + let project_paths = std::env::current_dir() .ok() - .and_then(|cwd| Self::project_config_path_from(&cwd)); - Self::load_from_paths(&config_path, project_path.as_deref()) + .map(|cwd| Self::project_config_paths_from(&cwd)) + .unwrap_or_default(); + Self::load_from_paths(&global_paths, &project_paths) } fn load_from_paths( - config_path: &std::path::Path, - project_override_path: Option<&std::path::Path>, + global_paths: &[PathBuf], + project_override_paths: &[PathBuf], ) -> Result { - let mut config = if config_path.exists() { - let content = std::fs::read_to_string(config_path)?; - toml::from_str(&content)? - } else { - Config::default() - }; + let mut merged = toml::Value::try_from(Self::default()) + .context("serialize default ECC 2.0 config for layered merge")?; - if let Some(project_path) = project_override_path.filter(|path| path.exists()) { - let content = std::fs::read_to_string(project_path)?; - let overrides: ProjectWorktreeConfigOverride = toml::from_str(&content)?; - if let Some(limit) = overrides.max_parallel_worktrees { - config.max_parallel_worktrees = limit; + for path in global_paths.iter().chain(project_override_paths.iter()) { + if path.exists() { + Self::merge_config_file(&mut merged, path)?; } } - Ok(config) + merged + .try_into() + .context("deserialize merged ECC 2.0 config") } - fn project_config_path_from(start: &std::path::Path) -> Option { - let global = Self::config_path(); + fn config_root() -> PathBuf { + dirs::config_dir().unwrap_or_else(|| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".config") + }) + } + + fn legacy_global_config_path() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".claude") + .join("ecc2.toml") + } + + fn global_config_paths() -> Vec { + let legacy = Self::legacy_global_config_path(); + let primary = Self::config_path(); + + if legacy == primary { + vec![primary] + } else { + vec![legacy, primary] + } + } + + fn project_config_paths_from(start: &std::path::Path) -> Vec { + let global_paths = Self::global_config_paths(); let mut current = Some(start); while let Some(path) = current { - let candidate = path.join(".claude").join("ecc2.toml"); - if candidate.exists() && candidate != global { - return Some(candidate); + let legacy = path.join(".claude").join("ecc2.toml"); + let primary = path.join("ecc2.toml"); + let mut matches = Vec::new(); + + if legacy.exists() && !global_paths.iter().any(|global| global == &legacy) { + matches.push(legacy); + } + if primary.exists() && !global_paths.iter().any(|global| global == &primary) { + matches.push(primary); + } + + if !matches.is_empty() { + return matches; } current = path.parent(); } - None + Vec::new() + } + + fn merge_config_file(base: &mut toml::Value, path: &std::path::Path) -> Result<()> { + let content = std::fs::read_to_string(path) + .with_context(|| format!("read ECC 2.0 config from {}", path.display()))?; + let overlay: toml::Value = toml::from_str(&content) + .with_context(|| format!("parse ECC 2.0 config from {}", path.display()))?; + Self::merge_toml_values(base, overlay); + Ok(()) + } + + fn merge_toml_values(base: &mut toml::Value, overlay: toml::Value) { + match (base, overlay) { + (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => { + for (key, overlay_value) in overlay_table { + if let Some(base_value) = base_table.get_mut(&key) { + Self::merge_toml_values(base_value, overlay_value); + } else { + base_table.insert(key, overlay_value); + } + } + } + (base_value, overlay_value) => *base_value = overlay_value, + } } pub fn save(&self) -> Result<()> { @@ -477,20 +526,94 @@ theme = "Dark" } #[test] - fn project_worktree_limit_override_replaces_global_limit() { + fn layered_config_merges_global_and_project_overrides() { let tempdir = std::env::temp_dir().join(format!("ecc2-config-{}", Uuid::new_v4())); - let global_path = tempdir.join("global.toml"); - let project_path = tempdir.join("project.toml"); + let legacy_global_path = tempdir.join("legacy-global.toml"); + let global_path = tempdir.join("config.toml"); + let project_path = tempdir.join("ecc2.toml"); std::fs::create_dir_all(&tempdir).unwrap(); - std::fs::write(&global_path, "max_parallel_worktrees = 6\n").unwrap(); - std::fs::write(&project_path, "max_parallel_worktrees = 2\n").unwrap(); + std::fs::write( + &legacy_global_path, + r#" +max_parallel_worktrees = 6 +auto_create_worktrees = false - let config = Config::load_from_paths(&global_path, Some(&project_path)).unwrap(); +[desktop_notifications] +enabled = true +session_completed = false +"#, + ) + .unwrap(); + std::fs::write( + &global_path, + r#" +auto_merge_ready_worktrees = true + +[pane_navigation] +focus_sessions = "q" +move_right = "d" +"#, + ) + .unwrap(); + std::fs::write( + &project_path, + r#" +max_parallel_worktrees = 2 +auto_dispatch_limit_per_session = 9 + +[desktop_notifications] +approval_requests = false + +[pane_navigation] +focus_metrics = "e" +"#, + ) + .unwrap(); + + let config = + Config::load_from_paths(&[legacy_global_path, global_path], &[project_path]).unwrap(); assert_eq!(config.max_parallel_worktrees, 2); + assert!(!config.auto_create_worktrees); + assert!(config.auto_merge_ready_worktrees); + assert_eq!(config.auto_dispatch_limit_per_session, 9); + assert!(config.desktop_notifications.enabled); + assert!(!config.desktop_notifications.session_completed); + assert!(!config.desktop_notifications.approval_requests); + assert_eq!(config.pane_navigation.focus_sessions, "q"); + assert_eq!(config.pane_navigation.focus_metrics, "e"); + assert_eq!(config.pane_navigation.move_right, "d"); let _ = std::fs::remove_dir_all(tempdir); } + #[test] + fn project_config_discovery_prefers_nearest_directory_and_new_path() { + let tempdir = std::env::temp_dir().join(format!("ecc2-config-{}", Uuid::new_v4())); + let project_root = tempdir.join("project"); + let nested_dir = project_root.join("src").join("module"); + std::fs::create_dir_all(project_root.join(".claude")).unwrap(); + std::fs::create_dir_all(&nested_dir).unwrap(); + std::fs::write(project_root.join(".claude").join("ecc2.toml"), "").unwrap(); + std::fs::write(project_root.join("ecc2.toml"), "").unwrap(); + + let paths = Config::project_config_paths_from(&nested_dir); + assert_eq!( + paths, + vec![ + project_root.join(".claude").join("ecc2.toml"), + project_root.join("ecc2.toml") + ] + ); + + let _ = std::fs::remove_dir_all(tempdir); + } + + #[test] + fn primary_config_path_uses_xdg_style_location() { + let path = Config::config_path(); + assert!(path.ends_with("ecc2/config.toml")); + } + #[test] fn pane_navigation_deserializes_from_toml() { let config: Config = toml::from_str(