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, MemoryProfile};
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}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
337#[serde(default)]
338pub struct ArchiveConfig {
339 pub enabled: bool,
340 pub threshold_chars: usize,
341 pub max_age_hours: u64,
342 pub max_disk_mb: u64,
343}
344
345impl Default for ArchiveConfig {
346 fn default() -> Self {
347 Self {
348 enabled: true,
349 threshold_chars: 4096,
350 max_age_hours: 48,
351 max_disk_mb: 500,
352 }
353 }
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize)]
358#[serde(default)]
359pub struct AutonomyConfig {
360 pub enabled: bool,
361 pub auto_preload: bool,
362 pub auto_dedup: bool,
363 pub auto_related: bool,
364 pub auto_consolidate: bool,
365 pub silent_preload: bool,
366 pub dedup_threshold: usize,
367 pub consolidate_every_calls: u32,
368 pub consolidate_cooldown_secs: u64,
369}
370
371impl Default for AutonomyConfig {
372 fn default() -> Self {
373 Self {
374 enabled: true,
375 auto_preload: true,
376 auto_dedup: true,
377 auto_related: true,
378 auto_consolidate: true,
379 silent_preload: true,
380 dedup_threshold: 8,
381 consolidate_every_calls: 25,
382 consolidate_cooldown_secs: 120,
383 }
384 }
385}
386
387impl AutonomyConfig {
388 pub fn from_env() -> Self {
390 let mut cfg = Self::default();
391 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
392 if v == "false" || v == "0" {
393 cfg.enabled = false;
394 }
395 }
396 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
397 cfg.auto_preload = v != "false" && v != "0";
398 }
399 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
400 cfg.auto_dedup = v != "false" && v != "0";
401 }
402 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
403 cfg.auto_related = v != "false" && v != "0";
404 }
405 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
406 cfg.auto_consolidate = v != "false" && v != "0";
407 }
408 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
409 cfg.silent_preload = v != "false" && v != "0";
410 }
411 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
412 if let Ok(n) = v.parse() {
413 cfg.dedup_threshold = n;
414 }
415 }
416 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
417 if let Ok(n) = v.parse() {
418 cfg.consolidate_every_calls = n;
419 }
420 }
421 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
422 if let Ok(n) = v.parse() {
423 cfg.consolidate_cooldown_secs = n;
424 }
425 }
426 cfg
427 }
428
429 pub fn load() -> Self {
431 let file_cfg = Config::load().autonomy;
432 let mut cfg = file_cfg;
433 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
434 if v == "false" || v == "0" {
435 cfg.enabled = false;
436 }
437 }
438 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
439 cfg.auto_preload = v != "false" && v != "0";
440 }
441 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
442 cfg.auto_dedup = v != "false" && v != "0";
443 }
444 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
445 cfg.auto_related = v != "false" && v != "0";
446 }
447 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
448 cfg.silent_preload = v != "false" && v != "0";
449 }
450 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
451 if let Ok(n) = v.parse() {
452 cfg.dedup_threshold = n;
453 }
454 }
455 cfg
456 }
457}
458
459#[derive(Debug, Clone, Serialize, Deserialize, Default)]
461#[serde(default)]
462pub struct CloudConfig {
463 pub contribute_enabled: bool,
464 pub last_contribute: Option<String>,
465 pub last_sync: Option<String>,
466 pub last_gain_sync: Option<String>,
467 pub last_model_pull: Option<String>,
468}
469
470#[derive(Debug, Clone, Serialize, Deserialize)]
472pub struct AliasEntry {
473 pub command: String,
474 pub alias: String,
475}
476
477#[derive(Debug, Clone, Serialize, Deserialize)]
479#[serde(default)]
480pub struct LoopDetectionConfig {
481 pub normal_threshold: u32,
482 pub reduced_threshold: u32,
483 pub blocked_threshold: u32,
484 pub window_secs: u64,
485 pub search_group_limit: u32,
486}
487
488impl Default for LoopDetectionConfig {
489 fn default() -> Self {
490 Self {
491 normal_threshold: 2,
492 reduced_threshold: 4,
493 blocked_threshold: 0,
496 window_secs: 300,
497 search_group_limit: 10,
498 }
499 }
500}
501
502impl Default for Config {
503 fn default() -> Self {
504 Self {
505 ultra_compact: false,
506 tee_mode: TeeMode::default(),
507 output_density: OutputDensity::default(),
508 checkpoint_interval: 15,
509 excluded_commands: Vec::new(),
510 passthrough_urls: Vec::new(),
511 custom_aliases: Vec::new(),
512 slow_command_threshold_ms: 5000,
513 theme: serde_defaults::default_theme(),
514 cloud: CloudConfig::default(),
515 autonomy: AutonomyConfig::default(),
516 proxy: ProxyConfig::default(),
517 buddy_enabled: serde_defaults::default_buddy_enabled(),
518 redirect_exclude: Vec::new(),
519 disabled_tools: Vec::new(),
520 loop_detection: LoopDetectionConfig::default(),
521 rules_scope: None,
522 extra_ignore_patterns: Vec::new(),
523 terse_agent: TerseAgent::default(),
524 compression_level: CompressionLevel::default(),
525 archive: ArchiveConfig::default(),
526 memory: MemoryPolicy::default(),
527 allow_paths: Vec::new(),
528 content_defined_chunking: false,
529 minimal_overhead: false,
530 shell_hook_disabled: false,
531 shell_activation: ShellActivation::default(),
532 update_check_disabled: false,
533 graph_index_max_files: serde_defaults::default_graph_index_max_files(),
534 bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
535 memory_profile: MemoryProfile::default(),
536 memory_cleanup: MemoryCleanup::default(),
537 }
538 }
539}
540
541#[derive(Debug, Clone, Copy, PartialEq, Eq)]
543pub enum RulesScope {
544 Both,
545 Global,
546 Project,
547}
548
549impl Config {
550 pub fn rules_scope_effective(&self) -> RulesScope {
552 let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
553 .ok()
554 .or_else(|| self.rules_scope.clone())
555 .unwrap_or_default();
556 match raw.trim().to_lowercase().as_str() {
557 "global" => RulesScope::Global,
558 "project" => RulesScope::Project,
559 _ => RulesScope::Both,
560 }
561 }
562
563 fn parse_disabled_tools_env(val: &str) -> Vec<String> {
564 val.split(',')
565 .map(|s| s.trim().to_string())
566 .filter(|s| !s.is_empty())
567 .collect()
568 }
569
570 pub fn disabled_tools_effective(&self) -> Vec<String> {
572 if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
573 Self::parse_disabled_tools_env(&val)
574 } else {
575 self.disabled_tools.clone()
576 }
577 }
578
579 pub fn minimal_overhead_effective(&self) -> bool {
581 std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
582 }
583
584 pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
592 if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
593 match raw.trim().to_lowercase().as_str() {
594 "minimal" => return true,
595 "full" => return self.minimal_overhead_effective(),
596 _ => {}
597 }
598 }
599
600 if self.minimal_overhead_effective() {
601 return true;
602 }
603
604 let client_lower = client_name.trim().to_lowercase();
605 if !client_lower.is_empty() {
606 if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
607 for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
608 if !needle.is_empty() && client_lower.contains(&needle) {
609 return true;
610 }
611 }
612 } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
613 return true;
614 }
615 }
616
617 let model = std::env::var("LEAN_CTX_MODEL")
618 .or_else(|_| std::env::var("LCTX_MODEL"))
619 .unwrap_or_default();
620 let model = model.trim().to_lowercase();
621 if !model.is_empty() {
622 let m = model.replace(['_', ' '], "-");
623 if m.contains("minimax")
624 || m.contains("mini-max")
625 || m.contains("m2.7")
626 || m.contains("m2-7")
627 {
628 return true;
629 }
630 }
631
632 false
633 }
634
635 pub fn shell_hook_disabled_effective(&self) -> bool {
637 std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
638 }
639
640 pub fn shell_activation_effective(&self) -> ShellActivation {
642 ShellActivation::effective(self)
643 }
644
645 pub fn update_check_disabled_effective(&self) -> bool {
647 std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
648 }
649
650 pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
651 let mut policy = self.memory.clone();
652 policy.apply_env_overrides();
653 policy.validate()?;
654 Ok(policy)
655 }
656}
657
658#[cfg(test)]
659mod disabled_tools_tests {
660 use super::*;
661
662 #[test]
663 fn config_field_default_is_empty() {
664 let cfg = Config::default();
665 assert!(cfg.disabled_tools.is_empty());
666 }
667
668 #[test]
669 fn effective_returns_config_field_when_no_env_var() {
670 if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
672 return;
673 }
674 let cfg = Config {
675 disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
676 ..Default::default()
677 };
678 assert_eq!(
679 cfg.disabled_tools_effective(),
680 vec!["ctx_graph", "ctx_agent"]
681 );
682 }
683
684 #[test]
685 fn parse_env_basic() {
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_trims_whitespace_and_skips_empty() {
692 let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
693 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
694 }
695
696 #[test]
697 fn parse_env_single_entry() {
698 let result = Config::parse_disabled_tools_env("ctx_graph");
699 assert_eq!(result, vec!["ctx_graph"]);
700 }
701
702 #[test]
703 fn parse_env_empty_string_returns_empty() {
704 let result = Config::parse_disabled_tools_env("");
705 assert!(result.is_empty());
706 }
707
708 #[test]
709 fn disabled_tools_deserialization_defaults_to_empty() {
710 let cfg: Config = toml::from_str("").unwrap();
711 assert!(cfg.disabled_tools.is_empty());
712 }
713
714 #[test]
715 fn disabled_tools_deserialization_from_toml() {
716 let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
717 assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
718 }
719}
720
721#[cfg(test)]
722mod rules_scope_tests {
723 use super::*;
724
725 #[test]
726 fn default_is_both() {
727 let cfg = Config::default();
728 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
729 }
730
731 #[test]
732 fn config_global() {
733 let cfg = Config {
734 rules_scope: Some("global".to_string()),
735 ..Default::default()
736 };
737 assert_eq!(cfg.rules_scope_effective(), RulesScope::Global);
738 }
739
740 #[test]
741 fn config_project() {
742 let cfg = Config {
743 rules_scope: Some("project".to_string()),
744 ..Default::default()
745 };
746 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
747 }
748
749 #[test]
750 fn unknown_value_falls_back_to_both() {
751 let cfg = Config {
752 rules_scope: Some("nonsense".to_string()),
753 ..Default::default()
754 };
755 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
756 }
757
758 #[test]
759 fn deserialization_none_by_default() {
760 let cfg: Config = toml::from_str("").unwrap();
761 assert!(cfg.rules_scope.is_none());
762 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
763 }
764
765 #[test]
766 fn deserialization_from_toml() {
767 let cfg: Config = toml::from_str(r#"rules_scope = "project""#).unwrap();
768 assert_eq!(cfg.rules_scope.as_deref(), Some("project"));
769 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
770 }
771}
772
773#[cfg(test)]
774mod loop_detection_config_tests {
775 use super::*;
776
777 #[test]
778 fn defaults_are_reasonable() {
779 let cfg = LoopDetectionConfig::default();
780 assert_eq!(cfg.normal_threshold, 2);
781 assert_eq!(cfg.reduced_threshold, 4);
782 assert_eq!(cfg.blocked_threshold, 0);
784 assert_eq!(cfg.window_secs, 300);
785 assert_eq!(cfg.search_group_limit, 10);
786 }
787
788 #[test]
789 fn deserialization_defaults_when_missing() {
790 let cfg: Config = toml::from_str("").unwrap();
791 assert_eq!(cfg.loop_detection.blocked_threshold, 0);
793 assert_eq!(cfg.loop_detection.search_group_limit, 10);
794 }
795
796 #[test]
797 fn deserialization_from_toml() {
798 let cfg: Config = toml::from_str(
799 r"
800 [loop_detection]
801 normal_threshold = 1
802 reduced_threshold = 3
803 blocked_threshold = 5
804 window_secs = 120
805 search_group_limit = 8
806 ",
807 )
808 .unwrap();
809 assert_eq!(cfg.loop_detection.normal_threshold, 1);
810 assert_eq!(cfg.loop_detection.reduced_threshold, 3);
811 assert_eq!(cfg.loop_detection.blocked_threshold, 5);
812 assert_eq!(cfg.loop_detection.window_secs, 120);
813 assert_eq!(cfg.loop_detection.search_group_limit, 8);
814 }
815
816 #[test]
817 fn partial_override_keeps_defaults() {
818 let cfg: Config = toml::from_str(
819 r"
820 [loop_detection]
821 blocked_threshold = 10
822 ",
823 )
824 .unwrap();
825 assert_eq!(cfg.loop_detection.blocked_threshold, 10);
826 assert_eq!(cfg.loop_detection.normal_threshold, 2);
827 assert_eq!(cfg.loop_detection.search_group_limit, 10);
828 }
829}
830
831impl Config {
832 pub fn path() -> Option<PathBuf> {
834 crate::core::data_dir::lean_ctx_data_dir()
835 .ok()
836 .map(|d| d.join("config.toml"))
837 }
838
839 pub fn local_path(project_root: &str) -> PathBuf {
841 PathBuf::from(project_root).join(".lean-ctx.toml")
842 }
843
844 fn find_project_root() -> Option<String> {
845 let cwd = std::env::current_dir().ok();
846
847 if let Some(root) =
848 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
849 {
850 let root_path = std::path::Path::new(&root);
851 let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
852 let has_marker = root_path.join(".git").exists()
853 || root_path.join("Cargo.toml").exists()
854 || root_path.join("package.json").exists()
855 || root_path.join("go.mod").exists()
856 || root_path.join("pyproject.toml").exists()
857 || root_path.join(".lean-ctx.toml").exists();
858
859 if cwd_is_under_root || has_marker {
860 return Some(root);
861 }
862 }
863
864 if let Some(ref cwd) = cwd {
865 let git_root = std::process::Command::new("git")
866 .args(["rev-parse", "--show-toplevel"])
867 .current_dir(cwd)
868 .stdout(std::process::Stdio::piped())
869 .stderr(std::process::Stdio::null())
870 .output()
871 .ok()
872 .and_then(|o| {
873 if o.status.success() {
874 String::from_utf8(o.stdout)
875 .ok()
876 .map(|s| s.trim().to_string())
877 } else {
878 None
879 }
880 });
881 if let Some(root) = git_root {
882 return Some(root);
883 }
884 return Some(cwd.to_string_lossy().to_string());
885 }
886 None
887 }
888
889 pub fn load() -> Self {
891 static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
892
893 let Some(path) = Self::path() else {
894 return Self::default();
895 };
896
897 let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
898
899 let mtime = std::fs::metadata(&path)
900 .and_then(|m| m.modified())
901 .unwrap_or(SystemTime::UNIX_EPOCH);
902
903 let local_mtime = local_path
904 .as_ref()
905 .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
906
907 if let Ok(guard) = CACHE.lock() {
908 if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
909 if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
910 return cfg.clone();
911 }
912 }
913 }
914
915 let mut cfg: Config = match std::fs::read_to_string(&path) {
916 Ok(content) => match toml::from_str(&content) {
917 Ok(c) => c,
918 Err(e) => {
919 tracing::warn!("config parse error in {}: {e}", path.display());
920 Self::default()
921 }
922 },
923 Err(_) => Self::default(),
924 };
925
926 if let Some(ref lp) = local_path {
927 if let Ok(local_content) = std::fs::read_to_string(lp) {
928 cfg.merge_local(&local_content);
929 }
930 }
931
932 if let Ok(mut guard) = CACHE.lock() {
933 *guard = Some((cfg.clone(), mtime, local_mtime));
934 }
935
936 cfg
937 }
938
939 fn merge_local(&mut self, local_toml: &str) {
940 let local: Config = match toml::from_str(local_toml) {
941 Ok(c) => c,
942 Err(e) => {
943 tracing::warn!("local config parse error: {e}");
944 return;
945 }
946 };
947 if local.ultra_compact {
948 self.ultra_compact = true;
949 }
950 if local.tee_mode != TeeMode::default() {
951 self.tee_mode = local.tee_mode;
952 }
953 if local.output_density != OutputDensity::default() {
954 self.output_density = local.output_density;
955 }
956 if local.checkpoint_interval != 15 {
957 self.checkpoint_interval = local.checkpoint_interval;
958 }
959 if !local.excluded_commands.is_empty() {
960 self.excluded_commands.extend(local.excluded_commands);
961 }
962 if !local.passthrough_urls.is_empty() {
963 self.passthrough_urls.extend(local.passthrough_urls);
964 }
965 if !local.custom_aliases.is_empty() {
966 self.custom_aliases.extend(local.custom_aliases);
967 }
968 if local.slow_command_threshold_ms != 5000 {
969 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
970 }
971 if local.theme != "default" {
972 self.theme = local.theme;
973 }
974 if !local.buddy_enabled {
975 self.buddy_enabled = false;
976 }
977 if !local.redirect_exclude.is_empty() {
978 self.redirect_exclude.extend(local.redirect_exclude);
979 }
980 if !local.disabled_tools.is_empty() {
981 self.disabled_tools.extend(local.disabled_tools);
982 }
983 if !local.extra_ignore_patterns.is_empty() {
984 self.extra_ignore_patterns
985 .extend(local.extra_ignore_patterns);
986 }
987 if local.rules_scope.is_some() {
988 self.rules_scope = local.rules_scope;
989 }
990 if local.proxy.anthropic_upstream.is_some() {
991 self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
992 }
993 if local.proxy.openai_upstream.is_some() {
994 self.proxy.openai_upstream = local.proxy.openai_upstream;
995 }
996 if local.proxy.gemini_upstream.is_some() {
997 self.proxy.gemini_upstream = local.proxy.gemini_upstream;
998 }
999 if !local.autonomy.enabled {
1000 self.autonomy.enabled = false;
1001 }
1002 if !local.autonomy.auto_preload {
1003 self.autonomy.auto_preload = false;
1004 }
1005 if !local.autonomy.auto_dedup {
1006 self.autonomy.auto_dedup = false;
1007 }
1008 if !local.autonomy.auto_related {
1009 self.autonomy.auto_related = false;
1010 }
1011 if !local.autonomy.auto_consolidate {
1012 self.autonomy.auto_consolidate = false;
1013 }
1014 if local.autonomy.silent_preload {
1015 self.autonomy.silent_preload = true;
1016 }
1017 if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1018 self.autonomy.silent_preload = false;
1019 }
1020 if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1021 self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1022 }
1023 if local.autonomy.consolidate_every_calls
1024 != AutonomyConfig::default().consolidate_every_calls
1025 {
1026 self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1027 }
1028 if local.autonomy.consolidate_cooldown_secs
1029 != AutonomyConfig::default().consolidate_cooldown_secs
1030 {
1031 self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1032 }
1033 if local.compression_level != CompressionLevel::default() {
1034 self.compression_level = local.compression_level;
1035 }
1036 if local.terse_agent != TerseAgent::default() {
1037 self.terse_agent = local.terse_agent;
1038 }
1039 if !local.archive.enabled {
1040 self.archive.enabled = false;
1041 }
1042 if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1043 self.archive.threshold_chars = local.archive.threshold_chars;
1044 }
1045 if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1046 self.archive.max_age_hours = local.archive.max_age_hours;
1047 }
1048 if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1049 self.archive.max_disk_mb = local.archive.max_disk_mb;
1050 }
1051 let mem_def = MemoryPolicy::default();
1052 if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1053 self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1054 }
1055 if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1056 self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1057 }
1058 if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1059 self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1060 }
1061 if local.memory.knowledge.contradiction_threshold
1062 != mem_def.knowledge.contradiction_threshold
1063 {
1064 self.memory.knowledge.contradiction_threshold =
1065 local.memory.knowledge.contradiction_threshold;
1066 }
1067
1068 if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1069 self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1070 }
1071 if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1072 {
1073 self.memory.episodic.max_actions_per_episode =
1074 local.memory.episodic.max_actions_per_episode;
1075 }
1076 if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1077 self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1078 }
1079
1080 if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1081 self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1082 }
1083 if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1084 self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1085 }
1086 if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1087 self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1088 }
1089 if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1090 self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1091 }
1092
1093 if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1094 self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1095 }
1096 if local.memory.lifecycle.low_confidence_threshold
1097 != mem_def.lifecycle.low_confidence_threshold
1098 {
1099 self.memory.lifecycle.low_confidence_threshold =
1100 local.memory.lifecycle.low_confidence_threshold;
1101 }
1102 if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1103 self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1104 }
1105 if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1106 self.memory.lifecycle.similarity_threshold =
1107 local.memory.lifecycle.similarity_threshold;
1108 }
1109
1110 if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1111 self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1112 }
1113 if !local.allow_paths.is_empty() {
1114 self.allow_paths.extend(local.allow_paths);
1115 }
1116 if local.minimal_overhead {
1117 self.minimal_overhead = true;
1118 }
1119 if local.shell_hook_disabled {
1120 self.shell_hook_disabled = true;
1121 }
1122 if local.shell_activation != ShellActivation::default() {
1123 self.shell_activation = local.shell_activation.clone();
1124 }
1125 if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1126 self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1127 }
1128 if local.memory_profile != MemoryProfile::default() {
1129 self.memory_profile = local.memory_profile;
1130 }
1131 if local.memory_cleanup != MemoryCleanup::default() {
1132 self.memory_cleanup = local.memory_cleanup;
1133 }
1134 }
1135
1136 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1138 let path = Self::path().ok_or_else(|| {
1139 super::error::LeanCtxError::Config("cannot determine home directory".into())
1140 })?;
1141 if let Some(parent) = path.parent() {
1142 std::fs::create_dir_all(parent)?;
1143 }
1144 let content = toml::to_string_pretty(self)
1145 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1146 std::fs::write(&path, content)?;
1147 Ok(())
1148 }
1149
1150 pub fn show(&self) -> String {
1152 let global_path = Self::path().map_or_else(
1153 || "~/.lean-ctx/config.toml".to_string(),
1154 |p| p.to_string_lossy().to_string(),
1155 );
1156 let content = toml::to_string_pretty(self).unwrap_or_default();
1157 let mut out = format!("Global config: {global_path}\n\n{content}");
1158
1159 if let Some(root) = Self::find_project_root() {
1160 let local = Self::local_path(&root);
1161 if local.exists() {
1162 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1163 } else {
1164 out.push_str(&format!(
1165 "\n\nLocal config: not found (create {} to override per-project)\n",
1166 local.display()
1167 ));
1168 }
1169 }
1170 out
1171 }
1172}
1173
1174#[cfg(test)]
1175mod compression_level_tests {
1176 use super::*;
1177
1178 #[test]
1179 fn default_is_off() {
1180 assert_eq!(CompressionLevel::default(), CompressionLevel::Off);
1181 }
1182
1183 #[test]
1184 fn to_components_off() {
1185 let (ta, od, crp, tm) = CompressionLevel::Off.to_components();
1186 assert_eq!(ta, TerseAgent::Off);
1187 assert_eq!(od, OutputDensity::Normal);
1188 assert_eq!(crp, "off");
1189 assert!(!tm);
1190 }
1191
1192 #[test]
1193 fn to_components_lite() {
1194 let (ta, od, crp, tm) = CompressionLevel::Lite.to_components();
1195 assert_eq!(ta, TerseAgent::Lite);
1196 assert_eq!(od, OutputDensity::Terse);
1197 assert_eq!(crp, "off");
1198 assert!(tm);
1199 }
1200
1201 #[test]
1202 fn to_components_standard() {
1203 let (ta, od, crp, tm) = CompressionLevel::Standard.to_components();
1204 assert_eq!(ta, TerseAgent::Full);
1205 assert_eq!(od, OutputDensity::Terse);
1206 assert_eq!(crp, "compact");
1207 assert!(tm);
1208 }
1209
1210 #[test]
1211 fn to_components_max() {
1212 let (ta, od, crp, tm) = CompressionLevel::Max.to_components();
1213 assert_eq!(ta, TerseAgent::Ultra);
1214 assert_eq!(od, OutputDensity::Ultra);
1215 assert_eq!(crp, "tdd");
1216 assert!(tm);
1217 }
1218
1219 #[test]
1220 fn from_legacy_ultra_agent_maps_to_max() {
1221 assert_eq!(
1222 CompressionLevel::from_legacy(&TerseAgent::Ultra, &OutputDensity::Normal),
1223 CompressionLevel::Max
1224 );
1225 }
1226
1227 #[test]
1228 fn from_legacy_ultra_density_maps_to_max() {
1229 assert_eq!(
1230 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Ultra),
1231 CompressionLevel::Max
1232 );
1233 }
1234
1235 #[test]
1236 fn from_legacy_full_agent_maps_to_standard() {
1237 assert_eq!(
1238 CompressionLevel::from_legacy(&TerseAgent::Full, &OutputDensity::Normal),
1239 CompressionLevel::Standard
1240 );
1241 }
1242
1243 #[test]
1244 fn from_legacy_lite_agent_maps_to_lite() {
1245 assert_eq!(
1246 CompressionLevel::from_legacy(&TerseAgent::Lite, &OutputDensity::Normal),
1247 CompressionLevel::Lite
1248 );
1249 }
1250
1251 #[test]
1252 fn from_legacy_terse_density_maps_to_lite() {
1253 assert_eq!(
1254 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Terse),
1255 CompressionLevel::Lite
1256 );
1257 }
1258
1259 #[test]
1260 fn from_legacy_both_off_maps_to_off() {
1261 assert_eq!(
1262 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Normal),
1263 CompressionLevel::Off
1264 );
1265 }
1266
1267 #[test]
1268 fn labels_match() {
1269 assert_eq!(CompressionLevel::Off.label(), "off");
1270 assert_eq!(CompressionLevel::Lite.label(), "lite");
1271 assert_eq!(CompressionLevel::Standard.label(), "standard");
1272 assert_eq!(CompressionLevel::Max.label(), "max");
1273 }
1274
1275 #[test]
1276 fn is_active_false_for_off() {
1277 assert!(!CompressionLevel::Off.is_active());
1278 }
1279
1280 #[test]
1281 fn is_active_true_for_all_others() {
1282 assert!(CompressionLevel::Lite.is_active());
1283 assert!(CompressionLevel::Standard.is_active());
1284 assert!(CompressionLevel::Max.is_active());
1285 }
1286
1287 #[test]
1288 fn deserialization_defaults_to_off() {
1289 let cfg: Config = toml::from_str("").unwrap();
1290 assert_eq!(cfg.compression_level, CompressionLevel::Off);
1291 }
1292
1293 #[test]
1294 fn deserialization_from_toml() {
1295 let cfg: Config = toml::from_str(r#"compression_level = "standard""#).unwrap();
1296 assert_eq!(cfg.compression_level, CompressionLevel::Standard);
1297 }
1298
1299 #[test]
1300 fn roundtrip_all_levels() {
1301 for level in [
1302 CompressionLevel::Off,
1303 CompressionLevel::Lite,
1304 CompressionLevel::Standard,
1305 CompressionLevel::Max,
1306 ] {
1307 let (ta, od, crp, tm) = level.to_components();
1308 assert!(!crp.is_empty());
1309 if level == CompressionLevel::Off {
1310 assert!(!tm);
1311 assert_eq!(ta, TerseAgent::Off);
1312 assert_eq!(od, OutputDensity::Normal);
1313 } else {
1314 assert!(tm);
1315 }
1316 }
1317 }
1318}
1319
1320#[cfg(test)]
1321mod memory_cleanup_tests {
1322 use super::*;
1323
1324 #[test]
1325 fn default_is_aggressive() {
1326 assert_eq!(MemoryCleanup::default(), MemoryCleanup::Aggressive);
1327 }
1328
1329 #[test]
1330 fn aggressive_ttl_is_300() {
1331 assert_eq!(MemoryCleanup::Aggressive.idle_ttl_secs(), 300);
1332 }
1333
1334 #[test]
1335 fn shared_ttl_is_1800() {
1336 assert_eq!(MemoryCleanup::Shared.idle_ttl_secs(), 1800);
1337 }
1338
1339 #[test]
1340 fn index_retention_multiplier_values() {
1341 assert!(
1342 (MemoryCleanup::Aggressive.index_retention_multiplier() - 1.0).abs() < f64::EPSILON
1343 );
1344 assert!((MemoryCleanup::Shared.index_retention_multiplier() - 3.0).abs() < f64::EPSILON);
1345 }
1346
1347 #[test]
1348 fn deserialization_defaults_to_aggressive() {
1349 let cfg: Config = toml::from_str("").unwrap();
1350 assert_eq!(cfg.memory_cleanup, MemoryCleanup::Aggressive);
1351 }
1352
1353 #[test]
1354 fn deserialization_from_toml() {
1355 let cfg: Config = toml::from_str(r#"memory_cleanup = "shared""#).unwrap();
1356 assert_eq!(cfg.memory_cleanup, MemoryCleanup::Shared);
1357 }
1358
1359 #[test]
1360 fn effective_uses_config_when_no_env() {
1361 let cfg = Config {
1362 memory_cleanup: MemoryCleanup::Shared,
1363 ..Default::default()
1364 };
1365 let eff = MemoryCleanup::effective(&cfg);
1366 assert_eq!(eff, MemoryCleanup::Shared);
1367 }
1368}