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