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