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