1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3use std::sync::Mutex;
4use std::time::SystemTime;
5
6use super::memory_policy::MemoryPolicy;
7
8#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
10#[serde(rename_all = "lowercase")]
11pub enum TeeMode {
12 Never,
13 #[default]
14 Failures,
15 Always,
16}
17
18#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
20#[serde(rename_all = "lowercase")]
21pub enum TerseAgent {
22 #[default]
23 Off,
24 Lite,
25 Full,
26 Ultra,
27}
28
29impl TerseAgent {
30 pub fn from_env() -> Self {
32 match std::env::var("LEAN_CTX_TERSE_AGENT")
33 .unwrap_or_default()
34 .to_lowercase()
35 .as_str()
36 {
37 "lite" => Self::Lite,
38 "full" => Self::Full,
39 "ultra" => Self::Ultra,
40 _ => Self::Off,
41 }
42 }
43
44 pub fn effective(config_val: &TerseAgent) -> Self {
46 match std::env::var("LEAN_CTX_TERSE_AGENT") {
47 Ok(val) if !val.is_empty() => match val.to_lowercase().as_str() {
48 "lite" => Self::Lite,
49 "full" => Self::Full,
50 "ultra" => Self::Ultra,
51 _ => Self::Off,
52 },
53 _ => config_val.clone(),
54 }
55 }
56
57 pub fn is_active(&self) -> bool {
59 !matches!(self, Self::Off)
60 }
61}
62
63#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
65#[serde(rename_all = "lowercase")]
66pub enum OutputDensity {
67 #[default]
68 Normal,
69 Terse,
70 Ultra,
71}
72
73impl OutputDensity {
74 pub fn from_env() -> Self {
76 match std::env::var("LEAN_CTX_OUTPUT_DENSITY")
77 .unwrap_or_default()
78 .to_lowercase()
79 .as_str()
80 {
81 "terse" => Self::Terse,
82 "ultra" => Self::Ultra,
83 _ => Self::Normal,
84 }
85 }
86
87 pub fn effective(config_val: &OutputDensity) -> Self {
89 let env_val = Self::from_env();
90 if env_val != Self::Normal {
91 return env_val;
92 }
93 let profile_val = crate::core::profiles::active_profile()
94 .compression
95 .output_density_effective()
96 .to_lowercase();
97 let profile_density = match profile_val.as_str() {
98 "terse" => Self::Terse,
99 "ultra" => Self::Ultra,
100 _ => Self::Normal,
101 };
102 if profile_density != Self::Normal {
103 return profile_density;
104 }
105 config_val.clone()
106 }
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(default)]
112pub struct Config {
113 pub ultra_compact: bool,
114 #[serde(default, deserialize_with = "deserialize_tee_mode")]
115 pub tee_mode: TeeMode,
116 #[serde(default)]
117 pub output_density: OutputDensity,
118 pub checkpoint_interval: u32,
119 pub excluded_commands: Vec<String>,
120 pub passthrough_urls: Vec<String>,
121 pub custom_aliases: Vec<AliasEntry>,
122 pub slow_command_threshold_ms: u64,
125 #[serde(default = "default_theme")]
126 pub theme: String,
127 #[serde(default)]
128 pub cloud: CloudConfig,
129 #[serde(default)]
130 pub autonomy: AutonomyConfig,
131 #[serde(default)]
132 pub proxy: ProxyConfig,
133 #[serde(default = "default_buddy_enabled")]
134 pub buddy_enabled: bool,
135 #[serde(default)]
136 pub redirect_exclude: Vec<String>,
137 #[serde(default)]
141 pub disabled_tools: Vec<String>,
142 #[serde(default)]
143 pub loop_detection: LoopDetectionConfig,
144 #[serde(default)]
148 pub rules_scope: Option<String>,
149 #[serde(default)]
152 pub extra_ignore_patterns: Vec<String>,
153 #[serde(default)]
157 pub terse_agent: TerseAgent,
158 #[serde(default)]
160 pub archive: ArchiveConfig,
161 #[serde(default)]
163 pub memory: MemoryPolicy,
164 #[serde(default)]
168 pub allow_paths: Vec<String>,
169 #[serde(default)]
172 pub content_defined_chunking: bool,
173 #[serde(default)]
176 pub minimal_overhead: bool,
177 #[serde(default)]
180 pub shell_hook_disabled: bool,
181 #[serde(default)]
184 pub update_check_disabled: bool,
185 #[serde(default = "default_bm25_max_cache_mb")]
188 pub bm25_max_cache_mb: u64,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
193#[serde(default)]
194pub struct ArchiveConfig {
195 pub enabled: bool,
196 pub threshold_chars: usize,
197 pub max_age_hours: u64,
198 pub max_disk_mb: u64,
199}
200
201impl Default for ArchiveConfig {
202 fn default() -> Self {
203 Self {
204 enabled: true,
205 threshold_chars: 4096,
206 max_age_hours: 48,
207 max_disk_mb: 500,
208 }
209 }
210}
211
212#[derive(Debug, Clone, Default, Serialize, Deserialize)]
214#[serde(default)]
215pub struct ProxyConfig {
216 pub anthropic_upstream: Option<String>,
217 pub openai_upstream: Option<String>,
218 pub gemini_upstream: Option<String>,
219}
220
221impl ProxyConfig {
222 pub fn resolve_upstream(&self, provider: ProxyProvider) -> String {
223 let (env_var, config_val, default) = match provider {
224 ProxyProvider::Anthropic => (
225 "LEAN_CTX_ANTHROPIC_UPSTREAM",
226 self.anthropic_upstream.as_deref(),
227 "https://api.anthropic.com",
228 ),
229 ProxyProvider::OpenAi => (
230 "LEAN_CTX_OPENAI_UPSTREAM",
231 self.openai_upstream.as_deref(),
232 "https://api.openai.com",
233 ),
234 ProxyProvider::Gemini => (
235 "LEAN_CTX_GEMINI_UPSTREAM",
236 self.gemini_upstream.as_deref(),
237 "https://generativelanguage.googleapis.com",
238 ),
239 };
240 std::env::var(env_var)
241 .ok()
242 .and_then(|v| normalize_url_opt(&v))
243 .or_else(|| config_val.and_then(normalize_url_opt))
244 .unwrap_or_else(|| normalize_url(default))
245 }
246}
247
248#[derive(Debug, Clone, Copy)]
249pub enum ProxyProvider {
250 Anthropic,
251 OpenAi,
252 Gemini,
253}
254
255pub fn normalize_url(value: &str) -> String {
256 value.trim().trim_end_matches('/').to_string()
257}
258
259pub fn normalize_url_opt(value: &str) -> Option<String> {
260 let trimmed = normalize_url(value);
261 if trimmed.is_empty() {
262 None
263 } else {
264 Some(trimmed)
265 }
266}
267
268pub fn is_local_proxy_url(value: &str) -> bool {
269 let n = normalize_url(value);
270 n.starts_with("http://127.0.0.1:")
271 || n.starts_with("http://localhost:")
272 || n.starts_with("http://[::1]:")
273}
274
275fn default_buddy_enabled() -> bool {
276 true
277}
278
279fn default_bm25_max_cache_mb() -> u64 {
280 512
281}
282
283fn deserialize_tee_mode<'de, D>(deserializer: D) -> Result<TeeMode, D::Error>
284where
285 D: serde::Deserializer<'de>,
286{
287 use serde::de::Error;
288 let v = serde_json::Value::deserialize(deserializer)?;
289 match &v {
290 serde_json::Value::Bool(true) => Ok(TeeMode::Failures),
291 serde_json::Value::Bool(false) => Ok(TeeMode::Never),
292 serde_json::Value::String(s) => match s.as_str() {
293 "never" => Ok(TeeMode::Never),
294 "failures" => Ok(TeeMode::Failures),
295 "always" => Ok(TeeMode::Always),
296 other => Err(D::Error::custom(format!("unknown tee_mode: {other}"))),
297 },
298 _ => Err(D::Error::custom("tee_mode must be string or bool")),
299 }
300}
301
302fn default_theme() -> String {
303 "default".to_string()
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
308#[serde(default)]
309pub struct AutonomyConfig {
310 pub enabled: bool,
311 pub auto_preload: bool,
312 pub auto_dedup: bool,
313 pub auto_related: bool,
314 pub auto_consolidate: bool,
315 pub silent_preload: bool,
316 pub dedup_threshold: usize,
317 pub consolidate_every_calls: u32,
318 pub consolidate_cooldown_secs: u64,
319}
320
321impl Default for AutonomyConfig {
322 fn default() -> Self {
323 Self {
324 enabled: true,
325 auto_preload: true,
326 auto_dedup: true,
327 auto_related: true,
328 auto_consolidate: true,
329 silent_preload: true,
330 dedup_threshold: 8,
331 consolidate_every_calls: 25,
332 consolidate_cooldown_secs: 120,
333 }
334 }
335}
336
337impl AutonomyConfig {
338 pub fn from_env() -> Self {
340 let mut cfg = Self::default();
341 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
342 if v == "false" || v == "0" {
343 cfg.enabled = false;
344 }
345 }
346 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
347 cfg.auto_preload = v != "false" && v != "0";
348 }
349 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
350 cfg.auto_dedup = v != "false" && v != "0";
351 }
352 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
353 cfg.auto_related = v != "false" && v != "0";
354 }
355 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
356 cfg.auto_consolidate = v != "false" && v != "0";
357 }
358 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
359 cfg.silent_preload = v != "false" && v != "0";
360 }
361 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
362 if let Ok(n) = v.parse() {
363 cfg.dedup_threshold = n;
364 }
365 }
366 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
367 if let Ok(n) = v.parse() {
368 cfg.consolidate_every_calls = n;
369 }
370 }
371 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
372 if let Ok(n) = v.parse() {
373 cfg.consolidate_cooldown_secs = n;
374 }
375 }
376 cfg
377 }
378
379 pub fn load() -> Self {
381 let file_cfg = Config::load().autonomy;
382 let mut cfg = file_cfg;
383 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
384 if v == "false" || v == "0" {
385 cfg.enabled = false;
386 }
387 }
388 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
389 cfg.auto_preload = v != "false" && v != "0";
390 }
391 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
392 cfg.auto_dedup = v != "false" && v != "0";
393 }
394 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
395 cfg.auto_related = v != "false" && v != "0";
396 }
397 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
398 cfg.silent_preload = v != "false" && v != "0";
399 }
400 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
401 if let Ok(n) = v.parse() {
402 cfg.dedup_threshold = n;
403 }
404 }
405 cfg
406 }
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize, Default)]
411#[serde(default)]
412pub struct CloudConfig {
413 pub contribute_enabled: bool,
414 pub last_contribute: Option<String>,
415 pub last_sync: Option<String>,
416 pub last_gain_sync: Option<String>,
417 pub last_model_pull: Option<String>,
418}
419
420#[derive(Debug, Clone, Serialize, Deserialize)]
422pub struct AliasEntry {
423 pub command: String,
424 pub alias: String,
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize)]
429#[serde(default)]
430pub struct LoopDetectionConfig {
431 pub normal_threshold: u32,
432 pub reduced_threshold: u32,
433 pub blocked_threshold: u32,
434 pub window_secs: u64,
435 pub search_group_limit: u32,
436}
437
438impl Default for LoopDetectionConfig {
439 fn default() -> Self {
440 Self {
441 normal_threshold: 2,
442 reduced_threshold: 4,
443 blocked_threshold: 0,
446 window_secs: 300,
447 search_group_limit: 10,
448 }
449 }
450}
451
452impl Default for Config {
453 fn default() -> Self {
454 Self {
455 ultra_compact: false,
456 tee_mode: TeeMode::default(),
457 output_density: OutputDensity::default(),
458 checkpoint_interval: 15,
459 excluded_commands: Vec::new(),
460 passthrough_urls: Vec::new(),
461 custom_aliases: Vec::new(),
462 slow_command_threshold_ms: 5000,
463 theme: default_theme(),
464 cloud: CloudConfig::default(),
465 autonomy: AutonomyConfig::default(),
466 proxy: ProxyConfig::default(),
467 buddy_enabled: default_buddy_enabled(),
468 redirect_exclude: Vec::new(),
469 disabled_tools: Vec::new(),
470 loop_detection: LoopDetectionConfig::default(),
471 rules_scope: None,
472 extra_ignore_patterns: Vec::new(),
473 terse_agent: TerseAgent::default(),
474 archive: ArchiveConfig::default(),
475 memory: MemoryPolicy::default(),
476 allow_paths: Vec::new(),
477 content_defined_chunking: false,
478 minimal_overhead: false,
479 shell_hook_disabled: false,
480 update_check_disabled: false,
481 bm25_max_cache_mb: default_bm25_max_cache_mb(),
482 }
483 }
484}
485
486#[derive(Debug, Clone, Copy, PartialEq, Eq)]
488pub enum RulesScope {
489 Both,
490 Global,
491 Project,
492}
493
494impl Config {
495 pub fn rules_scope_effective(&self) -> RulesScope {
497 let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
498 .ok()
499 .or_else(|| self.rules_scope.clone())
500 .unwrap_or_default();
501 match raw.trim().to_lowercase().as_str() {
502 "global" => RulesScope::Global,
503 "project" => RulesScope::Project,
504 _ => RulesScope::Both,
505 }
506 }
507
508 fn parse_disabled_tools_env(val: &str) -> Vec<String> {
509 val.split(',')
510 .map(|s| s.trim().to_string())
511 .filter(|s| !s.is_empty())
512 .collect()
513 }
514
515 pub fn disabled_tools_effective(&self) -> Vec<String> {
517 if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
518 Self::parse_disabled_tools_env(&val)
519 } else {
520 self.disabled_tools.clone()
521 }
522 }
523
524 pub fn minimal_overhead_effective(&self) -> bool {
526 std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
527 }
528
529 pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
537 if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
538 match raw.trim().to_lowercase().as_str() {
539 "minimal" => return true,
540 "full" => return self.minimal_overhead_effective(),
541 _ => {}
542 }
543 }
544
545 if self.minimal_overhead_effective() {
546 return true;
547 }
548
549 let client_lower = client_name.trim().to_lowercase();
550 if !client_lower.is_empty() {
551 if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
552 for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
553 if !needle.is_empty() && client_lower.contains(&needle) {
554 return true;
555 }
556 }
557 } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
558 return true;
559 }
560 }
561
562 let model = std::env::var("LEAN_CTX_MODEL")
563 .or_else(|_| std::env::var("LCTX_MODEL"))
564 .unwrap_or_default();
565 let model = model.trim().to_lowercase();
566 if !model.is_empty() {
567 let m = model.replace(['_', ' '], "-");
568 if m.contains("minimax")
569 || m.contains("mini-max")
570 || m.contains("m2.7")
571 || m.contains("m2-7")
572 {
573 return true;
574 }
575 }
576
577 false
578 }
579
580 pub fn shell_hook_disabled_effective(&self) -> bool {
582 std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
583 }
584
585 pub fn update_check_disabled_effective(&self) -> bool {
587 std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
588 }
589
590 pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
591 let mut policy = self.memory.clone();
592 policy.apply_env_overrides();
593 policy.validate()?;
594 Ok(policy)
595 }
596}
597
598#[cfg(test)]
599mod disabled_tools_tests {
600 use super::*;
601
602 #[test]
603 fn config_field_default_is_empty() {
604 let cfg = Config::default();
605 assert!(cfg.disabled_tools.is_empty());
606 }
607
608 #[test]
609 fn effective_returns_config_field_when_no_env_var() {
610 if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
612 return;
613 }
614 let cfg = Config {
615 disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
616 ..Default::default()
617 };
618 assert_eq!(
619 cfg.disabled_tools_effective(),
620 vec!["ctx_graph", "ctx_agent"]
621 );
622 }
623
624 #[test]
625 fn parse_env_basic() {
626 let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
627 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
628 }
629
630 #[test]
631 fn parse_env_trims_whitespace_and_skips_empty() {
632 let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
633 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
634 }
635
636 #[test]
637 fn parse_env_single_entry() {
638 let result = Config::parse_disabled_tools_env("ctx_graph");
639 assert_eq!(result, vec!["ctx_graph"]);
640 }
641
642 #[test]
643 fn parse_env_empty_string_returns_empty() {
644 let result = Config::parse_disabled_tools_env("");
645 assert!(result.is_empty());
646 }
647
648 #[test]
649 fn disabled_tools_deserialization_defaults_to_empty() {
650 let cfg: Config = toml::from_str("").unwrap();
651 assert!(cfg.disabled_tools.is_empty());
652 }
653
654 #[test]
655 fn disabled_tools_deserialization_from_toml() {
656 let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
657 assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
658 }
659}
660
661#[cfg(test)]
662mod rules_scope_tests {
663 use super::*;
664
665 #[test]
666 fn default_is_both() {
667 let cfg = Config::default();
668 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
669 }
670
671 #[test]
672 fn config_global() {
673 let cfg = Config {
674 rules_scope: Some("global".to_string()),
675 ..Default::default()
676 };
677 assert_eq!(cfg.rules_scope_effective(), RulesScope::Global);
678 }
679
680 #[test]
681 fn config_project() {
682 let cfg = Config {
683 rules_scope: Some("project".to_string()),
684 ..Default::default()
685 };
686 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
687 }
688
689 #[test]
690 fn unknown_value_falls_back_to_both() {
691 let cfg = Config {
692 rules_scope: Some("nonsense".to_string()),
693 ..Default::default()
694 };
695 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
696 }
697
698 #[test]
699 fn deserialization_none_by_default() {
700 let cfg: Config = toml::from_str("").unwrap();
701 assert!(cfg.rules_scope.is_none());
702 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
703 }
704
705 #[test]
706 fn deserialization_from_toml() {
707 let cfg: Config = toml::from_str(r#"rules_scope = "project""#).unwrap();
708 assert_eq!(cfg.rules_scope.as_deref(), Some("project"));
709 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
710 }
711}
712
713#[cfg(test)]
714mod loop_detection_config_tests {
715 use super::*;
716
717 #[test]
718 fn defaults_are_reasonable() {
719 let cfg = LoopDetectionConfig::default();
720 assert_eq!(cfg.normal_threshold, 2);
721 assert_eq!(cfg.reduced_threshold, 4);
722 assert_eq!(cfg.blocked_threshold, 0);
724 assert_eq!(cfg.window_secs, 300);
725 assert_eq!(cfg.search_group_limit, 10);
726 }
727
728 #[test]
729 fn deserialization_defaults_when_missing() {
730 let cfg: Config = toml::from_str("").unwrap();
731 assert_eq!(cfg.loop_detection.blocked_threshold, 0);
733 assert_eq!(cfg.loop_detection.search_group_limit, 10);
734 }
735
736 #[test]
737 fn deserialization_from_toml() {
738 let cfg: Config = toml::from_str(
739 r"
740 [loop_detection]
741 normal_threshold = 1
742 reduced_threshold = 3
743 blocked_threshold = 5
744 window_secs = 120
745 search_group_limit = 8
746 ",
747 )
748 .unwrap();
749 assert_eq!(cfg.loop_detection.normal_threshold, 1);
750 assert_eq!(cfg.loop_detection.reduced_threshold, 3);
751 assert_eq!(cfg.loop_detection.blocked_threshold, 5);
752 assert_eq!(cfg.loop_detection.window_secs, 120);
753 assert_eq!(cfg.loop_detection.search_group_limit, 8);
754 }
755
756 #[test]
757 fn partial_override_keeps_defaults() {
758 let cfg: Config = toml::from_str(
759 r"
760 [loop_detection]
761 blocked_threshold = 10
762 ",
763 )
764 .unwrap();
765 assert_eq!(cfg.loop_detection.blocked_threshold, 10);
766 assert_eq!(cfg.loop_detection.normal_threshold, 2);
767 assert_eq!(cfg.loop_detection.search_group_limit, 10);
768 }
769}
770
771impl Config {
772 pub fn path() -> Option<PathBuf> {
774 crate::core::data_dir::lean_ctx_data_dir()
775 .ok()
776 .map(|d| d.join("config.toml"))
777 }
778
779 pub fn local_path(project_root: &str) -> PathBuf {
781 PathBuf::from(project_root).join(".lean-ctx.toml")
782 }
783
784 fn find_project_root() -> Option<String> {
785 let cwd = std::env::current_dir().ok();
786
787 if let Some(root) =
788 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
789 {
790 let root_path = std::path::Path::new(&root);
791 let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
792 let has_marker = root_path.join(".git").exists()
793 || root_path.join("Cargo.toml").exists()
794 || root_path.join("package.json").exists()
795 || root_path.join("go.mod").exists()
796 || root_path.join("pyproject.toml").exists()
797 || root_path.join(".lean-ctx.toml").exists();
798
799 if cwd_is_under_root || has_marker {
800 return Some(root);
801 }
802 }
803
804 if let Some(ref cwd) = cwd {
805 let git_root = std::process::Command::new("git")
806 .args(["rev-parse", "--show-toplevel"])
807 .current_dir(cwd)
808 .stdout(std::process::Stdio::piped())
809 .stderr(std::process::Stdio::null())
810 .output()
811 .ok()
812 .and_then(|o| {
813 if o.status.success() {
814 String::from_utf8(o.stdout)
815 .ok()
816 .map(|s| s.trim().to_string())
817 } else {
818 None
819 }
820 });
821 if let Some(root) = git_root {
822 return Some(root);
823 }
824 return Some(cwd.to_string_lossy().to_string());
825 }
826 None
827 }
828
829 pub fn load() -> Self {
831 static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
832
833 let Some(path) = Self::path() else {
834 return Self::default();
835 };
836
837 let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
838
839 let mtime = std::fs::metadata(&path)
840 .and_then(|m| m.modified())
841 .unwrap_or(SystemTime::UNIX_EPOCH);
842
843 let local_mtime = local_path
844 .as_ref()
845 .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
846
847 if let Ok(guard) = CACHE.lock() {
848 if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
849 if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
850 return cfg.clone();
851 }
852 }
853 }
854
855 let mut cfg: Config = match std::fs::read_to_string(&path) {
856 Ok(content) => toml::from_str(&content).unwrap_or_default(),
857 Err(_) => Self::default(),
858 };
859
860 if let Some(ref lp) = local_path {
861 if let Ok(local_content) = std::fs::read_to_string(lp) {
862 cfg.merge_local(&local_content);
863 }
864 }
865
866 if let Ok(mut guard) = CACHE.lock() {
867 *guard = Some((cfg.clone(), mtime, local_mtime));
868 }
869
870 cfg
871 }
872
873 fn merge_local(&mut self, local_toml: &str) {
874 let local: Config = match toml::from_str(local_toml) {
875 Ok(c) => c,
876 Err(_) => return,
877 };
878 if local.ultra_compact {
879 self.ultra_compact = true;
880 }
881 if local.tee_mode != TeeMode::default() {
882 self.tee_mode = local.tee_mode;
883 }
884 if local.output_density != OutputDensity::default() {
885 self.output_density = local.output_density;
886 }
887 if local.checkpoint_interval != 15 {
888 self.checkpoint_interval = local.checkpoint_interval;
889 }
890 if !local.excluded_commands.is_empty() {
891 self.excluded_commands.extend(local.excluded_commands);
892 }
893 if !local.passthrough_urls.is_empty() {
894 self.passthrough_urls.extend(local.passthrough_urls);
895 }
896 if !local.custom_aliases.is_empty() {
897 self.custom_aliases.extend(local.custom_aliases);
898 }
899 if local.slow_command_threshold_ms != 5000 {
900 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
901 }
902 if local.theme != "default" {
903 self.theme = local.theme;
904 }
905 if !local.buddy_enabled {
906 self.buddy_enabled = false;
907 }
908 if !local.redirect_exclude.is_empty() {
909 self.redirect_exclude.extend(local.redirect_exclude);
910 }
911 if !local.disabled_tools.is_empty() {
912 self.disabled_tools.extend(local.disabled_tools);
913 }
914 if !local.extra_ignore_patterns.is_empty() {
915 self.extra_ignore_patterns
916 .extend(local.extra_ignore_patterns);
917 }
918 if local.rules_scope.is_some() {
919 self.rules_scope = local.rules_scope;
920 }
921 if local.proxy.anthropic_upstream.is_some() {
922 self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
923 }
924 if local.proxy.openai_upstream.is_some() {
925 self.proxy.openai_upstream = local.proxy.openai_upstream;
926 }
927 if local.proxy.gemini_upstream.is_some() {
928 self.proxy.gemini_upstream = local.proxy.gemini_upstream;
929 }
930 if !local.autonomy.enabled {
931 self.autonomy.enabled = false;
932 }
933 if !local.autonomy.auto_preload {
934 self.autonomy.auto_preload = false;
935 }
936 if !local.autonomy.auto_dedup {
937 self.autonomy.auto_dedup = false;
938 }
939 if !local.autonomy.auto_related {
940 self.autonomy.auto_related = false;
941 }
942 if !local.autonomy.auto_consolidate {
943 self.autonomy.auto_consolidate = false;
944 }
945 if local.autonomy.silent_preload {
946 self.autonomy.silent_preload = true;
947 }
948 if !local.autonomy.silent_preload && self.autonomy.silent_preload {
949 self.autonomy.silent_preload = false;
950 }
951 if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
952 self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
953 }
954 if local.autonomy.consolidate_every_calls
955 != AutonomyConfig::default().consolidate_every_calls
956 {
957 self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
958 }
959 if local.autonomy.consolidate_cooldown_secs
960 != AutonomyConfig::default().consolidate_cooldown_secs
961 {
962 self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
963 }
964 if local.terse_agent != TerseAgent::default() {
965 self.terse_agent = local.terse_agent;
966 }
967 if !local.archive.enabled {
968 self.archive.enabled = false;
969 }
970 if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
971 self.archive.threshold_chars = local.archive.threshold_chars;
972 }
973 if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
974 self.archive.max_age_hours = local.archive.max_age_hours;
975 }
976 if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
977 self.archive.max_disk_mb = local.archive.max_disk_mb;
978 }
979 let mem_def = MemoryPolicy::default();
980 if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
981 self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
982 }
983 if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
984 self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
985 }
986 if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
987 self.memory.knowledge.max_history = local.memory.knowledge.max_history;
988 }
989 if local.memory.knowledge.contradiction_threshold
990 != mem_def.knowledge.contradiction_threshold
991 {
992 self.memory.knowledge.contradiction_threshold =
993 local.memory.knowledge.contradiction_threshold;
994 }
995
996 if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
997 self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
998 }
999 if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1000 {
1001 self.memory.episodic.max_actions_per_episode =
1002 local.memory.episodic.max_actions_per_episode;
1003 }
1004 if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1005 self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1006 }
1007
1008 if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1009 self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1010 }
1011 if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1012 self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1013 }
1014 if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1015 self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1016 }
1017 if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1018 self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1019 }
1020
1021 if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1022 self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1023 }
1024 if local.memory.lifecycle.low_confidence_threshold
1025 != mem_def.lifecycle.low_confidence_threshold
1026 {
1027 self.memory.lifecycle.low_confidence_threshold =
1028 local.memory.lifecycle.low_confidence_threshold;
1029 }
1030 if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1031 self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1032 }
1033 if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1034 self.memory.lifecycle.similarity_threshold =
1035 local.memory.lifecycle.similarity_threshold;
1036 }
1037
1038 if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1039 self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1040 }
1041 if !local.allow_paths.is_empty() {
1042 self.allow_paths.extend(local.allow_paths);
1043 }
1044 if local.minimal_overhead {
1045 self.minimal_overhead = true;
1046 }
1047 if local.shell_hook_disabled {
1048 self.shell_hook_disabled = true;
1049 }
1050 if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1051 self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1052 }
1053 }
1054
1055 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1057 let path = Self::path().ok_or_else(|| {
1058 super::error::LeanCtxError::Config("cannot determine home directory".into())
1059 })?;
1060 if let Some(parent) = path.parent() {
1061 std::fs::create_dir_all(parent)?;
1062 }
1063 let content = toml::to_string_pretty(self)
1064 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1065 std::fs::write(&path, content)?;
1066 Ok(())
1067 }
1068
1069 pub fn show(&self) -> String {
1071 let global_path = Self::path().map_or_else(
1072 || "~/.lean-ctx/config.toml".to_string(),
1073 |p| p.to_string_lossy().to_string(),
1074 );
1075 let content = toml::to_string_pretty(self).unwrap_or_default();
1076 let mut out = format!("Global config: {global_path}\n\n{content}");
1077
1078 if let Some(root) = Self::find_project_root() {
1079 let local = Self::local_path(&root);
1080 if local.exists() {
1081 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1082 } else {
1083 out.push_str(&format!(
1084 "\n\nLocal config: not found (create {} to override per-project)\n",
1085 local.display()
1086 ));
1087 }
1088 }
1089 out
1090 }
1091}