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