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) => toml::from_str(&content).unwrap_or_default(),
911 Err(_) => Self::default(),
912 };
913
914 if let Some(ref lp) = local_path {
915 if let Ok(local_content) = std::fs::read_to_string(lp) {
916 cfg.merge_local(&local_content);
917 }
918 }
919
920 if let Ok(mut guard) = CACHE.lock() {
921 *guard = Some((cfg.clone(), mtime, local_mtime));
922 }
923
924 cfg
925 }
926
927 fn merge_local(&mut self, local_toml: &str) {
928 let local: Config = match toml::from_str(local_toml) {
929 Ok(c) => c,
930 Err(_) => return,
931 };
932 if local.ultra_compact {
933 self.ultra_compact = true;
934 }
935 if local.tee_mode != TeeMode::default() {
936 self.tee_mode = local.tee_mode;
937 }
938 if local.output_density != OutputDensity::default() {
939 self.output_density = local.output_density;
940 }
941 if local.checkpoint_interval != 15 {
942 self.checkpoint_interval = local.checkpoint_interval;
943 }
944 if !local.excluded_commands.is_empty() {
945 self.excluded_commands.extend(local.excluded_commands);
946 }
947 if !local.passthrough_urls.is_empty() {
948 self.passthrough_urls.extend(local.passthrough_urls);
949 }
950 if !local.custom_aliases.is_empty() {
951 self.custom_aliases.extend(local.custom_aliases);
952 }
953 if local.slow_command_threshold_ms != 5000 {
954 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
955 }
956 if local.theme != "default" {
957 self.theme = local.theme;
958 }
959 if !local.buddy_enabled {
960 self.buddy_enabled = false;
961 }
962 if !local.redirect_exclude.is_empty() {
963 self.redirect_exclude.extend(local.redirect_exclude);
964 }
965 if !local.disabled_tools.is_empty() {
966 self.disabled_tools.extend(local.disabled_tools);
967 }
968 if !local.extra_ignore_patterns.is_empty() {
969 self.extra_ignore_patterns
970 .extend(local.extra_ignore_patterns);
971 }
972 if local.rules_scope.is_some() {
973 self.rules_scope = local.rules_scope;
974 }
975 if local.proxy.anthropic_upstream.is_some() {
976 self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
977 }
978 if local.proxy.openai_upstream.is_some() {
979 self.proxy.openai_upstream = local.proxy.openai_upstream;
980 }
981 if local.proxy.gemini_upstream.is_some() {
982 self.proxy.gemini_upstream = local.proxy.gemini_upstream;
983 }
984 if !local.autonomy.enabled {
985 self.autonomy.enabled = false;
986 }
987 if !local.autonomy.auto_preload {
988 self.autonomy.auto_preload = false;
989 }
990 if !local.autonomy.auto_dedup {
991 self.autonomy.auto_dedup = false;
992 }
993 if !local.autonomy.auto_related {
994 self.autonomy.auto_related = false;
995 }
996 if !local.autonomy.auto_consolidate {
997 self.autonomy.auto_consolidate = false;
998 }
999 if local.autonomy.silent_preload {
1000 self.autonomy.silent_preload = true;
1001 }
1002 if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1003 self.autonomy.silent_preload = false;
1004 }
1005 if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1006 self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1007 }
1008 if local.autonomy.consolidate_every_calls
1009 != AutonomyConfig::default().consolidate_every_calls
1010 {
1011 self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1012 }
1013 if local.autonomy.consolidate_cooldown_secs
1014 != AutonomyConfig::default().consolidate_cooldown_secs
1015 {
1016 self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1017 }
1018 if local.compression_level != CompressionLevel::default() {
1019 self.compression_level = local.compression_level;
1020 }
1021 if local.terse_agent != TerseAgent::default() {
1022 self.terse_agent = local.terse_agent;
1023 }
1024 if !local.archive.enabled {
1025 self.archive.enabled = false;
1026 }
1027 if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1028 self.archive.threshold_chars = local.archive.threshold_chars;
1029 }
1030 if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1031 self.archive.max_age_hours = local.archive.max_age_hours;
1032 }
1033 if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1034 self.archive.max_disk_mb = local.archive.max_disk_mb;
1035 }
1036 let mem_def = MemoryPolicy::default();
1037 if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1038 self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1039 }
1040 if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1041 self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1042 }
1043 if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1044 self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1045 }
1046 if local.memory.knowledge.contradiction_threshold
1047 != mem_def.knowledge.contradiction_threshold
1048 {
1049 self.memory.knowledge.contradiction_threshold =
1050 local.memory.knowledge.contradiction_threshold;
1051 }
1052
1053 if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1054 self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1055 }
1056 if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1057 {
1058 self.memory.episodic.max_actions_per_episode =
1059 local.memory.episodic.max_actions_per_episode;
1060 }
1061 if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1062 self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1063 }
1064
1065 if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1066 self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1067 }
1068 if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1069 self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1070 }
1071 if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1072 self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1073 }
1074 if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1075 self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1076 }
1077
1078 if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1079 self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1080 }
1081 if local.memory.lifecycle.low_confidence_threshold
1082 != mem_def.lifecycle.low_confidence_threshold
1083 {
1084 self.memory.lifecycle.low_confidence_threshold =
1085 local.memory.lifecycle.low_confidence_threshold;
1086 }
1087 if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1088 self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1089 }
1090 if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1091 self.memory.lifecycle.similarity_threshold =
1092 local.memory.lifecycle.similarity_threshold;
1093 }
1094
1095 if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1096 self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1097 }
1098 if !local.allow_paths.is_empty() {
1099 self.allow_paths.extend(local.allow_paths);
1100 }
1101 if local.minimal_overhead {
1102 self.minimal_overhead = true;
1103 }
1104 if local.shell_hook_disabled {
1105 self.shell_hook_disabled = true;
1106 }
1107 if local.shell_activation != ShellActivation::default() {
1108 self.shell_activation = local.shell_activation.clone();
1109 }
1110 if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1111 self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1112 }
1113 if local.memory_profile != MemoryProfile::default() {
1114 self.memory_profile = local.memory_profile;
1115 }
1116 if local.memory_cleanup != MemoryCleanup::default() {
1117 self.memory_cleanup = local.memory_cleanup;
1118 }
1119 }
1120
1121 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1123 let path = Self::path().ok_or_else(|| {
1124 super::error::LeanCtxError::Config("cannot determine home directory".into())
1125 })?;
1126 if let Some(parent) = path.parent() {
1127 std::fs::create_dir_all(parent)?;
1128 }
1129 let content = toml::to_string_pretty(self)
1130 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1131 std::fs::write(&path, content)?;
1132 Ok(())
1133 }
1134
1135 pub fn show(&self) -> String {
1137 let global_path = Self::path().map_or_else(
1138 || "~/.lean-ctx/config.toml".to_string(),
1139 |p| p.to_string_lossy().to_string(),
1140 );
1141 let content = toml::to_string_pretty(self).unwrap_or_default();
1142 let mut out = format!("Global config: {global_path}\n\n{content}");
1143
1144 if let Some(root) = Self::find_project_root() {
1145 let local = Self::local_path(&root);
1146 if local.exists() {
1147 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1148 } else {
1149 out.push_str(&format!(
1150 "\n\nLocal config: not found (create {} to override per-project)\n",
1151 local.display()
1152 ));
1153 }
1154 }
1155 out
1156 }
1157}
1158
1159#[cfg(test)]
1160mod compression_level_tests {
1161 use super::*;
1162
1163 #[test]
1164 fn default_is_off() {
1165 assert_eq!(CompressionLevel::default(), CompressionLevel::Off);
1166 }
1167
1168 #[test]
1169 fn to_components_off() {
1170 let (ta, od, crp, tm) = CompressionLevel::Off.to_components();
1171 assert_eq!(ta, TerseAgent::Off);
1172 assert_eq!(od, OutputDensity::Normal);
1173 assert_eq!(crp, "off");
1174 assert!(!tm);
1175 }
1176
1177 #[test]
1178 fn to_components_lite() {
1179 let (ta, od, crp, tm) = CompressionLevel::Lite.to_components();
1180 assert_eq!(ta, TerseAgent::Lite);
1181 assert_eq!(od, OutputDensity::Terse);
1182 assert_eq!(crp, "off");
1183 assert!(tm);
1184 }
1185
1186 #[test]
1187 fn to_components_standard() {
1188 let (ta, od, crp, tm) = CompressionLevel::Standard.to_components();
1189 assert_eq!(ta, TerseAgent::Full);
1190 assert_eq!(od, OutputDensity::Terse);
1191 assert_eq!(crp, "compact");
1192 assert!(tm);
1193 }
1194
1195 #[test]
1196 fn to_components_max() {
1197 let (ta, od, crp, tm) = CompressionLevel::Max.to_components();
1198 assert_eq!(ta, TerseAgent::Ultra);
1199 assert_eq!(od, OutputDensity::Ultra);
1200 assert_eq!(crp, "tdd");
1201 assert!(tm);
1202 }
1203
1204 #[test]
1205 fn from_legacy_ultra_agent_maps_to_max() {
1206 assert_eq!(
1207 CompressionLevel::from_legacy(&TerseAgent::Ultra, &OutputDensity::Normal),
1208 CompressionLevel::Max
1209 );
1210 }
1211
1212 #[test]
1213 fn from_legacy_ultra_density_maps_to_max() {
1214 assert_eq!(
1215 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Ultra),
1216 CompressionLevel::Max
1217 );
1218 }
1219
1220 #[test]
1221 fn from_legacy_full_agent_maps_to_standard() {
1222 assert_eq!(
1223 CompressionLevel::from_legacy(&TerseAgent::Full, &OutputDensity::Normal),
1224 CompressionLevel::Standard
1225 );
1226 }
1227
1228 #[test]
1229 fn from_legacy_lite_agent_maps_to_lite() {
1230 assert_eq!(
1231 CompressionLevel::from_legacy(&TerseAgent::Lite, &OutputDensity::Normal),
1232 CompressionLevel::Lite
1233 );
1234 }
1235
1236 #[test]
1237 fn from_legacy_terse_density_maps_to_lite() {
1238 assert_eq!(
1239 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Terse),
1240 CompressionLevel::Lite
1241 );
1242 }
1243
1244 #[test]
1245 fn from_legacy_both_off_maps_to_off() {
1246 assert_eq!(
1247 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Normal),
1248 CompressionLevel::Off
1249 );
1250 }
1251
1252 #[test]
1253 fn labels_match() {
1254 assert_eq!(CompressionLevel::Off.label(), "off");
1255 assert_eq!(CompressionLevel::Lite.label(), "lite");
1256 assert_eq!(CompressionLevel::Standard.label(), "standard");
1257 assert_eq!(CompressionLevel::Max.label(), "max");
1258 }
1259
1260 #[test]
1261 fn is_active_false_for_off() {
1262 assert!(!CompressionLevel::Off.is_active());
1263 }
1264
1265 #[test]
1266 fn is_active_true_for_all_others() {
1267 assert!(CompressionLevel::Lite.is_active());
1268 assert!(CompressionLevel::Standard.is_active());
1269 assert!(CompressionLevel::Max.is_active());
1270 }
1271
1272 #[test]
1273 fn deserialization_defaults_to_off() {
1274 let cfg: Config = toml::from_str("").unwrap();
1275 assert_eq!(cfg.compression_level, CompressionLevel::Off);
1276 }
1277
1278 #[test]
1279 fn deserialization_from_toml() {
1280 let cfg: Config = toml::from_str(r#"compression_level = "standard""#).unwrap();
1281 assert_eq!(cfg.compression_level, CompressionLevel::Standard);
1282 }
1283
1284 #[test]
1285 fn roundtrip_all_levels() {
1286 for level in [
1287 CompressionLevel::Off,
1288 CompressionLevel::Lite,
1289 CompressionLevel::Standard,
1290 CompressionLevel::Max,
1291 ] {
1292 let (ta, od, crp, tm) = level.to_components();
1293 assert!(!crp.is_empty());
1294 if level == CompressionLevel::Off {
1295 assert!(!tm);
1296 assert_eq!(ta, TerseAgent::Off);
1297 assert_eq!(od, OutputDensity::Normal);
1298 } else {
1299 assert!(tm);
1300 }
1301 }
1302 }
1303}
1304
1305#[cfg(test)]
1306mod memory_cleanup_tests {
1307 use super::*;
1308
1309 #[test]
1310 fn default_is_aggressive() {
1311 assert_eq!(MemoryCleanup::default(), MemoryCleanup::Aggressive);
1312 }
1313
1314 #[test]
1315 fn aggressive_ttl_is_300() {
1316 assert_eq!(MemoryCleanup::Aggressive.idle_ttl_secs(), 300);
1317 }
1318
1319 #[test]
1320 fn shared_ttl_is_1800() {
1321 assert_eq!(MemoryCleanup::Shared.idle_ttl_secs(), 1800);
1322 }
1323
1324 #[test]
1325 fn index_retention_multiplier_values() {
1326 assert!(
1327 (MemoryCleanup::Aggressive.index_retention_multiplier() - 1.0).abs() < f64::EPSILON
1328 );
1329 assert!((MemoryCleanup::Shared.index_retention_multiplier() - 3.0).abs() < f64::EPSILON);
1330 }
1331
1332 #[test]
1333 fn deserialization_defaults_to_aggressive() {
1334 let cfg: Config = toml::from_str("").unwrap();
1335 assert_eq!(cfg.memory_cleanup, MemoryCleanup::Aggressive);
1336 }
1337
1338 #[test]
1339 fn deserialization_from_toml() {
1340 let cfg: Config = toml::from_str(r#"memory_cleanup = "shared""#).unwrap();
1341 assert_eq!(cfg.memory_cleanup, MemoryCleanup::Shared);
1342 }
1343
1344 #[test]
1345 fn effective_uses_config_when_no_env() {
1346 let cfg = Config {
1347 memory_cleanup: MemoryCleanup::Shared,
1348 ..Default::default()
1349 };
1350 let eff = MemoryCleanup::effective(&cfg);
1351 assert_eq!(eff, MemoryCleanup::Shared);
1352 }
1353}