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 silent_preload: bool,
115    pub dedup_threshold: usize,
116}
117
118impl Default for AutonomyConfig {
119    fn default() -> Self {
120        Self {
121            enabled: true,
122            auto_preload: true,
123            auto_dedup: true,
124            auto_related: true,
125            silent_preload: true,
126            dedup_threshold: 8,
127        }
128    }
129}
130
131impl AutonomyConfig {
132    pub fn from_env() -> Self {
133        let mut cfg = Self::default();
134        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
135            if v == "false" || v == "0" {
136                cfg.enabled = false;
137            }
138        }
139        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
140            cfg.auto_preload = v != "false" && v != "0";
141        }
142        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
143            cfg.auto_dedup = v != "false" && v != "0";
144        }
145        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
146            cfg.auto_related = v != "false" && v != "0";
147        }
148        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
149            cfg.silent_preload = v != "false" && v != "0";
150        }
151        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
152            if let Ok(n) = v.parse() {
153                cfg.dedup_threshold = n;
154            }
155        }
156        cfg
157    }
158
159    pub fn load() -> Self {
160        let file_cfg = Config::load().autonomy;
161        let mut cfg = file_cfg;
162        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
163            if v == "false" || v == "0" {
164                cfg.enabled = false;
165            }
166        }
167        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
168            cfg.auto_preload = v != "false" && v != "0";
169        }
170        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
171            cfg.auto_dedup = v != "false" && v != "0";
172        }
173        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
174            cfg.auto_related = v != "false" && v != "0";
175        }
176        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
177            cfg.silent_preload = v != "false" && v != "0";
178        }
179        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
180            if let Ok(n) = v.parse() {
181                cfg.dedup_threshold = n;
182            }
183        }
184        cfg
185    }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, Default)]
189#[serde(default)]
190pub struct CloudConfig {
191    pub contribute_enabled: bool,
192    pub last_contribute: Option<String>,
193    pub last_sync: Option<String>,
194    pub last_model_pull: Option<String>,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct AliasEntry {
199    pub command: String,
200    pub alias: String,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
204#[serde(default)]
205pub struct LoopDetectionConfig {
206    pub normal_threshold: u32,
207    pub reduced_threshold: u32,
208    pub blocked_threshold: u32,
209    pub window_secs: u64,
210    pub search_group_limit: u32,
211}
212
213impl Default for LoopDetectionConfig {
214    fn default() -> Self {
215        Self {
216            normal_threshold: 2,
217            reduced_threshold: 4,
218            blocked_threshold: 6,
219            window_secs: 300,
220            search_group_limit: 10,
221        }
222    }
223}
224
225impl Default for Config {
226    fn default() -> Self {
227        Self {
228            ultra_compact: false,
229            tee_mode: TeeMode::default(),
230            output_density: OutputDensity::default(),
231            checkpoint_interval: 15,
232            excluded_commands: Vec::new(),
233            passthrough_urls: Vec::new(),
234            custom_aliases: Vec::new(),
235            slow_command_threshold_ms: 5000,
236            theme: default_theme(),
237            cloud: CloudConfig::default(),
238            autonomy: AutonomyConfig::default(),
239            buddy_enabled: default_buddy_enabled(),
240            redirect_exclude: Vec::new(),
241            disabled_tools: Vec::new(),
242            loop_detection: LoopDetectionConfig::default(),
243        }
244    }
245}
246
247impl Config {
248    fn parse_disabled_tools_env(val: &str) -> Vec<String> {
249        val.split(',')
250            .map(|s| s.trim().to_string())
251            .filter(|s| !s.is_empty())
252            .collect()
253    }
254
255    pub fn disabled_tools_effective(&self) -> Vec<String> {
256        if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
257            Self::parse_disabled_tools_env(&val)
258        } else {
259            self.disabled_tools.clone()
260        }
261    }
262}
263
264#[cfg(test)]
265mod disabled_tools_tests {
266    use super::*;
267
268    #[test]
269    fn config_field_default_is_empty() {
270        let cfg = Config::default();
271        assert!(cfg.disabled_tools.is_empty());
272    }
273
274    #[test]
275    fn effective_returns_config_field_when_no_env_var() {
276        // Only meaningful when LEAN_CTX_DISABLED_TOOLS is unset; skip otherwise.
277        if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
278            return;
279        }
280        let mut cfg = Config::default();
281        cfg.disabled_tools = vec!["ctx_graph".to_string(), "ctx_agent".to_string()];
282        assert_eq!(
283            cfg.disabled_tools_effective(),
284            vec!["ctx_graph", "ctx_agent"]
285        );
286    }
287
288    #[test]
289    fn parse_env_basic() {
290        let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
291        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
292    }
293
294    #[test]
295    fn parse_env_trims_whitespace_and_skips_empty() {
296        let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
297        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
298    }
299
300    #[test]
301    fn parse_env_single_entry() {
302        let result = Config::parse_disabled_tools_env("ctx_graph");
303        assert_eq!(result, vec!["ctx_graph"]);
304    }
305
306    #[test]
307    fn parse_env_empty_string_returns_empty() {
308        let result = Config::parse_disabled_tools_env("");
309        assert!(result.is_empty());
310    }
311
312    #[test]
313    fn disabled_tools_deserialization_defaults_to_empty() {
314        let cfg: Config = toml::from_str("").unwrap();
315        assert!(cfg.disabled_tools.is_empty());
316    }
317
318    #[test]
319    fn disabled_tools_deserialization_from_toml() {
320        let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
321        assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
322    }
323}
324
325#[cfg(test)]
326mod loop_detection_config_tests {
327    use super::*;
328
329    #[test]
330    fn defaults_are_reasonable() {
331        let cfg = LoopDetectionConfig::default();
332        assert_eq!(cfg.normal_threshold, 2);
333        assert_eq!(cfg.reduced_threshold, 4);
334        assert_eq!(cfg.blocked_threshold, 6);
335        assert_eq!(cfg.window_secs, 300);
336        assert_eq!(cfg.search_group_limit, 10);
337    }
338
339    #[test]
340    fn deserialization_defaults_when_missing() {
341        let cfg: Config = toml::from_str("").unwrap();
342        assert_eq!(cfg.loop_detection.blocked_threshold, 6);
343        assert_eq!(cfg.loop_detection.search_group_limit, 10);
344    }
345
346    #[test]
347    fn deserialization_from_toml() {
348        let cfg: Config = toml::from_str(
349            r#"
350            [loop_detection]
351            normal_threshold = 1
352            reduced_threshold = 3
353            blocked_threshold = 5
354            window_secs = 120
355            search_group_limit = 8
356            "#,
357        )
358        .unwrap();
359        assert_eq!(cfg.loop_detection.normal_threshold, 1);
360        assert_eq!(cfg.loop_detection.reduced_threshold, 3);
361        assert_eq!(cfg.loop_detection.blocked_threshold, 5);
362        assert_eq!(cfg.loop_detection.window_secs, 120);
363        assert_eq!(cfg.loop_detection.search_group_limit, 8);
364    }
365
366    #[test]
367    fn partial_override_keeps_defaults() {
368        let cfg: Config = toml::from_str(
369            r#"
370            [loop_detection]
371            blocked_threshold = 10
372            "#,
373        )
374        .unwrap();
375        assert_eq!(cfg.loop_detection.blocked_threshold, 10);
376        assert_eq!(cfg.loop_detection.normal_threshold, 2);
377        assert_eq!(cfg.loop_detection.search_group_limit, 10);
378    }
379}
380
381impl Config {
382    pub fn path() -> Option<PathBuf> {
383        dirs::home_dir().map(|h| h.join(".lean-ctx").join("config.toml"))
384    }
385
386    pub fn load() -> Self {
387        static CACHE: Mutex<Option<(Config, SystemTime)>> = Mutex::new(None);
388
389        let path = match Self::path() {
390            Some(p) => p,
391            None => return Self::default(),
392        };
393
394        let mtime = std::fs::metadata(&path)
395            .and_then(|m| m.modified())
396            .unwrap_or(SystemTime::UNIX_EPOCH);
397
398        if let Ok(guard) = CACHE.lock() {
399            if let Some((ref cfg, ref cached_mtime)) = *guard {
400                if *cached_mtime == mtime {
401                    return cfg.clone();
402                }
403            }
404        }
405
406        let cfg = match std::fs::read_to_string(&path) {
407            Ok(content) => toml::from_str(&content).unwrap_or_default(),
408            Err(_) => Self::default(),
409        };
410
411        if let Ok(mut guard) = CACHE.lock() {
412            *guard = Some((cfg.clone(), mtime));
413        }
414
415        cfg
416    }
417
418    pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
419        let path = Self::path().ok_or_else(|| {
420            super::error::LeanCtxError::Config("cannot determine home directory".into())
421        })?;
422        if let Some(parent) = path.parent() {
423            std::fs::create_dir_all(parent)?;
424        }
425        let content = toml::to_string_pretty(self)
426            .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
427        std::fs::write(&path, content)?;
428        Ok(())
429    }
430
431    pub fn show(&self) -> String {
432        let path = Self::path()
433            .map(|p| p.to_string_lossy().to_string())
434            .unwrap_or_else(|| "~/.lean-ctx/config.toml".to_string());
435        let content = toml::to_string_pretty(self).unwrap_or_default();
436        format!("Config: {path}\n\n{content}")
437    }
438}