1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3use std::sync::Mutex;
4use std::time::SystemTime;
5
6use super::memory_policy::MemoryPolicy;
7
8mod memory;
9mod proxy;
10mod serde_defaults;
11mod shell_activation;
12
13pub use memory::{MemoryCleanup, MemoryProfile};
14pub use proxy::{is_local_proxy_url, normalize_url, normalize_url_opt, ProxyConfig, ProxyProvider};
15pub use shell_activation::ShellActivation;
16
17pub fn default_bm25_max_cache_mb() -> u64 {
19 serde_defaults::default_bm25_max_cache_mb()
20}
21
22#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
24#[serde(rename_all = "lowercase")]
25pub enum TeeMode {
26 Never,
27 #[default]
28 Failures,
29 Always,
30}
31
32#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
34#[serde(rename_all = "lowercase")]
35pub enum TerseAgent {
36 #[default]
37 Off,
38 Lite,
39 Full,
40 Ultra,
41}
42
43impl TerseAgent {
44 pub fn from_env() -> Self {
46 match std::env::var("LEAN_CTX_TERSE_AGENT")
47 .unwrap_or_default()
48 .to_lowercase()
49 .as_str()
50 {
51 "lite" => Self::Lite,
52 "full" => Self::Full,
53 "ultra" => Self::Ultra,
54 _ => Self::Off,
55 }
56 }
57
58 pub fn effective(config_val: &TerseAgent) -> Self {
60 match std::env::var("LEAN_CTX_TERSE_AGENT") {
61 Ok(val) if !val.is_empty() => match val.to_lowercase().as_str() {
62 "lite" => Self::Lite,
63 "full" => Self::Full,
64 "ultra" => Self::Ultra,
65 _ => Self::Off,
66 },
67 _ => config_val.clone(),
68 }
69 }
70
71 pub fn is_active(&self) -> bool {
73 !matches!(self, Self::Off)
74 }
75}
76
77#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
79#[serde(rename_all = "lowercase")]
80pub enum OutputDensity {
81 #[default]
82 Normal,
83 Terse,
84 Ultra,
85}
86
87impl OutputDensity {
88 pub fn from_env() -> Self {
90 match std::env::var("LEAN_CTX_OUTPUT_DENSITY")
91 .unwrap_or_default()
92 .to_lowercase()
93 .as_str()
94 {
95 "terse" => Self::Terse,
96 "ultra" => Self::Ultra,
97 _ => Self::Normal,
98 }
99 }
100
101 pub fn effective(config_val: &OutputDensity) -> Self {
103 let env_val = Self::from_env();
104 if env_val != Self::Normal {
105 return env_val;
106 }
107 let profile_val = crate::core::profiles::active_profile()
108 .compression
109 .output_density_effective()
110 .to_lowercase();
111 let profile_density = match profile_val.as_str() {
112 "terse" => Self::Terse,
113 "ultra" => Self::Ultra,
114 _ => Self::Normal,
115 };
116 if profile_density != Self::Normal {
117 return profile_density;
118 }
119 config_val.clone()
120 }
121}
122
123#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
128#[serde(rename_all = "lowercase")]
129pub enum CompressionLevel {
130 #[default]
131 Off,
132 Lite,
133 Standard,
134 Max,
135}
136
137impl CompressionLevel {
138 pub fn to_components(&self) -> (TerseAgent, OutputDensity, &'static str, bool) {
141 match self {
142 Self::Off => (TerseAgent::Off, OutputDensity::Normal, "off", false),
143 Self::Lite => (TerseAgent::Lite, OutputDensity::Terse, "off", true),
144 Self::Standard => (TerseAgent::Full, OutputDensity::Terse, "compact", true),
145 Self::Max => (TerseAgent::Ultra, OutputDensity::Ultra, "tdd", true),
146 }
147 }
148
149 pub fn from_legacy(terse_agent: &TerseAgent, output_density: &OutputDensity) -> Self {
152 match (terse_agent, output_density) {
153 (TerseAgent::Ultra, _) | (_, OutputDensity::Ultra) => Self::Max,
154 (TerseAgent::Full, _) => Self::Standard,
155 (TerseAgent::Lite, _) | (_, OutputDensity::Terse) => Self::Lite,
156 _ => Self::Off,
157 }
158 }
159
160 pub fn from_env() -> Option<Self> {
162 std::env::var("LEAN_CTX_COMPRESSION").ok().and_then(|v| {
163 match v.trim().to_lowercase().as_str() {
164 "off" => Some(Self::Off),
165 "lite" => Some(Self::Lite),
166 "standard" => Some(Self::Standard),
167 "max" => Some(Self::Max),
168 _ => None,
169 }
170 })
171 }
172
173 pub fn effective(config: &Config) -> Self {
179 if let Some(env_level) = Self::from_env() {
180 return env_level;
181 }
182 if config.compression_level != Self::Off {
183 return config.compression_level.clone();
184 }
185 if config.ultra_compact {
186 return Self::Max;
187 }
188 Self::from_legacy(&config.terse_agent, &config.output_density)
189 }
190
191 pub fn from_str_label(s: &str) -> Option<Self> {
192 match s.trim().to_lowercase().as_str() {
193 "off" => Some(Self::Off),
194 "lite" => Some(Self::Lite),
195 "standard" | "std" => Some(Self::Standard),
196 "max" => Some(Self::Max),
197 _ => None,
198 }
199 }
200
201 pub fn is_active(&self) -> bool {
202 !matches!(self, Self::Off)
203 }
204
205 pub fn label(&self) -> &'static str {
206 match self {
207 Self::Off => "off",
208 Self::Lite => "lite",
209 Self::Standard => "standard",
210 Self::Max => "max",
211 }
212 }
213
214 pub fn description(&self) -> &'static str {
215 match self {
216 Self::Off => "No compression — full verbose output",
217 Self::Lite => "Light compression — concise output, basic terse filtering",
218 Self::Standard => {
219 "Standard compression — dense output, compact protocol, pattern-aware"
220 }
221 Self::Max => "Maximum compression — expert mode, TDD protocol, all layers active",
222 }
223 }
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
228#[serde(default)]
229pub struct Config {
230 pub ultra_compact: bool,
231 #[serde(default, deserialize_with = "serde_defaults::deserialize_tee_mode")]
232 pub tee_mode: TeeMode,
233 #[serde(default)]
234 pub output_density: OutputDensity,
235 pub checkpoint_interval: u32,
236 pub excluded_commands: Vec<String>,
237 pub passthrough_urls: Vec<String>,
238 pub custom_aliases: Vec<AliasEntry>,
239 pub slow_command_threshold_ms: u64,
242 #[serde(default = "serde_defaults::default_theme")]
243 pub theme: String,
244 #[serde(default)]
245 pub cloud: CloudConfig,
246 #[serde(default)]
247 pub autonomy: AutonomyConfig,
248 #[serde(default)]
249 pub proxy: ProxyConfig,
250 #[serde(default = "serde_defaults::default_buddy_enabled")]
251 pub buddy_enabled: bool,
252 #[serde(default)]
253 pub redirect_exclude: Vec<String>,
254 #[serde(default)]
258 pub disabled_tools: Vec<String>,
259 #[serde(default)]
260 pub loop_detection: LoopDetectionConfig,
261 #[serde(default)]
265 pub rules_scope: Option<String>,
266 #[serde(default)]
269 pub extra_ignore_patterns: Vec<String>,
270 #[serde(default)]
274 pub terse_agent: TerseAgent,
275 #[serde(default)]
279 pub compression_level: CompressionLevel,
280 #[serde(default)]
282 pub archive: ArchiveConfig,
283 #[serde(default)]
285 pub memory: MemoryPolicy,
286 #[serde(default)]
290 pub allow_paths: Vec<String>,
291 #[serde(default)]
294 pub content_defined_chunking: bool,
295 #[serde(default)]
298 pub minimal_overhead: bool,
299 #[serde(default)]
302 pub shell_hook_disabled: bool,
303 #[serde(default)]
310 pub shell_activation: ShellActivation,
311 #[serde(default)]
314 pub update_check_disabled: bool,
315 #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
318 pub bm25_max_cache_mb: u64,
319 #[serde(default)]
322 pub memory_profile: MemoryProfile,
323 #[serde(default)]
327 pub memory_cleanup: MemoryCleanup,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize)]
332#[serde(default)]
333pub struct ArchiveConfig {
334 pub enabled: bool,
335 pub threshold_chars: usize,
336 pub max_age_hours: u64,
337 pub max_disk_mb: u64,
338}
339
340impl Default for ArchiveConfig {
341 fn default() -> Self {
342 Self {
343 enabled: true,
344 threshold_chars: 4096,
345 max_age_hours: 48,
346 max_disk_mb: 500,
347 }
348 }
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
353#[serde(default)]
354pub struct AutonomyConfig {
355 pub enabled: bool,
356 pub auto_preload: bool,
357 pub auto_dedup: bool,
358 pub auto_related: bool,
359 pub auto_consolidate: bool,
360 pub silent_preload: bool,
361 pub dedup_threshold: usize,
362 pub consolidate_every_calls: u32,
363 pub consolidate_cooldown_secs: u64,
364}
365
366impl Default for AutonomyConfig {
367 fn default() -> Self {
368 Self {
369 enabled: true,
370 auto_preload: true,
371 auto_dedup: true,
372 auto_related: true,
373 auto_consolidate: true,
374 silent_preload: true,
375 dedup_threshold: 8,
376 consolidate_every_calls: 25,
377 consolidate_cooldown_secs: 120,
378 }
379 }
380}
381
382impl AutonomyConfig {
383 pub fn from_env() -> Self {
385 let mut cfg = Self::default();
386 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
387 if v == "false" || v == "0" {
388 cfg.enabled = false;
389 }
390 }
391 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
392 cfg.auto_preload = v != "false" && v != "0";
393 }
394 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
395 cfg.auto_dedup = v != "false" && v != "0";
396 }
397 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
398 cfg.auto_related = v != "false" && v != "0";
399 }
400 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
401 cfg.auto_consolidate = v != "false" && v != "0";
402 }
403 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
404 cfg.silent_preload = v != "false" && v != "0";
405 }
406 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
407 if let Ok(n) = v.parse() {
408 cfg.dedup_threshold = n;
409 }
410 }
411 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
412 if let Ok(n) = v.parse() {
413 cfg.consolidate_every_calls = n;
414 }
415 }
416 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
417 if let Ok(n) = v.parse() {
418 cfg.consolidate_cooldown_secs = n;
419 }
420 }
421 cfg
422 }
423
424 pub fn load() -> Self {
426 let file_cfg = Config::load().autonomy;
427 let mut cfg = file_cfg;
428 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
429 if v == "false" || v == "0" {
430 cfg.enabled = false;
431 }
432 }
433 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
434 cfg.auto_preload = v != "false" && v != "0";
435 }
436 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
437 cfg.auto_dedup = v != "false" && v != "0";
438 }
439 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
440 cfg.auto_related = v != "false" && v != "0";
441 }
442 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
443 cfg.silent_preload = v != "false" && v != "0";
444 }
445 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
446 if let Ok(n) = v.parse() {
447 cfg.dedup_threshold = n;
448 }
449 }
450 cfg
451 }
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize, Default)]
456#[serde(default)]
457pub struct CloudConfig {
458 pub contribute_enabled: bool,
459 pub last_contribute: Option<String>,
460 pub last_sync: Option<String>,
461 pub last_gain_sync: Option<String>,
462 pub last_model_pull: Option<String>,
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize)]
467pub struct AliasEntry {
468 pub command: String,
469 pub alias: String,
470}
471
472#[derive(Debug, Clone, Serialize, Deserialize)]
474#[serde(default)]
475pub struct LoopDetectionConfig {
476 pub normal_threshold: u32,
477 pub reduced_threshold: u32,
478 pub blocked_threshold: u32,
479 pub window_secs: u64,
480 pub search_group_limit: u32,
481}
482
483impl Default for LoopDetectionConfig {
484 fn default() -> Self {
485 Self {
486 normal_threshold: 2,
487 reduced_threshold: 4,
488 blocked_threshold: 0,
491 window_secs: 300,
492 search_group_limit: 10,
493 }
494 }
495}
496
497impl Default for Config {
498 fn default() -> Self {
499 Self {
500 ultra_compact: false,
501 tee_mode: TeeMode::default(),
502 output_density: OutputDensity::default(),
503 checkpoint_interval: 15,
504 excluded_commands: Vec::new(),
505 passthrough_urls: Vec::new(),
506 custom_aliases: Vec::new(),
507 slow_command_threshold_ms: 5000,
508 theme: serde_defaults::default_theme(),
509 cloud: CloudConfig::default(),
510 autonomy: AutonomyConfig::default(),
511 proxy: ProxyConfig::default(),
512 buddy_enabled: serde_defaults::default_buddy_enabled(),
513 redirect_exclude: Vec::new(),
514 disabled_tools: Vec::new(),
515 loop_detection: LoopDetectionConfig::default(),
516 rules_scope: None,
517 extra_ignore_patterns: Vec::new(),
518 terse_agent: TerseAgent::default(),
519 compression_level: CompressionLevel::default(),
520 archive: ArchiveConfig::default(),
521 memory: MemoryPolicy::default(),
522 allow_paths: Vec::new(),
523 content_defined_chunking: false,
524 minimal_overhead: false,
525 shell_hook_disabled: false,
526 shell_activation: ShellActivation::default(),
527 update_check_disabled: false,
528 bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
529 memory_profile: MemoryProfile::default(),
530 memory_cleanup: MemoryCleanup::default(),
531 }
532 }
533}
534
535#[derive(Debug, Clone, Copy, PartialEq, Eq)]
537pub enum RulesScope {
538 Both,
539 Global,
540 Project,
541}
542
543impl Config {
544 pub fn rules_scope_effective(&self) -> RulesScope {
546 let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
547 .ok()
548 .or_else(|| self.rules_scope.clone())
549 .unwrap_or_default();
550 match raw.trim().to_lowercase().as_str() {
551 "global" => RulesScope::Global,
552 "project" => RulesScope::Project,
553 _ => RulesScope::Both,
554 }
555 }
556
557 fn parse_disabled_tools_env(val: &str) -> Vec<String> {
558 val.split(',')
559 .map(|s| s.trim().to_string())
560 .filter(|s| !s.is_empty())
561 .collect()
562 }
563
564 pub fn disabled_tools_effective(&self) -> Vec<String> {
566 if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
567 Self::parse_disabled_tools_env(&val)
568 } else {
569 self.disabled_tools.clone()
570 }
571 }
572
573 pub fn minimal_overhead_effective(&self) -> bool {
575 std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
576 }
577
578 pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
586 if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
587 match raw.trim().to_lowercase().as_str() {
588 "minimal" => return true,
589 "full" => return self.minimal_overhead_effective(),
590 _ => {}
591 }
592 }
593
594 if self.minimal_overhead_effective() {
595 return true;
596 }
597
598 let client_lower = client_name.trim().to_lowercase();
599 if !client_lower.is_empty() {
600 if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
601 for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
602 if !needle.is_empty() && client_lower.contains(&needle) {
603 return true;
604 }
605 }
606 } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
607 return true;
608 }
609 }
610
611 let model = std::env::var("LEAN_CTX_MODEL")
612 .or_else(|_| std::env::var("LCTX_MODEL"))
613 .unwrap_or_default();
614 let model = model.trim().to_lowercase();
615 if !model.is_empty() {
616 let m = model.replace(['_', ' '], "-");
617 if m.contains("minimax")
618 || m.contains("mini-max")
619 || m.contains("m2.7")
620 || m.contains("m2-7")
621 {
622 return true;
623 }
624 }
625
626 false
627 }
628
629 pub fn shell_hook_disabled_effective(&self) -> bool {
631 std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
632 }
633
634 pub fn shell_activation_effective(&self) -> ShellActivation {
636 ShellActivation::effective(self)
637 }
638
639 pub fn update_check_disabled_effective(&self) -> bool {
641 std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
642 }
643
644 pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
645 let mut policy = self.memory.clone();
646 policy.apply_env_overrides();
647 policy.validate()?;
648 Ok(policy)
649 }
650}
651
652#[cfg(test)]
653mod disabled_tools_tests {
654 use super::*;
655
656 #[test]
657 fn config_field_default_is_empty() {
658 let cfg = Config::default();
659 assert!(cfg.disabled_tools.is_empty());
660 }
661
662 #[test]
663 fn effective_returns_config_field_when_no_env_var() {
664 if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
666 return;
667 }
668 let cfg = Config {
669 disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
670 ..Default::default()
671 };
672 assert_eq!(
673 cfg.disabled_tools_effective(),
674 vec!["ctx_graph", "ctx_agent"]
675 );
676 }
677
678 #[test]
679 fn parse_env_basic() {
680 let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
681 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
682 }
683
684 #[test]
685 fn parse_env_trims_whitespace_and_skips_empty() {
686 let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
687 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
688 }
689
690 #[test]
691 fn parse_env_single_entry() {
692 let result = Config::parse_disabled_tools_env("ctx_graph");
693 assert_eq!(result, vec!["ctx_graph"]);
694 }
695
696 #[test]
697 fn parse_env_empty_string_returns_empty() {
698 let result = Config::parse_disabled_tools_env("");
699 assert!(result.is_empty());
700 }
701
702 #[test]
703 fn disabled_tools_deserialization_defaults_to_empty() {
704 let cfg: Config = toml::from_str("").unwrap();
705 assert!(cfg.disabled_tools.is_empty());
706 }
707
708 #[test]
709 fn disabled_tools_deserialization_from_toml() {
710 let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
711 assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
712 }
713}
714
715#[cfg(test)]
716mod rules_scope_tests {
717 use super::*;
718
719 #[test]
720 fn default_is_both() {
721 let cfg = Config::default();
722 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
723 }
724
725 #[test]
726 fn config_global() {
727 let cfg = Config {
728 rules_scope: Some("global".to_string()),
729 ..Default::default()
730 };
731 assert_eq!(cfg.rules_scope_effective(), RulesScope::Global);
732 }
733
734 #[test]
735 fn config_project() {
736 let cfg = Config {
737 rules_scope: Some("project".to_string()),
738 ..Default::default()
739 };
740 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
741 }
742
743 #[test]
744 fn unknown_value_falls_back_to_both() {
745 let cfg = Config {
746 rules_scope: Some("nonsense".to_string()),
747 ..Default::default()
748 };
749 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
750 }
751
752 #[test]
753 fn deserialization_none_by_default() {
754 let cfg: Config = toml::from_str("").unwrap();
755 assert!(cfg.rules_scope.is_none());
756 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
757 }
758
759 #[test]
760 fn deserialization_from_toml() {
761 let cfg: Config = toml::from_str(r#"rules_scope = "project""#).unwrap();
762 assert_eq!(cfg.rules_scope.as_deref(), Some("project"));
763 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
764 }
765}
766
767#[cfg(test)]
768mod loop_detection_config_tests {
769 use super::*;
770
771 #[test]
772 fn defaults_are_reasonable() {
773 let cfg = LoopDetectionConfig::default();
774 assert_eq!(cfg.normal_threshold, 2);
775 assert_eq!(cfg.reduced_threshold, 4);
776 assert_eq!(cfg.blocked_threshold, 0);
778 assert_eq!(cfg.window_secs, 300);
779 assert_eq!(cfg.search_group_limit, 10);
780 }
781
782 #[test]
783 fn deserialization_defaults_when_missing() {
784 let cfg: Config = toml::from_str("").unwrap();
785 assert_eq!(cfg.loop_detection.blocked_threshold, 0);
787 assert_eq!(cfg.loop_detection.search_group_limit, 10);
788 }
789
790 #[test]
791 fn deserialization_from_toml() {
792 let cfg: Config = toml::from_str(
793 r"
794 [loop_detection]
795 normal_threshold = 1
796 reduced_threshold = 3
797 blocked_threshold = 5
798 window_secs = 120
799 search_group_limit = 8
800 ",
801 )
802 .unwrap();
803 assert_eq!(cfg.loop_detection.normal_threshold, 1);
804 assert_eq!(cfg.loop_detection.reduced_threshold, 3);
805 assert_eq!(cfg.loop_detection.blocked_threshold, 5);
806 assert_eq!(cfg.loop_detection.window_secs, 120);
807 assert_eq!(cfg.loop_detection.search_group_limit, 8);
808 }
809
810 #[test]
811 fn partial_override_keeps_defaults() {
812 let cfg: Config = toml::from_str(
813 r"
814 [loop_detection]
815 blocked_threshold = 10
816 ",
817 )
818 .unwrap();
819 assert_eq!(cfg.loop_detection.blocked_threshold, 10);
820 assert_eq!(cfg.loop_detection.normal_threshold, 2);
821 assert_eq!(cfg.loop_detection.search_group_limit, 10);
822 }
823}
824
825impl Config {
826 pub fn path() -> Option<PathBuf> {
828 crate::core::data_dir::lean_ctx_data_dir()
829 .ok()
830 .map(|d| d.join("config.toml"))
831 }
832
833 pub fn local_path(project_root: &str) -> PathBuf {
835 PathBuf::from(project_root).join(".lean-ctx.toml")
836 }
837
838 fn find_project_root() -> Option<String> {
839 let cwd = std::env::current_dir().ok();
840
841 if let Some(root) =
842 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
843 {
844 let root_path = std::path::Path::new(&root);
845 let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
846 let has_marker = root_path.join(".git").exists()
847 || root_path.join("Cargo.toml").exists()
848 || root_path.join("package.json").exists()
849 || root_path.join("go.mod").exists()
850 || root_path.join("pyproject.toml").exists()
851 || root_path.join(".lean-ctx.toml").exists();
852
853 if cwd_is_under_root || has_marker {
854 return Some(root);
855 }
856 }
857
858 if let Some(ref cwd) = cwd {
859 let git_root = std::process::Command::new("git")
860 .args(["rev-parse", "--show-toplevel"])
861 .current_dir(cwd)
862 .stdout(std::process::Stdio::piped())
863 .stderr(std::process::Stdio::null())
864 .output()
865 .ok()
866 .and_then(|o| {
867 if o.status.success() {
868 String::from_utf8(o.stdout)
869 .ok()
870 .map(|s| s.trim().to_string())
871 } else {
872 None
873 }
874 });
875 if let Some(root) = git_root {
876 return Some(root);
877 }
878 return Some(cwd.to_string_lossy().to_string());
879 }
880 None
881 }
882
883 pub fn load() -> Self {
885 static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
886
887 let Some(path) = Self::path() else {
888 return Self::default();
889 };
890
891 let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
892
893 let mtime = std::fs::metadata(&path)
894 .and_then(|m| m.modified())
895 .unwrap_or(SystemTime::UNIX_EPOCH);
896
897 let local_mtime = local_path
898 .as_ref()
899 .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
900
901 if let Ok(guard) = CACHE.lock() {
902 if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
903 if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
904 return cfg.clone();
905 }
906 }
907 }
908
909 let mut cfg: Config = match std::fs::read_to_string(&path) {
910 Ok(content) => match toml::from_str(&content) {
911 Ok(c) => c,
912 Err(e) => {
913 tracing::warn!("config parse error in {}: {e}", path.display());
914 Self::default()
915 }
916 },
917 Err(_) => Self::default(),
918 };
919
920 if let Some(ref lp) = local_path {
921 if let Ok(local_content) = std::fs::read_to_string(lp) {
922 cfg.merge_local(&local_content);
923 }
924 }
925
926 if let Ok(mut guard) = CACHE.lock() {
927 *guard = Some((cfg.clone(), mtime, local_mtime));
928 }
929
930 cfg
931 }
932
933 fn merge_local(&mut self, local_toml: &str) {
934 let local: Config = match toml::from_str(local_toml) {
935 Ok(c) => c,
936 Err(e) => {
937 tracing::warn!("local config parse error: {e}");
938 return;
939 }
940 };
941 if local.ultra_compact {
942 self.ultra_compact = true;
943 }
944 if local.tee_mode != TeeMode::default() {
945 self.tee_mode = local.tee_mode;
946 }
947 if local.output_density != OutputDensity::default() {
948 self.output_density = local.output_density;
949 }
950 if local.checkpoint_interval != 15 {
951 self.checkpoint_interval = local.checkpoint_interval;
952 }
953 if !local.excluded_commands.is_empty() {
954 self.excluded_commands.extend(local.excluded_commands);
955 }
956 if !local.passthrough_urls.is_empty() {
957 self.passthrough_urls.extend(local.passthrough_urls);
958 }
959 if !local.custom_aliases.is_empty() {
960 self.custom_aliases.extend(local.custom_aliases);
961 }
962 if local.slow_command_threshold_ms != 5000 {
963 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
964 }
965 if local.theme != "default" {
966 self.theme = local.theme;
967 }
968 if !local.buddy_enabled {
969 self.buddy_enabled = false;
970 }
971 if !local.redirect_exclude.is_empty() {
972 self.redirect_exclude.extend(local.redirect_exclude);
973 }
974 if !local.disabled_tools.is_empty() {
975 self.disabled_tools.extend(local.disabled_tools);
976 }
977 if !local.extra_ignore_patterns.is_empty() {
978 self.extra_ignore_patterns
979 .extend(local.extra_ignore_patterns);
980 }
981 if local.rules_scope.is_some() {
982 self.rules_scope = local.rules_scope;
983 }
984 if local.proxy.anthropic_upstream.is_some() {
985 self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
986 }
987 if local.proxy.openai_upstream.is_some() {
988 self.proxy.openai_upstream = local.proxy.openai_upstream;
989 }
990 if local.proxy.gemini_upstream.is_some() {
991 self.proxy.gemini_upstream = local.proxy.gemini_upstream;
992 }
993 if !local.autonomy.enabled {
994 self.autonomy.enabled = false;
995 }
996 if !local.autonomy.auto_preload {
997 self.autonomy.auto_preload = false;
998 }
999 if !local.autonomy.auto_dedup {
1000 self.autonomy.auto_dedup = false;
1001 }
1002 if !local.autonomy.auto_related {
1003 self.autonomy.auto_related = false;
1004 }
1005 if !local.autonomy.auto_consolidate {
1006 self.autonomy.auto_consolidate = false;
1007 }
1008 if local.autonomy.silent_preload {
1009 self.autonomy.silent_preload = true;
1010 }
1011 if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1012 self.autonomy.silent_preload = false;
1013 }
1014 if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1015 self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1016 }
1017 if local.autonomy.consolidate_every_calls
1018 != AutonomyConfig::default().consolidate_every_calls
1019 {
1020 self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1021 }
1022 if local.autonomy.consolidate_cooldown_secs
1023 != AutonomyConfig::default().consolidate_cooldown_secs
1024 {
1025 self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1026 }
1027 if local.compression_level != CompressionLevel::default() {
1028 self.compression_level = local.compression_level;
1029 }
1030 if local.terse_agent != TerseAgent::default() {
1031 self.terse_agent = local.terse_agent;
1032 }
1033 if !local.archive.enabled {
1034 self.archive.enabled = false;
1035 }
1036 if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1037 self.archive.threshold_chars = local.archive.threshold_chars;
1038 }
1039 if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1040 self.archive.max_age_hours = local.archive.max_age_hours;
1041 }
1042 if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1043 self.archive.max_disk_mb = local.archive.max_disk_mb;
1044 }
1045 let mem_def = MemoryPolicy::default();
1046 if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1047 self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1048 }
1049 if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1050 self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1051 }
1052 if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1053 self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1054 }
1055 if local.memory.knowledge.contradiction_threshold
1056 != mem_def.knowledge.contradiction_threshold
1057 {
1058 self.memory.knowledge.contradiction_threshold =
1059 local.memory.knowledge.contradiction_threshold;
1060 }
1061
1062 if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1063 self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1064 }
1065 if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1066 {
1067 self.memory.episodic.max_actions_per_episode =
1068 local.memory.episodic.max_actions_per_episode;
1069 }
1070 if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1071 self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1072 }
1073
1074 if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1075 self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1076 }
1077 if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1078 self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1079 }
1080 if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1081 self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1082 }
1083 if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1084 self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1085 }
1086
1087 if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1088 self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1089 }
1090 if local.memory.lifecycle.low_confidence_threshold
1091 != mem_def.lifecycle.low_confidence_threshold
1092 {
1093 self.memory.lifecycle.low_confidence_threshold =
1094 local.memory.lifecycle.low_confidence_threshold;
1095 }
1096 if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1097 self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1098 }
1099 if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1100 self.memory.lifecycle.similarity_threshold =
1101 local.memory.lifecycle.similarity_threshold;
1102 }
1103
1104 if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1105 self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1106 }
1107 if !local.allow_paths.is_empty() {
1108 self.allow_paths.extend(local.allow_paths);
1109 }
1110 if local.minimal_overhead {
1111 self.minimal_overhead = true;
1112 }
1113 if local.shell_hook_disabled {
1114 self.shell_hook_disabled = true;
1115 }
1116 if local.shell_activation != ShellActivation::default() {
1117 self.shell_activation = local.shell_activation.clone();
1118 }
1119 if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1120 self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1121 }
1122 if local.memory_profile != MemoryProfile::default() {
1123 self.memory_profile = local.memory_profile;
1124 }
1125 if local.memory_cleanup != MemoryCleanup::default() {
1126 self.memory_cleanup = local.memory_cleanup;
1127 }
1128 }
1129
1130 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1132 let path = Self::path().ok_or_else(|| {
1133 super::error::LeanCtxError::Config("cannot determine home directory".into())
1134 })?;
1135 if let Some(parent) = path.parent() {
1136 std::fs::create_dir_all(parent)?;
1137 }
1138 let content = toml::to_string_pretty(self)
1139 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1140 std::fs::write(&path, content)?;
1141 Ok(())
1142 }
1143
1144 pub fn show(&self) -> String {
1146 let global_path = Self::path().map_or_else(
1147 || "~/.lean-ctx/config.toml".to_string(),
1148 |p| p.to_string_lossy().to_string(),
1149 );
1150 let content = toml::to_string_pretty(self).unwrap_or_default();
1151 let mut out = format!("Global config: {global_path}\n\n{content}");
1152
1153 if let Some(root) = Self::find_project_root() {
1154 let local = Self::local_path(&root);
1155 if local.exists() {
1156 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1157 } else {
1158 out.push_str(&format!(
1159 "\n\nLocal config: not found (create {} to override per-project)\n",
1160 local.display()
1161 ));
1162 }
1163 }
1164 out
1165 }
1166}
1167
1168#[cfg(test)]
1169mod compression_level_tests {
1170 use super::*;
1171
1172 #[test]
1173 fn default_is_off() {
1174 assert_eq!(CompressionLevel::default(), CompressionLevel::Off);
1175 }
1176
1177 #[test]
1178 fn to_components_off() {
1179 let (ta, od, crp, tm) = CompressionLevel::Off.to_components();
1180 assert_eq!(ta, TerseAgent::Off);
1181 assert_eq!(od, OutputDensity::Normal);
1182 assert_eq!(crp, "off");
1183 assert!(!tm);
1184 }
1185
1186 #[test]
1187 fn to_components_lite() {
1188 let (ta, od, crp, tm) = CompressionLevel::Lite.to_components();
1189 assert_eq!(ta, TerseAgent::Lite);
1190 assert_eq!(od, OutputDensity::Terse);
1191 assert_eq!(crp, "off");
1192 assert!(tm);
1193 }
1194
1195 #[test]
1196 fn to_components_standard() {
1197 let (ta, od, crp, tm) = CompressionLevel::Standard.to_components();
1198 assert_eq!(ta, TerseAgent::Full);
1199 assert_eq!(od, OutputDensity::Terse);
1200 assert_eq!(crp, "compact");
1201 assert!(tm);
1202 }
1203
1204 #[test]
1205 fn to_components_max() {
1206 let (ta, od, crp, tm) = CompressionLevel::Max.to_components();
1207 assert_eq!(ta, TerseAgent::Ultra);
1208 assert_eq!(od, OutputDensity::Ultra);
1209 assert_eq!(crp, "tdd");
1210 assert!(tm);
1211 }
1212
1213 #[test]
1214 fn from_legacy_ultra_agent_maps_to_max() {
1215 assert_eq!(
1216 CompressionLevel::from_legacy(&TerseAgent::Ultra, &OutputDensity::Normal),
1217 CompressionLevel::Max
1218 );
1219 }
1220
1221 #[test]
1222 fn from_legacy_ultra_density_maps_to_max() {
1223 assert_eq!(
1224 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Ultra),
1225 CompressionLevel::Max
1226 );
1227 }
1228
1229 #[test]
1230 fn from_legacy_full_agent_maps_to_standard() {
1231 assert_eq!(
1232 CompressionLevel::from_legacy(&TerseAgent::Full, &OutputDensity::Normal),
1233 CompressionLevel::Standard
1234 );
1235 }
1236
1237 #[test]
1238 fn from_legacy_lite_agent_maps_to_lite() {
1239 assert_eq!(
1240 CompressionLevel::from_legacy(&TerseAgent::Lite, &OutputDensity::Normal),
1241 CompressionLevel::Lite
1242 );
1243 }
1244
1245 #[test]
1246 fn from_legacy_terse_density_maps_to_lite() {
1247 assert_eq!(
1248 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Terse),
1249 CompressionLevel::Lite
1250 );
1251 }
1252
1253 #[test]
1254 fn from_legacy_both_off_maps_to_off() {
1255 assert_eq!(
1256 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Normal),
1257 CompressionLevel::Off
1258 );
1259 }
1260
1261 #[test]
1262 fn labels_match() {
1263 assert_eq!(CompressionLevel::Off.label(), "off");
1264 assert_eq!(CompressionLevel::Lite.label(), "lite");
1265 assert_eq!(CompressionLevel::Standard.label(), "standard");
1266 assert_eq!(CompressionLevel::Max.label(), "max");
1267 }
1268
1269 #[test]
1270 fn is_active_false_for_off() {
1271 assert!(!CompressionLevel::Off.is_active());
1272 }
1273
1274 #[test]
1275 fn is_active_true_for_all_others() {
1276 assert!(CompressionLevel::Lite.is_active());
1277 assert!(CompressionLevel::Standard.is_active());
1278 assert!(CompressionLevel::Max.is_active());
1279 }
1280
1281 #[test]
1282 fn deserialization_defaults_to_off() {
1283 let cfg: Config = toml::from_str("").unwrap();
1284 assert_eq!(cfg.compression_level, CompressionLevel::Off);
1285 }
1286
1287 #[test]
1288 fn deserialization_from_toml() {
1289 let cfg: Config = toml::from_str(r#"compression_level = "standard""#).unwrap();
1290 assert_eq!(cfg.compression_level, CompressionLevel::Standard);
1291 }
1292
1293 #[test]
1294 fn roundtrip_all_levels() {
1295 for level in [
1296 CompressionLevel::Off,
1297 CompressionLevel::Lite,
1298 CompressionLevel::Standard,
1299 CompressionLevel::Max,
1300 ] {
1301 let (ta, od, crp, tm) = level.to_components();
1302 assert!(!crp.is_empty());
1303 if level == CompressionLevel::Off {
1304 assert!(!tm);
1305 assert_eq!(ta, TerseAgent::Off);
1306 assert_eq!(od, OutputDensity::Normal);
1307 } else {
1308 assert!(tm);
1309 }
1310 }
1311 }
1312}
1313
1314#[cfg(test)]
1315mod memory_cleanup_tests {
1316 use super::*;
1317
1318 #[test]
1319 fn default_is_aggressive() {
1320 assert_eq!(MemoryCleanup::default(), MemoryCleanup::Aggressive);
1321 }
1322
1323 #[test]
1324 fn aggressive_ttl_is_300() {
1325 assert_eq!(MemoryCleanup::Aggressive.idle_ttl_secs(), 300);
1326 }
1327
1328 #[test]
1329 fn shared_ttl_is_1800() {
1330 assert_eq!(MemoryCleanup::Shared.idle_ttl_secs(), 1800);
1331 }
1332
1333 #[test]
1334 fn index_retention_multiplier_values() {
1335 assert!(
1336 (MemoryCleanup::Aggressive.index_retention_multiplier() - 1.0).abs() < f64::EPSILON
1337 );
1338 assert!((MemoryCleanup::Shared.index_retention_multiplier() - 3.0).abs() < f64::EPSILON);
1339 }
1340
1341 #[test]
1342 fn deserialization_defaults_to_aggressive() {
1343 let cfg: Config = toml::from_str("").unwrap();
1344 assert_eq!(cfg.memory_cleanup, MemoryCleanup::Aggressive);
1345 }
1346
1347 #[test]
1348 fn deserialization_from_toml() {
1349 let cfg: Config = toml::from_str(r#"memory_cleanup = "shared""#).unwrap();
1350 assert_eq!(cfg.memory_cleanup, MemoryCleanup::Shared);
1351 }
1352
1353 #[test]
1354 fn effective_uses_config_when_no_env() {
1355 let cfg = Config {
1356 memory_cleanup: MemoryCleanup::Shared,
1357 ..Default::default()
1358 };
1359 let eff = MemoryCleanup::effective(&cfg);
1360 assert_eq!(eff, MemoryCleanup::Shared);
1361 }
1362}