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)]
82 pub rules_scope: Option<String>,
83 #[serde(default)]
86 pub extra_ignore_patterns: Vec<String>,
87}
88
89fn default_buddy_enabled() -> bool {
90 true
91}
92
93fn deserialize_tee_mode<'de, D>(deserializer: D) -> Result<TeeMode, D::Error>
94where
95 D: serde::Deserializer<'de>,
96{
97 use serde::de::Error;
98 let v = serde_json::Value::deserialize(deserializer)?;
99 match &v {
100 serde_json::Value::Bool(true) => Ok(TeeMode::Failures),
101 serde_json::Value::Bool(false) => Ok(TeeMode::Never),
102 serde_json::Value::String(s) => match s.as_str() {
103 "never" => Ok(TeeMode::Never),
104 "failures" => Ok(TeeMode::Failures),
105 "always" => Ok(TeeMode::Always),
106 other => Err(D::Error::custom(format!("unknown tee_mode: {other}"))),
107 },
108 _ => Err(D::Error::custom("tee_mode must be string or bool")),
109 }
110}
111
112fn default_theme() -> String {
113 "default".to_string()
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117#[serde(default)]
118pub struct AutonomyConfig {
119 pub enabled: bool,
120 pub auto_preload: bool,
121 pub auto_dedup: bool,
122 pub auto_related: bool,
123 pub auto_consolidate: bool,
124 pub silent_preload: bool,
125 pub dedup_threshold: usize,
126 pub consolidate_every_calls: u32,
127 pub consolidate_cooldown_secs: u64,
128}
129
130impl Default for AutonomyConfig {
131 fn default() -> Self {
132 Self {
133 enabled: true,
134 auto_preload: true,
135 auto_dedup: true,
136 auto_related: true,
137 auto_consolidate: true,
138 silent_preload: true,
139 dedup_threshold: 8,
140 consolidate_every_calls: 25,
141 consolidate_cooldown_secs: 120,
142 }
143 }
144}
145
146impl AutonomyConfig {
147 pub fn from_env() -> Self {
148 let mut cfg = Self::default();
149 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
150 if v == "false" || v == "0" {
151 cfg.enabled = false;
152 }
153 }
154 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
155 cfg.auto_preload = v != "false" && v != "0";
156 }
157 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
158 cfg.auto_dedup = v != "false" && v != "0";
159 }
160 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
161 cfg.auto_related = v != "false" && v != "0";
162 }
163 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
164 cfg.auto_consolidate = v != "false" && v != "0";
165 }
166 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
167 cfg.silent_preload = v != "false" && v != "0";
168 }
169 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
170 if let Ok(n) = v.parse() {
171 cfg.dedup_threshold = n;
172 }
173 }
174 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
175 if let Ok(n) = v.parse() {
176 cfg.consolidate_every_calls = n;
177 }
178 }
179 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
180 if let Ok(n) = v.parse() {
181 cfg.consolidate_cooldown_secs = n;
182 }
183 }
184 cfg
185 }
186
187 pub fn load() -> Self {
188 let file_cfg = Config::load().autonomy;
189 let mut cfg = file_cfg;
190 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
191 if v == "false" || v == "0" {
192 cfg.enabled = false;
193 }
194 }
195 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
196 cfg.auto_preload = v != "false" && v != "0";
197 }
198 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
199 cfg.auto_dedup = v != "false" && v != "0";
200 }
201 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
202 cfg.auto_related = v != "false" && v != "0";
203 }
204 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
205 cfg.silent_preload = v != "false" && v != "0";
206 }
207 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
208 if let Ok(n) = v.parse() {
209 cfg.dedup_threshold = n;
210 }
211 }
212 cfg
213 }
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, Default)]
217#[serde(default)]
218pub struct CloudConfig {
219 pub contribute_enabled: bool,
220 pub last_contribute: Option<String>,
221 pub last_sync: Option<String>,
222 pub last_gain_sync: Option<String>,
223 pub last_model_pull: Option<String>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct AliasEntry {
228 pub command: String,
229 pub alias: String,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize)]
233#[serde(default)]
234pub struct LoopDetectionConfig {
235 pub normal_threshold: u32,
236 pub reduced_threshold: u32,
237 pub blocked_threshold: u32,
238 pub window_secs: u64,
239 pub search_group_limit: u32,
240}
241
242impl Default for LoopDetectionConfig {
243 fn default() -> Self {
244 Self {
245 normal_threshold: 2,
246 reduced_threshold: 4,
247 blocked_threshold: 6,
248 window_secs: 300,
249 search_group_limit: 10,
250 }
251 }
252}
253
254impl Default for Config {
255 fn default() -> Self {
256 Self {
257 ultra_compact: false,
258 tee_mode: TeeMode::default(),
259 output_density: OutputDensity::default(),
260 checkpoint_interval: 15,
261 excluded_commands: Vec::new(),
262 passthrough_urls: Vec::new(),
263 custom_aliases: Vec::new(),
264 slow_command_threshold_ms: 5000,
265 theme: default_theme(),
266 cloud: CloudConfig::default(),
267 autonomy: AutonomyConfig::default(),
268 buddy_enabled: default_buddy_enabled(),
269 redirect_exclude: Vec::new(),
270 disabled_tools: Vec::new(),
271 loop_detection: LoopDetectionConfig::default(),
272 rules_scope: None,
273 extra_ignore_patterns: Vec::new(),
274 }
275 }
276}
277
278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
279pub enum RulesScope {
280 Both,
281 Global,
282 Project,
283}
284
285impl Config {
286 pub fn rules_scope_effective(&self) -> RulesScope {
287 let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
288 .ok()
289 .or_else(|| self.rules_scope.clone())
290 .unwrap_or_default();
291 match raw.trim().to_lowercase().as_str() {
292 "global" => RulesScope::Global,
293 "project" => RulesScope::Project,
294 _ => RulesScope::Both,
295 }
296 }
297
298 fn parse_disabled_tools_env(val: &str) -> Vec<String> {
299 val.split(',')
300 .map(|s| s.trim().to_string())
301 .filter(|s| !s.is_empty())
302 .collect()
303 }
304
305 pub fn disabled_tools_effective(&self) -> Vec<String> {
306 if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
307 Self::parse_disabled_tools_env(&val)
308 } else {
309 self.disabled_tools.clone()
310 }
311 }
312}
313
314#[cfg(test)]
315mod disabled_tools_tests {
316 use super::*;
317
318 #[test]
319 fn config_field_default_is_empty() {
320 let cfg = Config::default();
321 assert!(cfg.disabled_tools.is_empty());
322 }
323
324 #[test]
325 fn effective_returns_config_field_when_no_env_var() {
326 if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
328 return;
329 }
330 let cfg = Config {
331 disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
332 ..Default::default()
333 };
334 assert_eq!(
335 cfg.disabled_tools_effective(),
336 vec!["ctx_graph", "ctx_agent"]
337 );
338 }
339
340 #[test]
341 fn parse_env_basic() {
342 let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
343 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
344 }
345
346 #[test]
347 fn parse_env_trims_whitespace_and_skips_empty() {
348 let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
349 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
350 }
351
352 #[test]
353 fn parse_env_single_entry() {
354 let result = Config::parse_disabled_tools_env("ctx_graph");
355 assert_eq!(result, vec!["ctx_graph"]);
356 }
357
358 #[test]
359 fn parse_env_empty_string_returns_empty() {
360 let result = Config::parse_disabled_tools_env("");
361 assert!(result.is_empty());
362 }
363
364 #[test]
365 fn disabled_tools_deserialization_defaults_to_empty() {
366 let cfg: Config = toml::from_str("").unwrap();
367 assert!(cfg.disabled_tools.is_empty());
368 }
369
370 #[test]
371 fn disabled_tools_deserialization_from_toml() {
372 let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
373 assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
374 }
375}
376
377#[cfg(test)]
378mod rules_scope_tests {
379 use super::*;
380
381 #[test]
382 fn default_is_both() {
383 let cfg = Config::default();
384 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
385 }
386
387 #[test]
388 fn config_global() {
389 let cfg = Config {
390 rules_scope: Some("global".to_string()),
391 ..Default::default()
392 };
393 assert_eq!(cfg.rules_scope_effective(), RulesScope::Global);
394 }
395
396 #[test]
397 fn config_project() {
398 let cfg = Config {
399 rules_scope: Some("project".to_string()),
400 ..Default::default()
401 };
402 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
403 }
404
405 #[test]
406 fn unknown_value_falls_back_to_both() {
407 let cfg = Config {
408 rules_scope: Some("nonsense".to_string()),
409 ..Default::default()
410 };
411 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
412 }
413
414 #[test]
415 fn deserialization_none_by_default() {
416 let cfg: Config = toml::from_str("").unwrap();
417 assert!(cfg.rules_scope.is_none());
418 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
419 }
420
421 #[test]
422 fn deserialization_from_toml() {
423 let cfg: Config = toml::from_str(r#"rules_scope = "project""#).unwrap();
424 assert_eq!(cfg.rules_scope.as_deref(), Some("project"));
425 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
426 }
427}
428
429#[cfg(test)]
430mod loop_detection_config_tests {
431 use super::*;
432
433 #[test]
434 fn defaults_are_reasonable() {
435 let cfg = LoopDetectionConfig::default();
436 assert_eq!(cfg.normal_threshold, 2);
437 assert_eq!(cfg.reduced_threshold, 4);
438 assert_eq!(cfg.blocked_threshold, 6);
439 assert_eq!(cfg.window_secs, 300);
440 assert_eq!(cfg.search_group_limit, 10);
441 }
442
443 #[test]
444 fn deserialization_defaults_when_missing() {
445 let cfg: Config = toml::from_str("").unwrap();
446 assert_eq!(cfg.loop_detection.blocked_threshold, 6);
447 assert_eq!(cfg.loop_detection.search_group_limit, 10);
448 }
449
450 #[test]
451 fn deserialization_from_toml() {
452 let cfg: Config = toml::from_str(
453 r#"
454 [loop_detection]
455 normal_threshold = 1
456 reduced_threshold = 3
457 blocked_threshold = 5
458 window_secs = 120
459 search_group_limit = 8
460 "#,
461 )
462 .unwrap();
463 assert_eq!(cfg.loop_detection.normal_threshold, 1);
464 assert_eq!(cfg.loop_detection.reduced_threshold, 3);
465 assert_eq!(cfg.loop_detection.blocked_threshold, 5);
466 assert_eq!(cfg.loop_detection.window_secs, 120);
467 assert_eq!(cfg.loop_detection.search_group_limit, 8);
468 }
469
470 #[test]
471 fn partial_override_keeps_defaults() {
472 let cfg: Config = toml::from_str(
473 r#"
474 [loop_detection]
475 blocked_threshold = 10
476 "#,
477 )
478 .unwrap();
479 assert_eq!(cfg.loop_detection.blocked_threshold, 10);
480 assert_eq!(cfg.loop_detection.normal_threshold, 2);
481 assert_eq!(cfg.loop_detection.search_group_limit, 10);
482 }
483}
484
485impl Config {
486 pub fn path() -> Option<PathBuf> {
487 crate::core::data_dir::lean_ctx_data_dir()
488 .ok()
489 .map(|d| d.join("config.toml"))
490 }
491
492 pub fn local_path(project_root: &str) -> PathBuf {
493 PathBuf::from(project_root).join(".lean-ctx.toml")
494 }
495
496 fn find_project_root() -> Option<String> {
497 let cwd = std::env::current_dir().ok();
498
499 if let Some(root) =
500 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
501 {
502 let root_path = std::path::Path::new(&root);
503 let cwd_is_under_root = cwd
504 .as_ref()
505 .map(|c| c.starts_with(root_path))
506 .unwrap_or(false);
507 let has_marker = root_path.join(".git").exists()
508 || root_path.join("Cargo.toml").exists()
509 || root_path.join("package.json").exists()
510 || root_path.join("go.mod").exists()
511 || root_path.join("pyproject.toml").exists()
512 || root_path.join(".lean-ctx.toml").exists();
513
514 if cwd_is_under_root || has_marker {
515 return Some(root);
516 }
517 }
518
519 if let Some(ref cwd) = cwd {
520 let git_root = std::process::Command::new("git")
521 .args(["rev-parse", "--show-toplevel"])
522 .current_dir(cwd)
523 .stdout(std::process::Stdio::piped())
524 .stderr(std::process::Stdio::null())
525 .output()
526 .ok()
527 .and_then(|o| {
528 if o.status.success() {
529 String::from_utf8(o.stdout)
530 .ok()
531 .map(|s| s.trim().to_string())
532 } else {
533 None
534 }
535 });
536 if let Some(root) = git_root {
537 return Some(root);
538 }
539 return Some(cwd.to_string_lossy().to_string());
540 }
541 None
542 }
543
544 pub fn load() -> Self {
545 static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
546
547 let path = match Self::path() {
548 Some(p) => p,
549 None => return Self::default(),
550 };
551
552 let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
553
554 let mtime = std::fs::metadata(&path)
555 .and_then(|m| m.modified())
556 .unwrap_or(SystemTime::UNIX_EPOCH);
557
558 let local_mtime = local_path
559 .as_ref()
560 .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
561
562 if let Ok(guard) = CACHE.lock() {
563 if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
564 if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
565 return cfg.clone();
566 }
567 }
568 }
569
570 let mut cfg: Config = match std::fs::read_to_string(&path) {
571 Ok(content) => toml::from_str(&content).unwrap_or_default(),
572 Err(_) => Self::default(),
573 };
574
575 if let Some(ref lp) = local_path {
576 if let Ok(local_content) = std::fs::read_to_string(lp) {
577 cfg.merge_local(&local_content);
578 }
579 }
580
581 if let Ok(mut guard) = CACHE.lock() {
582 *guard = Some((cfg.clone(), mtime, local_mtime));
583 }
584
585 cfg
586 }
587
588 fn merge_local(&mut self, local_toml: &str) {
589 let local: Config = match toml::from_str(local_toml) {
590 Ok(c) => c,
591 Err(_) => return,
592 };
593 if local.ultra_compact {
594 self.ultra_compact = true;
595 }
596 if local.tee_mode != TeeMode::default() {
597 self.tee_mode = local.tee_mode;
598 }
599 if local.output_density != OutputDensity::default() {
600 self.output_density = local.output_density;
601 }
602 if local.checkpoint_interval != 15 {
603 self.checkpoint_interval = local.checkpoint_interval;
604 }
605 if !local.excluded_commands.is_empty() {
606 self.excluded_commands.extend(local.excluded_commands);
607 }
608 if !local.passthrough_urls.is_empty() {
609 self.passthrough_urls.extend(local.passthrough_urls);
610 }
611 if !local.custom_aliases.is_empty() {
612 self.custom_aliases.extend(local.custom_aliases);
613 }
614 if local.slow_command_threshold_ms != 5000 {
615 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
616 }
617 if local.theme != "default" {
618 self.theme = local.theme;
619 }
620 if !local.buddy_enabled {
621 self.buddy_enabled = false;
622 }
623 if !local.redirect_exclude.is_empty() {
624 self.redirect_exclude.extend(local.redirect_exclude);
625 }
626 if !local.disabled_tools.is_empty() {
627 self.disabled_tools.extend(local.disabled_tools);
628 }
629 if !local.extra_ignore_patterns.is_empty() {
630 self.extra_ignore_patterns
631 .extend(local.extra_ignore_patterns);
632 }
633 if local.rules_scope.is_some() {
634 self.rules_scope = local.rules_scope;
635 }
636 if !local.autonomy.enabled {
637 self.autonomy.enabled = false;
638 }
639 if !local.autonomy.auto_preload {
640 self.autonomy.auto_preload = false;
641 }
642 if !local.autonomy.auto_dedup {
643 self.autonomy.auto_dedup = false;
644 }
645 if !local.autonomy.auto_related {
646 self.autonomy.auto_related = false;
647 }
648 if !local.autonomy.auto_consolidate {
649 self.autonomy.auto_consolidate = false;
650 }
651 if local.autonomy.silent_preload {
652 self.autonomy.silent_preload = true;
653 }
654 if !local.autonomy.silent_preload && self.autonomy.silent_preload {
655 self.autonomy.silent_preload = false;
656 }
657 if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
658 self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
659 }
660 if local.autonomy.consolidate_every_calls
661 != AutonomyConfig::default().consolidate_every_calls
662 {
663 self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
664 }
665 if local.autonomy.consolidate_cooldown_secs
666 != AutonomyConfig::default().consolidate_cooldown_secs
667 {
668 self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
669 }
670 }
671
672 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
673 let path = Self::path().ok_or_else(|| {
674 super::error::LeanCtxError::Config("cannot determine home directory".into())
675 })?;
676 if let Some(parent) = path.parent() {
677 std::fs::create_dir_all(parent)?;
678 }
679 let content = toml::to_string_pretty(self)
680 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
681 std::fs::write(&path, content)?;
682 Ok(())
683 }
684
685 pub fn show(&self) -> String {
686 let global_path = Self::path()
687 .map(|p| p.to_string_lossy().to_string())
688 .unwrap_or_else(|| "~/.lean-ctx/config.toml".to_string());
689 let content = toml::to_string_pretty(self).unwrap_or_default();
690 let mut out = format!("Global config: {global_path}\n\n{content}");
691
692 if let Some(root) = Self::find_project_root() {
693 let local = Self::local_path(&root);
694 if local.exists() {
695 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
696 } else {
697 out.push_str(&format!(
698 "\n\nLocal config: not found (create {} to override per-project)\n",
699 local.display()
700 ));
701 }
702 }
703 out
704 }
705}