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