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