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, Serialize, Deserialize)]
16#[serde(default)]
17pub struct Config {
18    pub ultra_compact: bool,
19    #[serde(default, deserialize_with = "deserialize_tee_mode")]
20    pub tee_mode: TeeMode,
21    pub checkpoint_interval: u32,
22    pub excluded_commands: Vec<String>,
23    pub passthrough_urls: Vec<String>,
24    pub custom_aliases: Vec<AliasEntry>,
25    /// Commands taking longer than this threshold (ms) are recorded in the slow log.
26    /// Set to 0 to disable slow logging.
27    pub slow_command_threshold_ms: u64,
28    #[serde(default = "default_theme")]
29    pub theme: String,
30    #[serde(default)]
31    pub cloud: CloudConfig,
32    #[serde(default)]
33    pub autonomy: AutonomyConfig,
34    #[serde(default = "default_buddy_enabled")]
35    pub buddy_enabled: bool,
36    #[serde(default)]
37    pub redirect_exclude: Vec<String>,
38    /// Tools to exclude from the MCP tool list returned by list_tools.
39    /// Accepts exact tool names (e.g. ["ctx_graph", "ctx_agent"]).
40    /// Empty by default — all tools listed, no behaviour change.
41    #[serde(default)]
42    pub disabled_tools: Vec<String>,
43}
44
45fn default_buddy_enabled() -> bool {
46    true
47}
48
49fn deserialize_tee_mode<'de, D>(deserializer: D) -> Result<TeeMode, D::Error>
50where
51    D: serde::Deserializer<'de>,
52{
53    use serde::de::Error;
54    let v = serde_json::Value::deserialize(deserializer)?;
55    match &v {
56        serde_json::Value::Bool(true) => Ok(TeeMode::Failures),
57        serde_json::Value::Bool(false) => Ok(TeeMode::Never),
58        serde_json::Value::String(s) => match s.as_str() {
59            "never" => Ok(TeeMode::Never),
60            "failures" => Ok(TeeMode::Failures),
61            "always" => Ok(TeeMode::Always),
62            other => Err(D::Error::custom(format!("unknown tee_mode: {other}"))),
63        },
64        _ => Err(D::Error::custom("tee_mode must be string or bool")),
65    }
66}
67
68fn default_theme() -> String {
69    "default".to_string()
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73#[serde(default)]
74pub struct AutonomyConfig {
75    pub enabled: bool,
76    pub auto_preload: bool,
77    pub auto_dedup: bool,
78    pub auto_related: bool,
79    pub silent_preload: bool,
80    pub dedup_threshold: usize,
81}
82
83impl Default for AutonomyConfig {
84    fn default() -> Self {
85        Self {
86            enabled: true,
87            auto_preload: true,
88            auto_dedup: true,
89            auto_related: true,
90            silent_preload: true,
91            dedup_threshold: 8,
92        }
93    }
94}
95
96impl AutonomyConfig {
97    pub fn from_env() -> Self {
98        let mut cfg = Self::default();
99        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
100            if v == "false" || v == "0" {
101                cfg.enabled = false;
102            }
103        }
104        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
105            cfg.auto_preload = v != "false" && v != "0";
106        }
107        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
108            cfg.auto_dedup = v != "false" && v != "0";
109        }
110        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
111            cfg.auto_related = v != "false" && v != "0";
112        }
113        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
114            cfg.silent_preload = v != "false" && v != "0";
115        }
116        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
117            if let Ok(n) = v.parse() {
118                cfg.dedup_threshold = n;
119            }
120        }
121        cfg
122    }
123
124    pub fn load() -> Self {
125        let file_cfg = Config::load().autonomy;
126        let mut cfg = file_cfg;
127        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
128            if v == "false" || v == "0" {
129                cfg.enabled = false;
130            }
131        }
132        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
133            cfg.auto_preload = v != "false" && v != "0";
134        }
135        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
136            cfg.auto_dedup = v != "false" && v != "0";
137        }
138        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
139            cfg.auto_related = v != "false" && v != "0";
140        }
141        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
142            cfg.silent_preload = v != "false" && v != "0";
143        }
144        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
145            if let Ok(n) = v.parse() {
146                cfg.dedup_threshold = n;
147            }
148        }
149        cfg
150    }
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize, Default)]
154#[serde(default)]
155pub struct CloudConfig {
156    pub contribute_enabled: bool,
157    pub last_contribute: Option<String>,
158    pub last_sync: Option<String>,
159    pub last_model_pull: Option<String>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct AliasEntry {
164    pub command: String,
165    pub alias: String,
166}
167
168impl Default for Config {
169    fn default() -> Self {
170        Self {
171            ultra_compact: false,
172            tee_mode: TeeMode::default(),
173            checkpoint_interval: 15,
174            excluded_commands: Vec::new(),
175            passthrough_urls: Vec::new(),
176            custom_aliases: Vec::new(),
177            slow_command_threshold_ms: 5000,
178            theme: default_theme(),
179            cloud: CloudConfig::default(),
180            autonomy: AutonomyConfig::default(),
181            buddy_enabled: default_buddy_enabled(),
182            redirect_exclude: Vec::new(),
183            disabled_tools: Vec::new(),
184        }
185    }
186}
187
188impl Config {
189    fn parse_disabled_tools_env(val: &str) -> Vec<String> {
190        val.split(',')
191            .map(|s| s.trim().to_string())
192            .filter(|s| !s.is_empty())
193            .collect()
194    }
195
196    pub fn disabled_tools_effective(&self) -> Vec<String> {
197        if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
198            Self::parse_disabled_tools_env(&val)
199        } else {
200            self.disabled_tools.clone()
201        }
202    }
203}
204
205#[cfg(test)]
206mod disabled_tools_tests {
207    use super::*;
208
209    #[test]
210    fn config_field_default_is_empty() {
211        let cfg = Config::default();
212        assert!(cfg.disabled_tools.is_empty());
213    }
214
215    #[test]
216    fn effective_returns_config_field_when_no_env_var() {
217        // Only meaningful when LEAN_CTX_DISABLED_TOOLS is unset; skip otherwise.
218        if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
219            return;
220        }
221        let mut cfg = Config::default();
222        cfg.disabled_tools = vec!["ctx_graph".to_string(), "ctx_agent".to_string()];
223        assert_eq!(
224            cfg.disabled_tools_effective(),
225            vec!["ctx_graph", "ctx_agent"]
226        );
227    }
228
229    #[test]
230    fn parse_env_basic() {
231        let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
232        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
233    }
234
235    #[test]
236    fn parse_env_trims_whitespace_and_skips_empty() {
237        let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
238        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
239    }
240
241    #[test]
242    fn parse_env_single_entry() {
243        let result = Config::parse_disabled_tools_env("ctx_graph");
244        assert_eq!(result, vec!["ctx_graph"]);
245    }
246
247    #[test]
248    fn parse_env_empty_string_returns_empty() {
249        let result = Config::parse_disabled_tools_env("");
250        assert!(result.is_empty());
251    }
252
253    #[test]
254    fn disabled_tools_deserialization_defaults_to_empty() {
255        let cfg: Config = toml::from_str("").unwrap();
256        assert!(cfg.disabled_tools.is_empty());
257    }
258
259    #[test]
260    fn disabled_tools_deserialization_from_toml() {
261        let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
262        assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
263    }
264}
265
266impl Config {
267    pub fn path() -> Option<PathBuf> {
268        dirs::home_dir().map(|h| h.join(".lean-ctx").join("config.toml"))
269    }
270
271    pub fn load() -> Self {
272        static CACHE: Mutex<Option<(Config, SystemTime)>> = Mutex::new(None);
273
274        let path = match Self::path() {
275            Some(p) => p,
276            None => return Self::default(),
277        };
278
279        let mtime = std::fs::metadata(&path)
280            .and_then(|m| m.modified())
281            .unwrap_or(SystemTime::UNIX_EPOCH);
282
283        if let Ok(guard) = CACHE.lock() {
284            if let Some((ref cfg, ref cached_mtime)) = *guard {
285                if *cached_mtime == mtime {
286                    return cfg.clone();
287                }
288            }
289        }
290
291        let cfg = match std::fs::read_to_string(&path) {
292            Ok(content) => toml::from_str(&content).unwrap_or_default(),
293            Err(_) => Self::default(),
294        };
295
296        if let Ok(mut guard) = CACHE.lock() {
297            *guard = Some((cfg.clone(), mtime));
298        }
299
300        cfg
301    }
302
303    pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
304        let path = Self::path().ok_or_else(|| {
305            super::error::LeanCtxError::Config("cannot determine home directory".into())
306        })?;
307        if let Some(parent) = path.parent() {
308            std::fs::create_dir_all(parent)?;
309        }
310        let content = toml::to_string_pretty(self)
311            .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
312        std::fs::write(&path, content)?;
313        Ok(())
314    }
315
316    pub fn show(&self) -> String {
317        let path = Self::path()
318            .map(|p| p.to_string_lossy().to_string())
319            .unwrap_or_else(|| "~/.lean-ctx/config.toml".to_string());
320        let content = toml::to_string_pretty(self).unwrap_or_default();
321        format!("Config: {path}\n\n{content}")
322    }
323}