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