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