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 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 #[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 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}