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