Skip to main content

lean_ctx/core/
config.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3use std::sync::Mutex;
4use std::time::SystemTime;
5
6#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
7#[serde(rename_all = "lowercase")]
8pub enum TeeMode {
9    Never,
10    #[default]
11    Failures,
12    Always,
13}
14
15#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
16#[serde(rename_all = "lowercase")]
17pub enum OutputDensity {
18    #[default]
19    Normal,
20    Terse,
21    Ultra,
22}
23
24impl OutputDensity {
25    pub fn from_env() -> Self {
26        match std::env::var("LEAN_CTX_OUTPUT_DENSITY")
27            .unwrap_or_default()
28            .to_lowercase()
29            .as_str()
30        {
31            "terse" => Self::Terse,
32            "ultra" => Self::Ultra,
33            _ => Self::Normal,
34        }
35    }
36
37    pub fn effective(config_val: &OutputDensity) -> Self {
38        let env_val = Self::from_env();
39        if env_val != Self::Normal {
40            return env_val;
41        }
42        config_val.clone()
43    }
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(default)]
48pub struct Config {
49    pub ultra_compact: bool,
50    #[serde(default, deserialize_with = "deserialize_tee_mode")]
51    pub tee_mode: TeeMode,
52    #[serde(default)]
53    pub output_density: OutputDensity,
54    pub checkpoint_interval: u32,
55    pub excluded_commands: Vec<String>,
56    pub passthrough_urls: Vec<String>,
57    pub custom_aliases: Vec<AliasEntry>,
58    /// Commands taking longer than this threshold (ms) are recorded in the slow log.
59    /// Set to 0 to disable slow logging.
60    pub slow_command_threshold_ms: u64,
61    #[serde(default = "default_theme")]
62    pub theme: String,
63    #[serde(default)]
64    pub cloud: CloudConfig,
65    #[serde(default)]
66    pub autonomy: AutonomyConfig,
67    #[serde(default = "default_buddy_enabled")]
68    pub buddy_enabled: bool,
69    #[serde(default)]
70    pub redirect_exclude: Vec<String>,
71    /// Tools to exclude from the MCP tool list returned by list_tools.
72    /// Accepts exact tool names (e.g. ["ctx_graph", "ctx_agent"]).
73    /// Empty by default — all tools listed, no behaviour change.
74    #[serde(default)]
75    pub disabled_tools: Vec<String>,
76    #[serde(default)]
77    pub loop_detection: LoopDetectionConfig,
78    /// Controls where lean-ctx installs agent rule files.
79    /// Values: "both" (default), "global" (home-dir only), "project" (repo-local only).
80    /// Override via LEAN_CTX_RULES_SCOPE env var.
81    #[serde(default)]
82    pub rules_scope: Option<String>,
83    /// Extra glob patterns to ignore in graph/overview/preload (repo-local).
84    /// Example: ["externals/**", "target/**", "temp/**"]
85    #[serde(default)]
86    pub extra_ignore_patterns: Vec<String>,
87}
88
89fn default_buddy_enabled() -> bool {
90    true
91}
92
93fn deserialize_tee_mode<'de, D>(deserializer: D) -> Result<TeeMode, D::Error>
94where
95    D: serde::Deserializer<'de>,
96{
97    use serde::de::Error;
98    let v = serde_json::Value::deserialize(deserializer)?;
99    match &v {
100        serde_json::Value::Bool(true) => Ok(TeeMode::Failures),
101        serde_json::Value::Bool(false) => Ok(TeeMode::Never),
102        serde_json::Value::String(s) => match s.as_str() {
103            "never" => Ok(TeeMode::Never),
104            "failures" => Ok(TeeMode::Failures),
105            "always" => Ok(TeeMode::Always),
106            other => Err(D::Error::custom(format!("unknown tee_mode: {other}"))),
107        },
108        _ => Err(D::Error::custom("tee_mode must be string or bool")),
109    }
110}
111
112fn default_theme() -> String {
113    "default".to_string()
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117#[serde(default)]
118pub struct AutonomyConfig {
119    pub enabled: bool,
120    pub auto_preload: bool,
121    pub auto_dedup: bool,
122    pub auto_related: bool,
123    pub auto_consolidate: bool,
124    pub silent_preload: bool,
125    pub dedup_threshold: usize,
126    pub consolidate_every_calls: u32,
127    pub consolidate_cooldown_secs: u64,
128}
129
130impl Default for AutonomyConfig {
131    fn default() -> Self {
132        Self {
133            enabled: true,
134            auto_preload: true,
135            auto_dedup: true,
136            auto_related: true,
137            auto_consolidate: true,
138            silent_preload: true,
139            dedup_threshold: 8,
140            consolidate_every_calls: 25,
141            consolidate_cooldown_secs: 120,
142        }
143    }
144}
145
146impl AutonomyConfig {
147    pub fn from_env() -> Self {
148        let mut cfg = Self::default();
149        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
150            if v == "false" || v == "0" {
151                cfg.enabled = false;
152            }
153        }
154        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
155            cfg.auto_preload = v != "false" && v != "0";
156        }
157        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
158            cfg.auto_dedup = v != "false" && v != "0";
159        }
160        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
161            cfg.auto_related = v != "false" && v != "0";
162        }
163        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
164            cfg.auto_consolidate = v != "false" && v != "0";
165        }
166        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
167            cfg.silent_preload = v != "false" && v != "0";
168        }
169        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
170            if let Ok(n) = v.parse() {
171                cfg.dedup_threshold = n;
172            }
173        }
174        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
175            if let Ok(n) = v.parse() {
176                cfg.consolidate_every_calls = n;
177            }
178        }
179        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
180            if let Ok(n) = v.parse() {
181                cfg.consolidate_cooldown_secs = n;
182            }
183        }
184        cfg
185    }
186
187    pub fn load() -> Self {
188        let file_cfg = Config::load().autonomy;
189        let mut cfg = file_cfg;
190        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
191            if v == "false" || v == "0" {
192                cfg.enabled = false;
193            }
194        }
195        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
196            cfg.auto_preload = v != "false" && v != "0";
197        }
198        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
199            cfg.auto_dedup = v != "false" && v != "0";
200        }
201        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
202            cfg.auto_related = v != "false" && v != "0";
203        }
204        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
205            cfg.silent_preload = v != "false" && v != "0";
206        }
207        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
208            if let Ok(n) = v.parse() {
209                cfg.dedup_threshold = n;
210            }
211        }
212        cfg
213    }
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, Default)]
217#[serde(default)]
218pub struct CloudConfig {
219    pub contribute_enabled: bool,
220    pub last_contribute: Option<String>,
221    pub last_sync: Option<String>,
222    pub last_gain_sync: Option<String>,
223    pub last_model_pull: Option<String>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct AliasEntry {
228    pub command: String,
229    pub alias: String,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize)]
233#[serde(default)]
234pub struct LoopDetectionConfig {
235    pub normal_threshold: u32,
236    pub reduced_threshold: u32,
237    pub blocked_threshold: u32,
238    pub window_secs: u64,
239    pub search_group_limit: u32,
240}
241
242impl Default for LoopDetectionConfig {
243    fn default() -> Self {
244        Self {
245            normal_threshold: 2,
246            reduced_threshold: 4,
247            blocked_threshold: 6,
248            window_secs: 300,
249            search_group_limit: 10,
250        }
251    }
252}
253
254impl Default for Config {
255    fn default() -> Self {
256        Self {
257            ultra_compact: false,
258            tee_mode: TeeMode::default(),
259            output_density: OutputDensity::default(),
260            checkpoint_interval: 15,
261            excluded_commands: Vec::new(),
262            passthrough_urls: Vec::new(),
263            custom_aliases: Vec::new(),
264            slow_command_threshold_ms: 5000,
265            theme: default_theme(),
266            cloud: CloudConfig::default(),
267            autonomy: AutonomyConfig::default(),
268            buddy_enabled: default_buddy_enabled(),
269            redirect_exclude: Vec::new(),
270            disabled_tools: Vec::new(),
271            loop_detection: LoopDetectionConfig::default(),
272            rules_scope: None,
273            extra_ignore_patterns: Vec::new(),
274        }
275    }
276}
277
278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
279pub enum RulesScope {
280    Both,
281    Global,
282    Project,
283}
284
285impl Config {
286    pub fn rules_scope_effective(&self) -> RulesScope {
287        let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
288            .ok()
289            .or_else(|| self.rules_scope.clone())
290            .unwrap_or_default();
291        match raw.trim().to_lowercase().as_str() {
292            "global" => RulesScope::Global,
293            "project" => RulesScope::Project,
294            _ => RulesScope::Both,
295        }
296    }
297
298    fn parse_disabled_tools_env(val: &str) -> Vec<String> {
299        val.split(',')
300            .map(|s| s.trim().to_string())
301            .filter(|s| !s.is_empty())
302            .collect()
303    }
304
305    pub fn disabled_tools_effective(&self) -> Vec<String> {
306        if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
307            Self::parse_disabled_tools_env(&val)
308        } else {
309            self.disabled_tools.clone()
310        }
311    }
312}
313
314#[cfg(test)]
315mod disabled_tools_tests {
316    use super::*;
317
318    #[test]
319    fn config_field_default_is_empty() {
320        let cfg = Config::default();
321        assert!(cfg.disabled_tools.is_empty());
322    }
323
324    #[test]
325    fn effective_returns_config_field_when_no_env_var() {
326        // Only meaningful when LEAN_CTX_DISABLED_TOOLS is unset; skip otherwise.
327        if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
328            return;
329        }
330        let cfg = Config {
331            disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
332            ..Default::default()
333        };
334        assert_eq!(
335            cfg.disabled_tools_effective(),
336            vec!["ctx_graph", "ctx_agent"]
337        );
338    }
339
340    #[test]
341    fn parse_env_basic() {
342        let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
343        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
344    }
345
346    #[test]
347    fn parse_env_trims_whitespace_and_skips_empty() {
348        let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
349        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
350    }
351
352    #[test]
353    fn parse_env_single_entry() {
354        let result = Config::parse_disabled_tools_env("ctx_graph");
355        assert_eq!(result, vec!["ctx_graph"]);
356    }
357
358    #[test]
359    fn parse_env_empty_string_returns_empty() {
360        let result = Config::parse_disabled_tools_env("");
361        assert!(result.is_empty());
362    }
363
364    #[test]
365    fn disabled_tools_deserialization_defaults_to_empty() {
366        let cfg: Config = toml::from_str("").unwrap();
367        assert!(cfg.disabled_tools.is_empty());
368    }
369
370    #[test]
371    fn disabled_tools_deserialization_from_toml() {
372        let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
373        assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
374    }
375}
376
377#[cfg(test)]
378mod rules_scope_tests {
379    use super::*;
380
381    #[test]
382    fn default_is_both() {
383        let cfg = Config::default();
384        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
385    }
386
387    #[test]
388    fn config_global() {
389        let cfg = Config {
390            rules_scope: Some("global".to_string()),
391            ..Default::default()
392        };
393        assert_eq!(cfg.rules_scope_effective(), RulesScope::Global);
394    }
395
396    #[test]
397    fn config_project() {
398        let cfg = Config {
399            rules_scope: Some("project".to_string()),
400            ..Default::default()
401        };
402        assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
403    }
404
405    #[test]
406    fn unknown_value_falls_back_to_both() {
407        let cfg = Config {
408            rules_scope: Some("nonsense".to_string()),
409            ..Default::default()
410        };
411        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
412    }
413
414    #[test]
415    fn deserialization_none_by_default() {
416        let cfg: Config = toml::from_str("").unwrap();
417        assert!(cfg.rules_scope.is_none());
418        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
419    }
420
421    #[test]
422    fn deserialization_from_toml() {
423        let cfg: Config = toml::from_str(r#"rules_scope = "project""#).unwrap();
424        assert_eq!(cfg.rules_scope.as_deref(), Some("project"));
425        assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
426    }
427}
428
429#[cfg(test)]
430mod loop_detection_config_tests {
431    use super::*;
432
433    #[test]
434    fn defaults_are_reasonable() {
435        let cfg = LoopDetectionConfig::default();
436        assert_eq!(cfg.normal_threshold, 2);
437        assert_eq!(cfg.reduced_threshold, 4);
438        assert_eq!(cfg.blocked_threshold, 6);
439        assert_eq!(cfg.window_secs, 300);
440        assert_eq!(cfg.search_group_limit, 10);
441    }
442
443    #[test]
444    fn deserialization_defaults_when_missing() {
445        let cfg: Config = toml::from_str("").unwrap();
446        assert_eq!(cfg.loop_detection.blocked_threshold, 6);
447        assert_eq!(cfg.loop_detection.search_group_limit, 10);
448    }
449
450    #[test]
451    fn deserialization_from_toml() {
452        let cfg: Config = toml::from_str(
453            r#"
454            [loop_detection]
455            normal_threshold = 1
456            reduced_threshold = 3
457            blocked_threshold = 5
458            window_secs = 120
459            search_group_limit = 8
460            "#,
461        )
462        .unwrap();
463        assert_eq!(cfg.loop_detection.normal_threshold, 1);
464        assert_eq!(cfg.loop_detection.reduced_threshold, 3);
465        assert_eq!(cfg.loop_detection.blocked_threshold, 5);
466        assert_eq!(cfg.loop_detection.window_secs, 120);
467        assert_eq!(cfg.loop_detection.search_group_limit, 8);
468    }
469
470    #[test]
471    fn partial_override_keeps_defaults() {
472        let cfg: Config = toml::from_str(
473            r#"
474            [loop_detection]
475            blocked_threshold = 10
476            "#,
477        )
478        .unwrap();
479        assert_eq!(cfg.loop_detection.blocked_threshold, 10);
480        assert_eq!(cfg.loop_detection.normal_threshold, 2);
481        assert_eq!(cfg.loop_detection.search_group_limit, 10);
482    }
483}
484
485impl Config {
486    pub fn path() -> Option<PathBuf> {
487        crate::core::data_dir::lean_ctx_data_dir()
488            .ok()
489            .map(|d| d.join("config.toml"))
490    }
491
492    pub fn local_path(project_root: &str) -> PathBuf {
493        PathBuf::from(project_root).join(".lean-ctx.toml")
494    }
495
496    fn find_project_root() -> Option<String> {
497        let cwd = std::env::current_dir().ok();
498
499        if let Some(root) =
500            crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
501        {
502            let root_path = std::path::Path::new(&root);
503            let cwd_is_under_root = cwd
504                .as_ref()
505                .map(|c| c.starts_with(root_path))
506                .unwrap_or(false);
507            let has_marker = root_path.join(".git").exists()
508                || root_path.join("Cargo.toml").exists()
509                || root_path.join("package.json").exists()
510                || root_path.join("go.mod").exists()
511                || root_path.join("pyproject.toml").exists()
512                || root_path.join(".lean-ctx.toml").exists();
513
514            if cwd_is_under_root || has_marker {
515                return Some(root);
516            }
517        }
518
519        if let Some(ref cwd) = cwd {
520            let git_root = std::process::Command::new("git")
521                .args(["rev-parse", "--show-toplevel"])
522                .current_dir(cwd)
523                .stdout(std::process::Stdio::piped())
524                .stderr(std::process::Stdio::null())
525                .output()
526                .ok()
527                .and_then(|o| {
528                    if o.status.success() {
529                        String::from_utf8(o.stdout)
530                            .ok()
531                            .map(|s| s.trim().to_string())
532                    } else {
533                        None
534                    }
535                });
536            if let Some(root) = git_root {
537                return Some(root);
538            }
539            return Some(cwd.to_string_lossy().to_string());
540        }
541        None
542    }
543
544    pub fn load() -> Self {
545        static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
546
547        let path = match Self::path() {
548            Some(p) => p,
549            None => return Self::default(),
550        };
551
552        let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
553
554        let mtime = std::fs::metadata(&path)
555            .and_then(|m| m.modified())
556            .unwrap_or(SystemTime::UNIX_EPOCH);
557
558        let local_mtime = local_path
559            .as_ref()
560            .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
561
562        if let Ok(guard) = CACHE.lock() {
563            if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
564                if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
565                    return cfg.clone();
566                }
567            }
568        }
569
570        let mut cfg: Config = match std::fs::read_to_string(&path) {
571            Ok(content) => toml::from_str(&content).unwrap_or_default(),
572            Err(_) => Self::default(),
573        };
574
575        if let Some(ref lp) = local_path {
576            if let Ok(local_content) = std::fs::read_to_string(lp) {
577                cfg.merge_local(&local_content);
578            }
579        }
580
581        if let Ok(mut guard) = CACHE.lock() {
582            *guard = Some((cfg.clone(), mtime, local_mtime));
583        }
584
585        cfg
586    }
587
588    fn merge_local(&mut self, local_toml: &str) {
589        let local: Config = match toml::from_str(local_toml) {
590            Ok(c) => c,
591            Err(_) => return,
592        };
593        if local.ultra_compact {
594            self.ultra_compact = true;
595        }
596        if local.tee_mode != TeeMode::default() {
597            self.tee_mode = local.tee_mode;
598        }
599        if local.output_density != OutputDensity::default() {
600            self.output_density = local.output_density;
601        }
602        if local.checkpoint_interval != 15 {
603            self.checkpoint_interval = local.checkpoint_interval;
604        }
605        if !local.excluded_commands.is_empty() {
606            self.excluded_commands.extend(local.excluded_commands);
607        }
608        if !local.passthrough_urls.is_empty() {
609            self.passthrough_urls.extend(local.passthrough_urls);
610        }
611        if !local.custom_aliases.is_empty() {
612            self.custom_aliases.extend(local.custom_aliases);
613        }
614        if local.slow_command_threshold_ms != 5000 {
615            self.slow_command_threshold_ms = local.slow_command_threshold_ms;
616        }
617        if local.theme != "default" {
618            self.theme = local.theme;
619        }
620        if !local.buddy_enabled {
621            self.buddy_enabled = false;
622        }
623        if !local.redirect_exclude.is_empty() {
624            self.redirect_exclude.extend(local.redirect_exclude);
625        }
626        if !local.disabled_tools.is_empty() {
627            self.disabled_tools.extend(local.disabled_tools);
628        }
629        if !local.extra_ignore_patterns.is_empty() {
630            self.extra_ignore_patterns
631                .extend(local.extra_ignore_patterns);
632        }
633        if local.rules_scope.is_some() {
634            self.rules_scope = local.rules_scope;
635        }
636        if !local.autonomy.enabled {
637            self.autonomy.enabled = false;
638        }
639        if !local.autonomy.auto_preload {
640            self.autonomy.auto_preload = false;
641        }
642        if !local.autonomy.auto_dedup {
643            self.autonomy.auto_dedup = false;
644        }
645        if !local.autonomy.auto_related {
646            self.autonomy.auto_related = false;
647        }
648        if !local.autonomy.auto_consolidate {
649            self.autonomy.auto_consolidate = false;
650        }
651        if local.autonomy.silent_preload {
652            self.autonomy.silent_preload = true;
653        }
654        if !local.autonomy.silent_preload && self.autonomy.silent_preload {
655            self.autonomy.silent_preload = false;
656        }
657        if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
658            self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
659        }
660        if local.autonomy.consolidate_every_calls
661            != AutonomyConfig::default().consolidate_every_calls
662        {
663            self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
664        }
665        if local.autonomy.consolidate_cooldown_secs
666            != AutonomyConfig::default().consolidate_cooldown_secs
667        {
668            self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
669        }
670    }
671
672    pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
673        let path = Self::path().ok_or_else(|| {
674            super::error::LeanCtxError::Config("cannot determine home directory".into())
675        })?;
676        if let Some(parent) = path.parent() {
677            std::fs::create_dir_all(parent)?;
678        }
679        let content = toml::to_string_pretty(self)
680            .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
681        std::fs::write(&path, content)?;
682        Ok(())
683    }
684
685    pub fn show(&self) -> String {
686        let global_path = Self::path()
687            .map(|p| p.to_string_lossy().to_string())
688            .unwrap_or_else(|| "~/.lean-ctx/config.toml".to_string());
689        let content = toml::to_string_pretty(self).unwrap_or_default();
690        let mut out = format!("Global config: {global_path}\n\n{content}");
691
692        if let Some(root) = Self::find_project_root() {
693            let local = Self::local_path(&root);
694            if local.exists() {
695                out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
696            } else {
697                out.push_str(&format!(
698                    "\n\nLocal config: not found (create {} to override per-project)\n",
699                    local.display()
700                ));
701            }
702        }
703        out
704    }
705}