mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-10 19:33:37 +08:00
feat: add layered ecc2 toml config loading
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -76,11 +76,6 @@ pub struct PaneNavigationConfig {
|
|||||||
pub move_right: String,
|
pub move_right: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
struct ProjectWorktreeConfigOverride {
|
|
||||||
max_parallel_worktrees: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum PaneNavigationAction {
|
pub enum PaneNavigationAction {
|
||||||
FocusSlot(usize),
|
FocusSlot(usize),
|
||||||
@@ -144,10 +139,7 @@ impl Config {
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub fn config_path() -> PathBuf {
|
pub fn config_path() -> PathBuf {
|
||||||
dirs::home_dir()
|
Self::config_root().join("ecc2").join("config.toml")
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
|
||||||
.join(".claude")
|
|
||||||
.join("ecc2.toml")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cost_metrics_path(&self) -> PathBuf {
|
pub fn cost_metrics_path(&self) -> PathBuf {
|
||||||
@@ -171,48 +163,105 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn load() -> Result<Self> {
|
pub fn load() -> Result<Self> {
|
||||||
let config_path = Self::config_path();
|
let global_paths = Self::global_config_paths();
|
||||||
let project_path = std::env::current_dir()
|
let project_paths = std::env::current_dir()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|cwd| Self::project_config_path_from(&cwd));
|
.map(|cwd| Self::project_config_paths_from(&cwd))
|
||||||
Self::load_from_paths(&config_path, project_path.as_deref())
|
.unwrap_or_default();
|
||||||
|
Self::load_from_paths(&global_paths, &project_paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_from_paths(
|
fn load_from_paths(
|
||||||
config_path: &std::path::Path,
|
global_paths: &[PathBuf],
|
||||||
project_override_path: Option<&std::path::Path>,
|
project_override_paths: &[PathBuf],
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let mut config = if config_path.exists() {
|
let mut merged = toml::Value::try_from(Self::default())
|
||||||
let content = std::fs::read_to_string(config_path)?;
|
.context("serialize default ECC 2.0 config for layered merge")?;
|
||||||
toml::from_str(&content)?
|
|
||||||
|
for path in global_paths.iter().chain(project_override_paths.iter()) {
|
||||||
|
if path.exists() {
|
||||||
|
Self::merge_config_file(&mut merged, path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged
|
||||||
|
.try_into()
|
||||||
|
.context("deserialize merged ECC 2.0 config")
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PathBuf> {
|
||||||
|
let legacy = Self::legacy_global_config_path();
|
||||||
|
let primary = Self::config_path();
|
||||||
|
|
||||||
|
if legacy == primary {
|
||||||
|
vec![primary]
|
||||||
} else {
|
} else {
|
||||||
Config::default()
|
vec![legacy, primary]
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(config)
|
fn project_config_paths_from(start: &std::path::Path) -> Vec<PathBuf> {
|
||||||
}
|
let global_paths = Self::global_config_paths();
|
||||||
|
|
||||||
fn project_config_path_from(start: &std::path::Path) -> Option<PathBuf> {
|
|
||||||
let global = Self::config_path();
|
|
||||||
let mut current = Some(start);
|
let mut current = Some(start);
|
||||||
|
|
||||||
while let Some(path) = current {
|
while let Some(path) = current {
|
||||||
let candidate = path.join(".claude").join("ecc2.toml");
|
let legacy = path.join(".claude").join("ecc2.toml");
|
||||||
if candidate.exists() && candidate != global {
|
let primary = path.join("ecc2.toml");
|
||||||
return Some(candidate);
|
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();
|
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<()> {
|
pub fn save(&self) -> Result<()> {
|
||||||
@@ -477,20 +526,94 @@ theme = "Dark"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 tempdir = std::env::temp_dir().join(format!("ecc2-config-{}", Uuid::new_v4()));
|
||||||
let global_path = tempdir.join("global.toml");
|
let legacy_global_path = tempdir.join("legacy-global.toml");
|
||||||
let project_path = tempdir.join("project.toml");
|
let global_path = tempdir.join("config.toml");
|
||||||
|
let project_path = tempdir.join("ecc2.toml");
|
||||||
std::fs::create_dir_all(&tempdir).unwrap();
|
std::fs::create_dir_all(&tempdir).unwrap();
|
||||||
std::fs::write(&global_path, "max_parallel_worktrees = 6\n").unwrap();
|
std::fs::write(
|
||||||
std::fs::write(&project_path, "max_parallel_worktrees = 2\n").unwrap();
|
&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_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);
|
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]
|
#[test]
|
||||||
fn pane_navigation_deserializes_from_toml() {
|
fn pane_navigation_deserializes_from_toml() {
|
||||||
let config: Config = toml::from_str(
|
let config: Config = toml::from_str(
|
||||||
|
|||||||
Reference in New Issue
Block a user