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