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