Skip to main content

lean_ctx/core/
config.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3use std::sync::Mutex;
4use std::time::SystemTime;
5
6#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
7#[serde(rename_all = "lowercase")]
8pub enum TeeMode {
9    Never,
10    #[default]
11    Failures,
12    Always,
13}
14
15#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
16#[serde(rename_all = "lowercase")]
17pub enum TerseAgent {
18    #[default]
19    Off,
20    Lite,
21    Full,
22    Ultra,
23}
24
25impl TerseAgent {
26    pub fn from_env() -> Self {
27        match std::env::var("LEAN_CTX_TERSE_AGENT")
28            .unwrap_or_default()
29            .to_lowercase()
30            .as_str()
31        {
32            "lite" => Self::Lite,
33            "full" => Self::Full,
34            "ultra" => Self::Ultra,
35            "off" | "0" | "false" => Self::Off,
36            _ => Self::Off,
37        }
38    }
39
40    pub fn effective(config_val: &TerseAgent) -> Self {
41        match std::env::var("LEAN_CTX_TERSE_AGENT") {
42            Ok(val) if !val.is_empty() => match val.to_lowercase().as_str() {
43                "lite" => Self::Lite,
44                "full" => Self::Full,
45                "ultra" => Self::Ultra,
46                _ => Self::Off,
47            },
48            _ => config_val.clone(),
49        }
50    }
51
52    pub fn is_active(&self) -> bool {
53        !matches!(self, Self::Off)
54    }
55}
56
57#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
58#[serde(rename_all = "lowercase")]
59pub enum OutputDensity {
60    #[default]
61    Normal,
62    Terse,
63    Ultra,
64}
65
66impl OutputDensity {
67    pub fn from_env() -> Self {
68        match std::env::var("LEAN_CTX_OUTPUT_DENSITY")
69            .unwrap_or_default()
70            .to_lowercase()
71            .as_str()
72        {
73            "terse" => Self::Terse,
74            "ultra" => Self::Ultra,
75            _ => Self::Normal,
76        }
77    }
78
79    pub fn effective(config_val: &OutputDensity) -> Self {
80        let env_val = Self::from_env();
81        if env_val != Self::Normal {
82            return env_val;
83        }
84        config_val.clone()
85    }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(default)]
90pub struct Config {
91    pub ultra_compact: bool,
92    #[serde(default, deserialize_with = "deserialize_tee_mode")]
93    pub tee_mode: TeeMode,
94    #[serde(default)]
95    pub output_density: OutputDensity,
96    pub checkpoint_interval: u32,
97    pub excluded_commands: Vec<String>,
98    pub passthrough_urls: Vec<String>,
99    pub custom_aliases: Vec<AliasEntry>,
100    /// Commands taking longer than this threshold (ms) are recorded in the slow log.
101    /// Set to 0 to disable slow logging.
102    pub slow_command_threshold_ms: u64,
103    #[serde(default = "default_theme")]
104    pub theme: String,
105    #[serde(default)]
106    pub cloud: CloudConfig,
107    #[serde(default)]
108    pub autonomy: AutonomyConfig,
109    #[serde(default = "default_buddy_enabled")]
110    pub buddy_enabled: bool,
111    #[serde(default)]
112    pub redirect_exclude: Vec<String>,
113    /// Tools to exclude from the MCP tool list returned by list_tools.
114    /// Accepts exact tool names (e.g. ["ctx_graph", "ctx_agent"]).
115    /// Empty by default — all tools listed, no behaviour change.
116    #[serde(default)]
117    pub disabled_tools: Vec<String>,
118    #[serde(default)]
119    pub loop_detection: LoopDetectionConfig,
120    /// Controls where lean-ctx installs agent rule files.
121    /// Values: "both" (default), "global" (home-dir only), "project" (repo-local only).
122    /// Override via LEAN_CTX_RULES_SCOPE env var.
123    #[serde(default)]
124    pub rules_scope: Option<String>,
125    /// Extra glob patterns to ignore in graph/overview/preload (repo-local).
126    /// Example: ["externals/**", "target/**", "temp/**"]
127    #[serde(default)]
128    pub extra_ignore_patterns: Vec<String>,
129    /// Controls agent output verbosity via instructions injection.
130    /// Values: "off" (default), "lite", "full", "ultra".
131    /// Override via LEAN_CTX_TERSE_AGENT env var.
132    #[serde(default)]
133    pub terse_agent: TerseAgent,
134    /// Archive configuration for zero-loss compression.
135    #[serde(default)]
136    pub archive: ArchiveConfig,
137    /// Additional paths allowed by PathJail (absolute).
138    /// Useful for multi-project workspaces where the jail root is a parent directory.
139    /// Override via LEAN_CTX_ALLOW_PATH env var (path-list separator).
140    #[serde(default)]
141    pub allow_paths: Vec<String>,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
145#[serde(default)]
146pub struct ArchiveConfig {
147    pub enabled: bool,
148    pub threshold_chars: usize,
149    pub max_age_hours: u64,
150    pub max_disk_mb: u64,
151}
152
153impl Default for ArchiveConfig {
154    fn default() -> Self {
155        Self {
156            enabled: true,
157            threshold_chars: 4096,
158            max_age_hours: 48,
159            max_disk_mb: 500,
160        }
161    }
162}
163
164fn default_buddy_enabled() -> bool {
165    true
166}
167
168fn deserialize_tee_mode<'de, D>(deserializer: D) -> Result<TeeMode, D::Error>
169where
170    D: serde::Deserializer<'de>,
171{
172    use serde::de::Error;
173    let v = serde_json::Value::deserialize(deserializer)?;
174    match &v {
175        serde_json::Value::Bool(true) => Ok(TeeMode::Failures),
176        serde_json::Value::Bool(false) => Ok(TeeMode::Never),
177        serde_json::Value::String(s) => match s.as_str() {
178            "never" => Ok(TeeMode::Never),
179            "failures" => Ok(TeeMode::Failures),
180            "always" => Ok(TeeMode::Always),
181            other => Err(D::Error::custom(format!("unknown tee_mode: {other}"))),
182        },
183        _ => Err(D::Error::custom("tee_mode must be string or bool")),
184    }
185}
186
187fn default_theme() -> String {
188    "default".to_string()
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
192#[serde(default)]
193pub struct AutonomyConfig {
194    pub enabled: bool,
195    pub auto_preload: bool,
196    pub auto_dedup: bool,
197    pub auto_related: bool,
198    pub auto_consolidate: bool,
199    pub silent_preload: bool,
200    pub dedup_threshold: usize,
201    pub consolidate_every_calls: u32,
202    pub consolidate_cooldown_secs: u64,
203}
204
205impl Default for AutonomyConfig {
206    fn default() -> Self {
207        Self {
208            enabled: true,
209            auto_preload: true,
210            auto_dedup: true,
211            auto_related: true,
212            auto_consolidate: true,
213            silent_preload: true,
214            dedup_threshold: 8,
215            consolidate_every_calls: 25,
216            consolidate_cooldown_secs: 120,
217        }
218    }
219}
220
221impl AutonomyConfig {
222    pub fn from_env() -> Self {
223        let mut cfg = Self::default();
224        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
225            if v == "false" || v == "0" {
226                cfg.enabled = false;
227            }
228        }
229        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
230            cfg.auto_preload = v != "false" && v != "0";
231        }
232        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
233            cfg.auto_dedup = v != "false" && v != "0";
234        }
235        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
236            cfg.auto_related = v != "false" && v != "0";
237        }
238        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
239            cfg.auto_consolidate = v != "false" && v != "0";
240        }
241        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
242            cfg.silent_preload = v != "false" && v != "0";
243        }
244        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
245            if let Ok(n) = v.parse() {
246                cfg.dedup_threshold = n;
247            }
248        }
249        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
250            if let Ok(n) = v.parse() {
251                cfg.consolidate_every_calls = n;
252            }
253        }
254        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
255            if let Ok(n) = v.parse() {
256                cfg.consolidate_cooldown_secs = n;
257            }
258        }
259        cfg
260    }
261
262    pub fn load() -> Self {
263        let file_cfg = Config::load().autonomy;
264        let mut cfg = file_cfg;
265        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
266            if v == "false" || v == "0" {
267                cfg.enabled = false;
268            }
269        }
270        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
271            cfg.auto_preload = v != "false" && v != "0";
272        }
273        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
274            cfg.auto_dedup = v != "false" && v != "0";
275        }
276        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
277            cfg.auto_related = v != "false" && v != "0";
278        }
279        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
280            cfg.silent_preload = v != "false" && v != "0";
281        }
282        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
283            if let Ok(n) = v.parse() {
284                cfg.dedup_threshold = n;
285            }
286        }
287        cfg
288    }
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize, Default)]
292#[serde(default)]
293pub struct CloudConfig {
294    pub contribute_enabled: bool,
295    pub last_contribute: Option<String>,
296    pub last_sync: Option<String>,
297    pub last_gain_sync: Option<String>,
298    pub last_model_pull: Option<String>,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct AliasEntry {
303    pub command: String,
304    pub alias: String,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize)]
308#[serde(default)]
309pub struct LoopDetectionConfig {
310    pub normal_threshold: u32,
311    pub reduced_threshold: u32,
312    pub blocked_threshold: u32,
313    pub window_secs: u64,
314    pub search_group_limit: u32,
315}
316
317impl Default for LoopDetectionConfig {
318    fn default() -> Self {
319        Self {
320            normal_threshold: 2,
321            reduced_threshold: 4,
322            blocked_threshold: 6,
323            window_secs: 300,
324            search_group_limit: 10,
325        }
326    }
327}
328
329impl Default for Config {
330    fn default() -> Self {
331        Self {
332            ultra_compact: false,
333            tee_mode: TeeMode::default(),
334            output_density: OutputDensity::default(),
335            checkpoint_interval: 15,
336            excluded_commands: Vec::new(),
337            passthrough_urls: Vec::new(),
338            custom_aliases: Vec::new(),
339            slow_command_threshold_ms: 5000,
340            theme: default_theme(),
341            cloud: CloudConfig::default(),
342            autonomy: AutonomyConfig::default(),
343            buddy_enabled: default_buddy_enabled(),
344            redirect_exclude: Vec::new(),
345            disabled_tools: Vec::new(),
346            loop_detection: LoopDetectionConfig::default(),
347            rules_scope: None,
348            extra_ignore_patterns: Vec::new(),
349            terse_agent: TerseAgent::default(),
350            archive: ArchiveConfig::default(),
351            allow_paths: Vec::new(),
352        }
353    }
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357pub enum RulesScope {
358    Both,
359    Global,
360    Project,
361}
362
363impl Config {
364    pub fn rules_scope_effective(&self) -> RulesScope {
365        let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
366            .ok()
367            .or_else(|| self.rules_scope.clone())
368            .unwrap_or_default();
369        match raw.trim().to_lowercase().as_str() {
370            "global" => RulesScope::Global,
371            "project" => RulesScope::Project,
372            _ => RulesScope::Both,
373        }
374    }
375
376    fn parse_disabled_tools_env(val: &str) -> Vec<String> {
377        val.split(',')
378            .map(|s| s.trim().to_string())
379            .filter(|s| !s.is_empty())
380            .collect()
381    }
382
383    pub fn disabled_tools_effective(&self) -> Vec<String> {
384        if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
385            Self::parse_disabled_tools_env(&val)
386        } else {
387            self.disabled_tools.clone()
388        }
389    }
390}
391
392#[cfg(test)]
393mod disabled_tools_tests {
394    use super::*;
395
396    #[test]
397    fn config_field_default_is_empty() {
398        let cfg = Config::default();
399        assert!(cfg.disabled_tools.is_empty());
400    }
401
402    #[test]
403    fn effective_returns_config_field_when_no_env_var() {
404        // Only meaningful when LEAN_CTX_DISABLED_TOOLS is unset; skip otherwise.
405        if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
406            return;
407        }
408        let cfg = Config {
409            disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
410            ..Default::default()
411        };
412        assert_eq!(
413            cfg.disabled_tools_effective(),
414            vec!["ctx_graph", "ctx_agent"]
415        );
416    }
417
418    #[test]
419    fn parse_env_basic() {
420        let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
421        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
422    }
423
424    #[test]
425    fn parse_env_trims_whitespace_and_skips_empty() {
426        let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
427        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
428    }
429
430    #[test]
431    fn parse_env_single_entry() {
432        let result = Config::parse_disabled_tools_env("ctx_graph");
433        assert_eq!(result, vec!["ctx_graph"]);
434    }
435
436    #[test]
437    fn parse_env_empty_string_returns_empty() {
438        let result = Config::parse_disabled_tools_env("");
439        assert!(result.is_empty());
440    }
441
442    #[test]
443    fn disabled_tools_deserialization_defaults_to_empty() {
444        let cfg: Config = toml::from_str("").unwrap();
445        assert!(cfg.disabled_tools.is_empty());
446    }
447
448    #[test]
449    fn disabled_tools_deserialization_from_toml() {
450        let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
451        assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
452    }
453}
454
455#[cfg(test)]
456mod rules_scope_tests {
457    use super::*;
458
459    #[test]
460    fn default_is_both() {
461        let cfg = Config::default();
462        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
463    }
464
465    #[test]
466    fn config_global() {
467        let cfg = Config {
468            rules_scope: Some("global".to_string()),
469            ..Default::default()
470        };
471        assert_eq!(cfg.rules_scope_effective(), RulesScope::Global);
472    }
473
474    #[test]
475    fn config_project() {
476        let cfg = Config {
477            rules_scope: Some("project".to_string()),
478            ..Default::default()
479        };
480        assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
481    }
482
483    #[test]
484    fn unknown_value_falls_back_to_both() {
485        let cfg = Config {
486            rules_scope: Some("nonsense".to_string()),
487            ..Default::default()
488        };
489        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
490    }
491
492    #[test]
493    fn deserialization_none_by_default() {
494        let cfg: Config = toml::from_str("").unwrap();
495        assert!(cfg.rules_scope.is_none());
496        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
497    }
498
499    #[test]
500    fn deserialization_from_toml() {
501        let cfg: Config = toml::from_str(r#"rules_scope = "project""#).unwrap();
502        assert_eq!(cfg.rules_scope.as_deref(), Some("project"));
503        assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
504    }
505}
506
507#[cfg(test)]
508mod loop_detection_config_tests {
509    use super::*;
510
511    #[test]
512    fn defaults_are_reasonable() {
513        let cfg = LoopDetectionConfig::default();
514        assert_eq!(cfg.normal_threshold, 2);
515        assert_eq!(cfg.reduced_threshold, 4);
516        assert_eq!(cfg.blocked_threshold, 6);
517        assert_eq!(cfg.window_secs, 300);
518        assert_eq!(cfg.search_group_limit, 10);
519    }
520
521    #[test]
522    fn deserialization_defaults_when_missing() {
523        let cfg: Config = toml::from_str("").unwrap();
524        assert_eq!(cfg.loop_detection.blocked_threshold, 6);
525        assert_eq!(cfg.loop_detection.search_group_limit, 10);
526    }
527
528    #[test]
529    fn deserialization_from_toml() {
530        let cfg: Config = toml::from_str(
531            r#"
532            [loop_detection]
533            normal_threshold = 1
534            reduced_threshold = 3
535            blocked_threshold = 5
536            window_secs = 120
537            search_group_limit = 8
538            "#,
539        )
540        .unwrap();
541        assert_eq!(cfg.loop_detection.normal_threshold, 1);
542        assert_eq!(cfg.loop_detection.reduced_threshold, 3);
543        assert_eq!(cfg.loop_detection.blocked_threshold, 5);
544        assert_eq!(cfg.loop_detection.window_secs, 120);
545        assert_eq!(cfg.loop_detection.search_group_limit, 8);
546    }
547
548    #[test]
549    fn partial_override_keeps_defaults() {
550        let cfg: Config = toml::from_str(
551            r#"
552            [loop_detection]
553            blocked_threshold = 10
554            "#,
555        )
556        .unwrap();
557        assert_eq!(cfg.loop_detection.blocked_threshold, 10);
558        assert_eq!(cfg.loop_detection.normal_threshold, 2);
559        assert_eq!(cfg.loop_detection.search_group_limit, 10);
560    }
561}
562
563impl Config {
564    pub fn path() -> Option<PathBuf> {
565        crate::core::data_dir::lean_ctx_data_dir()
566            .ok()
567            .map(|d| d.join("config.toml"))
568    }
569
570    pub fn local_path(project_root: &str) -> PathBuf {
571        PathBuf::from(project_root).join(".lean-ctx.toml")
572    }
573
574    fn find_project_root() -> Option<String> {
575        let cwd = std::env::current_dir().ok();
576
577        if let Some(root) =
578            crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
579        {
580            let root_path = std::path::Path::new(&root);
581            let cwd_is_under_root = cwd
582                .as_ref()
583                .map(|c| c.starts_with(root_path))
584                .unwrap_or(false);
585            let has_marker = root_path.join(".git").exists()
586                || root_path.join("Cargo.toml").exists()
587                || root_path.join("package.json").exists()
588                || root_path.join("go.mod").exists()
589                || root_path.join("pyproject.toml").exists()
590                || root_path.join(".lean-ctx.toml").exists();
591
592            if cwd_is_under_root || has_marker {
593                return Some(root);
594            }
595        }
596
597        if let Some(ref cwd) = cwd {
598            let git_root = std::process::Command::new("git")
599                .args(["rev-parse", "--show-toplevel"])
600                .current_dir(cwd)
601                .stdout(std::process::Stdio::piped())
602                .stderr(std::process::Stdio::null())
603                .output()
604                .ok()
605                .and_then(|o| {
606                    if o.status.success() {
607                        String::from_utf8(o.stdout)
608                            .ok()
609                            .map(|s| s.trim().to_string())
610                    } else {
611                        None
612                    }
613                });
614            if let Some(root) = git_root {
615                return Some(root);
616            }
617            return Some(cwd.to_string_lossy().to_string());
618        }
619        None
620    }
621
622    pub fn load() -> Self {
623        static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
624
625        let path = match Self::path() {
626            Some(p) => p,
627            None => return Self::default(),
628        };
629
630        let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
631
632        let mtime = std::fs::metadata(&path)
633            .and_then(|m| m.modified())
634            .unwrap_or(SystemTime::UNIX_EPOCH);
635
636        let local_mtime = local_path
637            .as_ref()
638            .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
639
640        if let Ok(guard) = CACHE.lock() {
641            if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
642                if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
643                    return cfg.clone();
644                }
645            }
646        }
647
648        let mut cfg: Config = match std::fs::read_to_string(&path) {
649            Ok(content) => toml::from_str(&content).unwrap_or_default(),
650            Err(_) => Self::default(),
651        };
652
653        if let Some(ref lp) = local_path {
654            if let Ok(local_content) = std::fs::read_to_string(lp) {
655                cfg.merge_local(&local_content);
656            }
657        }
658
659        if let Ok(mut guard) = CACHE.lock() {
660            *guard = Some((cfg.clone(), mtime, local_mtime));
661        }
662
663        cfg
664    }
665
666    fn merge_local(&mut self, local_toml: &str) {
667        let local: Config = match toml::from_str(local_toml) {
668            Ok(c) => c,
669            Err(_) => return,
670        };
671        if local.ultra_compact {
672            self.ultra_compact = true;
673        }
674        if local.tee_mode != TeeMode::default() {
675            self.tee_mode = local.tee_mode;
676        }
677        if local.output_density != OutputDensity::default() {
678            self.output_density = local.output_density;
679        }
680        if local.checkpoint_interval != 15 {
681            self.checkpoint_interval = local.checkpoint_interval;
682        }
683        if !local.excluded_commands.is_empty() {
684            self.excluded_commands.extend(local.excluded_commands);
685        }
686        if !local.passthrough_urls.is_empty() {
687            self.passthrough_urls.extend(local.passthrough_urls);
688        }
689        if !local.custom_aliases.is_empty() {
690            self.custom_aliases.extend(local.custom_aliases);
691        }
692        if local.slow_command_threshold_ms != 5000 {
693            self.slow_command_threshold_ms = local.slow_command_threshold_ms;
694        }
695        if local.theme != "default" {
696            self.theme = local.theme;
697        }
698        if !local.buddy_enabled {
699            self.buddy_enabled = false;
700        }
701        if !local.redirect_exclude.is_empty() {
702            self.redirect_exclude.extend(local.redirect_exclude);
703        }
704        if !local.disabled_tools.is_empty() {
705            self.disabled_tools.extend(local.disabled_tools);
706        }
707        if !local.extra_ignore_patterns.is_empty() {
708            self.extra_ignore_patterns
709                .extend(local.extra_ignore_patterns);
710        }
711        if local.rules_scope.is_some() {
712            self.rules_scope = local.rules_scope;
713        }
714        if !local.autonomy.enabled {
715            self.autonomy.enabled = false;
716        }
717        if !local.autonomy.auto_preload {
718            self.autonomy.auto_preload = false;
719        }
720        if !local.autonomy.auto_dedup {
721            self.autonomy.auto_dedup = false;
722        }
723        if !local.autonomy.auto_related {
724            self.autonomy.auto_related = false;
725        }
726        if !local.autonomy.auto_consolidate {
727            self.autonomy.auto_consolidate = false;
728        }
729        if local.autonomy.silent_preload {
730            self.autonomy.silent_preload = true;
731        }
732        if !local.autonomy.silent_preload && self.autonomy.silent_preload {
733            self.autonomy.silent_preload = false;
734        }
735        if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
736            self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
737        }
738        if local.autonomy.consolidate_every_calls
739            != AutonomyConfig::default().consolidate_every_calls
740        {
741            self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
742        }
743        if local.autonomy.consolidate_cooldown_secs
744            != AutonomyConfig::default().consolidate_cooldown_secs
745        {
746            self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
747        }
748        if local.terse_agent != TerseAgent::default() {
749            self.terse_agent = local.terse_agent;
750        }
751        if !local.archive.enabled {
752            self.archive.enabled = false;
753        }
754        if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
755            self.archive.threshold_chars = local.archive.threshold_chars;
756        }
757        if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
758            self.archive.max_age_hours = local.archive.max_age_hours;
759        }
760        if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
761            self.archive.max_disk_mb = local.archive.max_disk_mb;
762        }
763        if !local.allow_paths.is_empty() {
764            self.allow_paths.extend(local.allow_paths);
765        }
766    }
767
768    pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
769        let path = Self::path().ok_or_else(|| {
770            super::error::LeanCtxError::Config("cannot determine home directory".into())
771        })?;
772        if let Some(parent) = path.parent() {
773            std::fs::create_dir_all(parent)?;
774        }
775        let content = toml::to_string_pretty(self)
776            .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
777        std::fs::write(&path, content)?;
778        Ok(())
779    }
780
781    pub fn show(&self) -> String {
782        let global_path = Self::path()
783            .map(|p| p.to_string_lossy().to_string())
784            .unwrap_or_else(|| "~/.lean-ctx/config.toml".to_string());
785        let content = toml::to_string_pretty(self).unwrap_or_default();
786        let mut out = format!("Global config: {global_path}\n\n{content}");
787
788        if let Some(root) = Self::find_project_root() {
789            let local = Self::local_path(&root);
790            if local.exists() {
791                out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
792            } else {
793                out.push_str(&format!(
794                    "\n\nLocal config: not found (create {} to override per-project)\n",
795                    local.display()
796                ));
797            }
798        }
799        out
800    }
801}