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    /// Extra glob patterns to ignore in graph/overview/preload (repo-local).
79    /// Example: ["externals/**", "target/**", "temp/**"]
80    #[serde(default)]
81    pub extra_ignore_patterns: Vec<String>,
82}
83
84fn default_buddy_enabled() -> bool {
85    true
86}
87
88fn deserialize_tee_mode<'de, D>(deserializer: D) -> Result<TeeMode, D::Error>
89where
90    D: serde::Deserializer<'de>,
91{
92    use serde::de::Error;
93    let v = serde_json::Value::deserialize(deserializer)?;
94    match &v {
95        serde_json::Value::Bool(true) => Ok(TeeMode::Failures),
96        serde_json::Value::Bool(false) => Ok(TeeMode::Never),
97        serde_json::Value::String(s) => match s.as_str() {
98            "never" => Ok(TeeMode::Never),
99            "failures" => Ok(TeeMode::Failures),
100            "always" => Ok(TeeMode::Always),
101            other => Err(D::Error::custom(format!("unknown tee_mode: {other}"))),
102        },
103        _ => Err(D::Error::custom("tee_mode must be string or bool")),
104    }
105}
106
107fn default_theme() -> String {
108    "default".to_string()
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
112#[serde(default)]
113pub struct AutonomyConfig {
114    pub enabled: bool,
115    pub auto_preload: bool,
116    pub auto_dedup: bool,
117    pub auto_related: bool,
118    pub auto_consolidate: bool,
119    pub silent_preload: bool,
120    pub dedup_threshold: usize,
121    pub consolidate_every_calls: u32,
122    pub consolidate_cooldown_secs: u64,
123}
124
125impl Default for AutonomyConfig {
126    fn default() -> Self {
127        Self {
128            enabled: true,
129            auto_preload: true,
130            auto_dedup: true,
131            auto_related: true,
132            auto_consolidate: true,
133            silent_preload: true,
134            dedup_threshold: 8,
135            consolidate_every_calls: 25,
136            consolidate_cooldown_secs: 120,
137        }
138    }
139}
140
141impl AutonomyConfig {
142    pub fn from_env() -> Self {
143        let mut cfg = Self::default();
144        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
145            if v == "false" || v == "0" {
146                cfg.enabled = false;
147            }
148        }
149        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
150            cfg.auto_preload = v != "false" && v != "0";
151        }
152        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
153            cfg.auto_dedup = v != "false" && v != "0";
154        }
155        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
156            cfg.auto_related = v != "false" && v != "0";
157        }
158        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
159            cfg.auto_consolidate = v != "false" && v != "0";
160        }
161        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
162            cfg.silent_preload = v != "false" && v != "0";
163        }
164        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
165            if let Ok(n) = v.parse() {
166                cfg.dedup_threshold = n;
167            }
168        }
169        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
170            if let Ok(n) = v.parse() {
171                cfg.consolidate_every_calls = n;
172            }
173        }
174        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
175            if let Ok(n) = v.parse() {
176                cfg.consolidate_cooldown_secs = n;
177            }
178        }
179        cfg
180    }
181
182    pub fn load() -> Self {
183        let file_cfg = Config::load().autonomy;
184        let mut cfg = file_cfg;
185        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
186            if v == "false" || v == "0" {
187                cfg.enabled = false;
188            }
189        }
190        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
191            cfg.auto_preload = v != "false" && v != "0";
192        }
193        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
194            cfg.auto_dedup = v != "false" && v != "0";
195        }
196        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
197            cfg.auto_related = v != "false" && v != "0";
198        }
199        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
200            cfg.silent_preload = v != "false" && v != "0";
201        }
202        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
203            if let Ok(n) = v.parse() {
204                cfg.dedup_threshold = n;
205            }
206        }
207        cfg
208    }
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize, Default)]
212#[serde(default)]
213pub struct CloudConfig {
214    pub contribute_enabled: bool,
215    pub last_contribute: Option<String>,
216    pub last_sync: Option<String>,
217    pub last_gain_sync: Option<String>,
218    pub last_model_pull: Option<String>,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct AliasEntry {
223    pub command: String,
224    pub alias: String,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
228#[serde(default)]
229pub struct LoopDetectionConfig {
230    pub normal_threshold: u32,
231    pub reduced_threshold: u32,
232    pub blocked_threshold: u32,
233    pub window_secs: u64,
234    pub search_group_limit: u32,
235}
236
237impl Default for LoopDetectionConfig {
238    fn default() -> Self {
239        Self {
240            normal_threshold: 2,
241            reduced_threshold: 4,
242            blocked_threshold: 6,
243            window_secs: 300,
244            search_group_limit: 10,
245        }
246    }
247}
248
249impl Default for Config {
250    fn default() -> Self {
251        Self {
252            ultra_compact: false,
253            tee_mode: TeeMode::default(),
254            output_density: OutputDensity::default(),
255            checkpoint_interval: 15,
256            excluded_commands: Vec::new(),
257            passthrough_urls: Vec::new(),
258            custom_aliases: Vec::new(),
259            slow_command_threshold_ms: 5000,
260            theme: default_theme(),
261            cloud: CloudConfig::default(),
262            autonomy: AutonomyConfig::default(),
263            buddy_enabled: default_buddy_enabled(),
264            redirect_exclude: Vec::new(),
265            disabled_tools: Vec::new(),
266            loop_detection: LoopDetectionConfig::default(),
267            extra_ignore_patterns: Vec::new(),
268        }
269    }
270}
271
272impl Config {
273    fn parse_disabled_tools_env(val: &str) -> Vec<String> {
274        val.split(',')
275            .map(|s| s.trim().to_string())
276            .filter(|s| !s.is_empty())
277            .collect()
278    }
279
280    pub fn disabled_tools_effective(&self) -> Vec<String> {
281        if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
282            Self::parse_disabled_tools_env(&val)
283        } else {
284            self.disabled_tools.clone()
285        }
286    }
287}
288
289#[cfg(test)]
290mod disabled_tools_tests {
291    use super::*;
292
293    #[test]
294    fn config_field_default_is_empty() {
295        let cfg = Config::default();
296        assert!(cfg.disabled_tools.is_empty());
297    }
298
299    #[test]
300    fn effective_returns_config_field_when_no_env_var() {
301        // Only meaningful when LEAN_CTX_DISABLED_TOOLS is unset; skip otherwise.
302        if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
303            return;
304        }
305        let cfg = Config {
306            disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
307            ..Default::default()
308        };
309        assert_eq!(
310            cfg.disabled_tools_effective(),
311            vec!["ctx_graph", "ctx_agent"]
312        );
313    }
314
315    #[test]
316    fn parse_env_basic() {
317        let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
318        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
319    }
320
321    #[test]
322    fn parse_env_trims_whitespace_and_skips_empty() {
323        let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
324        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
325    }
326
327    #[test]
328    fn parse_env_single_entry() {
329        let result = Config::parse_disabled_tools_env("ctx_graph");
330        assert_eq!(result, vec!["ctx_graph"]);
331    }
332
333    #[test]
334    fn parse_env_empty_string_returns_empty() {
335        let result = Config::parse_disabled_tools_env("");
336        assert!(result.is_empty());
337    }
338
339    #[test]
340    fn disabled_tools_deserialization_defaults_to_empty() {
341        let cfg: Config = toml::from_str("").unwrap();
342        assert!(cfg.disabled_tools.is_empty());
343    }
344
345    #[test]
346    fn disabled_tools_deserialization_from_toml() {
347        let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
348        assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
349    }
350}
351
352#[cfg(test)]
353mod loop_detection_config_tests {
354    use super::*;
355
356    #[test]
357    fn defaults_are_reasonable() {
358        let cfg = LoopDetectionConfig::default();
359        assert_eq!(cfg.normal_threshold, 2);
360        assert_eq!(cfg.reduced_threshold, 4);
361        assert_eq!(cfg.blocked_threshold, 6);
362        assert_eq!(cfg.window_secs, 300);
363        assert_eq!(cfg.search_group_limit, 10);
364    }
365
366    #[test]
367    fn deserialization_defaults_when_missing() {
368        let cfg: Config = toml::from_str("").unwrap();
369        assert_eq!(cfg.loop_detection.blocked_threshold, 6);
370        assert_eq!(cfg.loop_detection.search_group_limit, 10);
371    }
372
373    #[test]
374    fn deserialization_from_toml() {
375        let cfg: Config = toml::from_str(
376            r#"
377            [loop_detection]
378            normal_threshold = 1
379            reduced_threshold = 3
380            blocked_threshold = 5
381            window_secs = 120
382            search_group_limit = 8
383            "#,
384        )
385        .unwrap();
386        assert_eq!(cfg.loop_detection.normal_threshold, 1);
387        assert_eq!(cfg.loop_detection.reduced_threshold, 3);
388        assert_eq!(cfg.loop_detection.blocked_threshold, 5);
389        assert_eq!(cfg.loop_detection.window_secs, 120);
390        assert_eq!(cfg.loop_detection.search_group_limit, 8);
391    }
392
393    #[test]
394    fn partial_override_keeps_defaults() {
395        let cfg: Config = toml::from_str(
396            r#"
397            [loop_detection]
398            blocked_threshold = 10
399            "#,
400        )
401        .unwrap();
402        assert_eq!(cfg.loop_detection.blocked_threshold, 10);
403        assert_eq!(cfg.loop_detection.normal_threshold, 2);
404        assert_eq!(cfg.loop_detection.search_group_limit, 10);
405    }
406}
407
408impl Config {
409    pub fn path() -> Option<PathBuf> {
410        crate::core::data_dir::lean_ctx_data_dir()
411            .ok()
412            .map(|d| d.join("config.toml"))
413    }
414
415    pub fn local_path(project_root: &str) -> PathBuf {
416        PathBuf::from(project_root).join(".lean-ctx.toml")
417    }
418
419    fn find_project_root() -> Option<String> {
420        if let Some(root) =
421            crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
422        {
423            return Some(root);
424        }
425        if let Ok(cwd) = std::env::current_dir() {
426            let git_root = std::process::Command::new("git")
427                .args(["rev-parse", "--show-toplevel"])
428                .current_dir(&cwd)
429                .stdout(std::process::Stdio::piped())
430                .stderr(std::process::Stdio::null())
431                .output()
432                .ok()
433                .and_then(|o| {
434                    if o.status.success() {
435                        String::from_utf8(o.stdout)
436                            .ok()
437                            .map(|s| s.trim().to_string())
438                    } else {
439                        None
440                    }
441                });
442            if let Some(root) = git_root {
443                return Some(root);
444            }
445            return Some(cwd.to_string_lossy().to_string());
446        }
447        None
448    }
449
450    pub fn load() -> Self {
451        static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
452
453        let path = match Self::path() {
454            Some(p) => p,
455            None => return Self::default(),
456        };
457
458        let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
459
460        let mtime = std::fs::metadata(&path)
461            .and_then(|m| m.modified())
462            .unwrap_or(SystemTime::UNIX_EPOCH);
463
464        let local_mtime = local_path
465            .as_ref()
466            .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
467
468        if let Ok(guard) = CACHE.lock() {
469            if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
470                if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
471                    return cfg.clone();
472                }
473            }
474        }
475
476        let mut cfg: Config = match std::fs::read_to_string(&path) {
477            Ok(content) => toml::from_str(&content).unwrap_or_default(),
478            Err(_) => Self::default(),
479        };
480
481        if let Some(ref lp) = local_path {
482            if let Ok(local_content) = std::fs::read_to_string(lp) {
483                cfg.merge_local(&local_content);
484            }
485        }
486
487        if let Ok(mut guard) = CACHE.lock() {
488            *guard = Some((cfg.clone(), mtime, local_mtime));
489        }
490
491        cfg
492    }
493
494    fn merge_local(&mut self, local_toml: &str) {
495        let local: Config = match toml::from_str(local_toml) {
496            Ok(c) => c,
497            Err(_) => return,
498        };
499        if local.ultra_compact {
500            self.ultra_compact = true;
501        }
502        if local.tee_mode != TeeMode::default() {
503            self.tee_mode = local.tee_mode;
504        }
505        if local.output_density != OutputDensity::default() {
506            self.output_density = local.output_density;
507        }
508        if local.checkpoint_interval != 15 {
509            self.checkpoint_interval = local.checkpoint_interval;
510        }
511        if !local.excluded_commands.is_empty() {
512            self.excluded_commands.extend(local.excluded_commands);
513        }
514        if !local.passthrough_urls.is_empty() {
515            self.passthrough_urls.extend(local.passthrough_urls);
516        }
517        if !local.custom_aliases.is_empty() {
518            self.custom_aliases.extend(local.custom_aliases);
519        }
520        if local.slow_command_threshold_ms != 5000 {
521            self.slow_command_threshold_ms = local.slow_command_threshold_ms;
522        }
523        if local.theme != "default" {
524            self.theme = local.theme;
525        }
526        if !local.buddy_enabled {
527            self.buddy_enabled = false;
528        }
529        if !local.redirect_exclude.is_empty() {
530            self.redirect_exclude.extend(local.redirect_exclude);
531        }
532        if !local.disabled_tools.is_empty() {
533            self.disabled_tools.extend(local.disabled_tools);
534        }
535        if !local.extra_ignore_patterns.is_empty() {
536            self.extra_ignore_patterns
537                .extend(local.extra_ignore_patterns);
538        }
539        if !local.autonomy.enabled {
540            self.autonomy.enabled = false;
541        }
542        if !local.autonomy.auto_preload {
543            self.autonomy.auto_preload = false;
544        }
545        if !local.autonomy.auto_dedup {
546            self.autonomy.auto_dedup = false;
547        }
548        if !local.autonomy.auto_related {
549            self.autonomy.auto_related = false;
550        }
551        if !local.autonomy.auto_consolidate {
552            self.autonomy.auto_consolidate = false;
553        }
554        if local.autonomy.silent_preload {
555            self.autonomy.silent_preload = true;
556        }
557        if !local.autonomy.silent_preload && self.autonomy.silent_preload {
558            self.autonomy.silent_preload = false;
559        }
560        if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
561            self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
562        }
563        if local.autonomy.consolidate_every_calls
564            != AutonomyConfig::default().consolidate_every_calls
565        {
566            self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
567        }
568        if local.autonomy.consolidate_cooldown_secs
569            != AutonomyConfig::default().consolidate_cooldown_secs
570        {
571            self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
572        }
573    }
574
575    pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
576        let path = Self::path().ok_or_else(|| {
577            super::error::LeanCtxError::Config("cannot determine home directory".into())
578        })?;
579        if let Some(parent) = path.parent() {
580            std::fs::create_dir_all(parent)?;
581        }
582        let content = toml::to_string_pretty(self)
583            .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
584        std::fs::write(&path, content)?;
585        Ok(())
586    }
587
588    pub fn show(&self) -> String {
589        let global_path = Self::path()
590            .map(|p| p.to_string_lossy().to_string())
591            .unwrap_or_else(|| "~/.lean-ctx/config.toml".to_string());
592        let content = toml::to_string_pretty(self).unwrap_or_default();
593        let mut out = format!("Global config: {global_path}\n\n{content}");
594
595        if let Some(root) = Self::find_project_root() {
596            let local = Self::local_path(&root);
597            if local.exists() {
598                out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
599            } else {
600                out.push_str(&format!(
601                    "\n\nLocal config: not found (create {} to override per-project)\n",
602                    local.display()
603                ));
604            }
605        }
606        out
607    }
608}