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