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