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