1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4use std::sync::atomic::AtomicU8;
5use std::sync::Mutex;
6use std::time::SystemTime;
7
8static SESSION_DEGRADE_LEVEL: AtomicU8 = AtomicU8::new(0);
9
10use super::memory_policy::MemoryPolicy;
11
12mod memory;
13mod proxy;
14pub mod schema;
15mod serde_defaults;
16pub mod setter;
17mod shell_activation;
18
19pub use memory::{MemoryCleanup, MemoryGuardConfig, MemoryProfile, SavingsFooter};
20pub use proxy::{is_local_proxy_url, normalize_url, normalize_url_opt, ProxyConfig, ProxyProvider};
21pub use shell_activation::ShellActivation;
22
23pub fn default_bm25_max_cache_mb() -> u64 {
25 serde_defaults::default_bm25_max_cache_mb()
26}
27
28#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
30#[serde(rename_all = "lowercase")]
31pub enum TeeMode {
32 Never,
33 #[default]
34 Failures,
35 HighCompression,
36 Always,
37}
38
39#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
43#[serde(rename_all = "lowercase")]
44pub enum TerseAgent {
45 #[default]
46 Off,
47 Lite,
48 Full,
49 Ultra,
50}
51
52impl TerseAgent {
53 pub fn from_env() -> Self {
55 match std::env::var("LEAN_CTX_TERSE_AGENT")
56 .unwrap_or_default()
57 .to_lowercase()
58 .as_str()
59 {
60 "lite" => Self::Lite,
61 "full" => Self::Full,
62 "ultra" => Self::Ultra,
63 _ => Self::Off,
64 }
65 }
66}
67
68#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
72#[serde(rename_all = "lowercase")]
73pub enum OutputDensity {
74 #[default]
75 Normal,
76 Terse,
77 Ultra,
78}
79
80impl OutputDensity {
81 pub fn from_env() -> Self {
83 match std::env::var("LEAN_CTX_OUTPUT_DENSITY")
84 .unwrap_or_default()
85 .to_lowercase()
86 .as_str()
87 {
88 "terse" => Self::Terse,
89 "ultra" => Self::Ultra,
90 _ => Self::Normal,
91 }
92 }
93}
94
95#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
100#[serde(rename_all = "snake_case")]
101pub enum ResponseVerbosity {
102 #[default]
103 Full,
104 HeadersOnly,
105}
106
107impl ResponseVerbosity {
108 pub fn effective() -> Self {
109 if let Ok(v) = std::env::var("LEAN_CTX_RESPONSE_VERBOSITY") {
110 match v.trim().to_lowercase().as_str() {
111 "headers_only" | "headers" | "minimal" => return Self::HeadersOnly,
112 "full" | "" => return Self::Full,
113 _ => {}
114 }
115 }
116 Config::load().response_verbosity
117 }
118
119 pub fn is_headers_only(&self) -> bool {
120 matches!(self, Self::HeadersOnly)
121 }
122}
123
124#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
126#[serde(rename_all = "lowercase")]
127pub enum CompressionLevel {
128 Off,
129 #[default]
136 Lite,
137 Standard,
138 Max,
139}
140
141impl CompressionLevel {
142 pub fn to_components(&self) -> (TerseAgent, OutputDensity, &'static str, bool) {
145 match self {
146 Self::Off => (TerseAgent::Off, OutputDensity::Normal, "off", false),
147 Self::Lite => (TerseAgent::Lite, OutputDensity::Terse, "off", true),
148 Self::Standard => (TerseAgent::Full, OutputDensity::Terse, "compact", true),
149 Self::Max => (TerseAgent::Ultra, OutputDensity::Ultra, "tdd", true),
150 }
151 }
152
153 pub fn from_legacy(terse_agent: &TerseAgent, output_density: &OutputDensity) -> Self {
156 match (terse_agent, output_density) {
157 (TerseAgent::Ultra, _) | (_, OutputDensity::Ultra) => Self::Max,
158 (TerseAgent::Full, _) => Self::Standard,
159 (TerseAgent::Lite, _) | (_, OutputDensity::Terse) => Self::Lite,
160 _ => Self::Off,
161 }
162 }
163
164 pub fn from_env() -> Option<Self> {
166 std::env::var("LEAN_CTX_COMPRESSION").ok().and_then(|v| {
167 match v.trim().to_lowercase().as_str() {
168 "off" => Some(Self::Off),
169 "lite" => Some(Self::Lite),
170 "standard" => Some(Self::Standard),
171 "max" => Some(Self::Max),
172 _ => None,
173 }
174 })
175 }
176
177 pub fn effective(config: &Config) -> Self {
185 if let Some(degraded) = Self::session_degrade_level() {
186 return degraded;
187 }
188 if let Some(env_level) = Self::from_env() {
189 return env_level;
190 }
191 if config.compression_level != Self::Off {
192 return config.compression_level.clone();
193 }
194 if config.ultra_compact {
195 return Self::Max;
196 }
197 let ta_env = TerseAgent::from_env();
198 let od_env = OutputDensity::from_env();
199 let ta = if ta_env == TerseAgent::Off {
200 config.terse_agent.clone()
201 } else {
202 ta_env
203 };
204 let od = if od_env == OutputDensity::Normal {
205 config.output_density.clone()
206 } else {
207 od_env
208 };
209 Self::from_legacy(&ta, &od)
210 }
211
212 pub fn session_degrade_level() -> Option<Self> {
215 match SESSION_DEGRADE_LEVEL.load(std::sync::atomic::Ordering::Relaxed) {
216 1 => Some(Self::Off),
217 2 => Some(Self::Lite),
218 _ => None,
219 }
220 }
221
222 pub fn set_session_degrade(level: &Self) {
224 let val = match level {
225 Self::Off => 1u8,
226 Self::Lite => 2u8,
227 _ => 0u8,
228 };
229 SESSION_DEGRADE_LEVEL.store(val, std::sync::atomic::Ordering::Relaxed);
230 }
231
232 pub fn clear_session_degrade() {
234 SESSION_DEGRADE_LEVEL.store(0, std::sync::atomic::Ordering::Relaxed);
235 }
236
237 pub fn from_str_label(s: &str) -> Option<Self> {
238 match s.trim().to_lowercase().as_str() {
239 "off" => Some(Self::Off),
240 "lite" => Some(Self::Lite),
241 "standard" | "std" => Some(Self::Standard),
242 "max" => Some(Self::Max),
243 _ => None,
244 }
245 }
246
247 pub fn is_active(&self) -> bool {
248 !matches!(self, Self::Off)
249 }
250
251 pub fn label(&self) -> &'static str {
252 match self {
253 Self::Off => "off",
254 Self::Lite => "lite",
255 Self::Standard => "standard",
256 Self::Max => "max",
257 }
258 }
259
260 pub fn description(&self) -> &'static str {
261 match self {
262 Self::Off => "No compression — full verbose output",
263 Self::Lite => "Light compression — concise output, basic terse filtering",
264 Self::Standard => {
265 "Standard compression — dense output, compact protocol, pattern-aware"
266 }
267 Self::Max => "Maximum compression — expert mode, TDD protocol, all layers active",
268 }
269 }
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
274#[serde(default)]
275pub struct Config {
276 pub ultra_compact: bool,
277 #[serde(default, deserialize_with = "serde_defaults::deserialize_tee_mode")]
278 pub tee_mode: TeeMode,
279 #[serde(default)]
280 pub output_density: OutputDensity,
281 pub checkpoint_interval: u32,
282 pub excluded_commands: Vec<String>,
283 pub passthrough_urls: Vec<String>,
284 pub custom_aliases: Vec<AliasEntry>,
285 pub slow_command_threshold_ms: u64,
288 #[serde(default = "serde_defaults::default_theme")]
289 pub theme: String,
290 #[serde(default)]
291 pub cloud: CloudConfig,
292 #[serde(default)]
293 pub autonomy: AutonomyConfig,
294 #[serde(default)]
295 pub providers: ProvidersConfig,
296 #[serde(default)]
297 pub proxy: ProxyConfig,
298 #[serde(default)]
303 pub proxy_enabled: Option<bool>,
304 #[serde(default)]
305 pub proxy_port: Option<u16>,
306 #[serde(default)]
309 pub proxy_timeout_ms: Option<u64>,
310 #[serde(default = "serde_defaults::default_buddy_enabled")]
311 pub buddy_enabled: bool,
312 #[serde(default = "serde_defaults::default_true")]
313 pub enable_wakeup_ctx: bool,
314 #[serde(default)]
315 pub redirect_exclude: Vec<String>,
316 #[serde(default)]
320 pub disabled_tools: Vec<String>,
321 #[serde(default)]
327 pub default_tool_categories: Vec<String>,
328 #[serde(default)]
332 pub no_degrade: bool,
333 #[serde(default)]
336 pub profile: Option<String>,
337 #[serde(default)]
338 pub loop_detection: LoopDetectionConfig,
339 #[serde(default)]
343 pub rules_scope: Option<String>,
344 #[serde(default)]
347 pub extra_ignore_patterns: Vec<String>,
348 #[serde(default)]
352 pub terse_agent: TerseAgent,
353 #[serde(default)]
357 pub compression_level: CompressionLevel,
358 #[serde(default)]
360 pub archive: ArchiveConfig,
361 #[serde(default)]
363 pub memory: MemoryPolicy,
364 #[serde(default)]
368 pub allow_paths: Vec<String>,
369 #[serde(default)]
374 pub extra_roots: Vec<String>,
375 #[serde(default)]
378 pub content_defined_chunking: bool,
379 #[serde(default)]
382 pub minimal_overhead: bool,
383 #[serde(default = "serde_defaults::default_true")]
385 pub symbol_map_auto: bool,
386 #[serde(default)]
388 pub journal_enabled: bool,
389 #[serde(default)]
391 pub auto_capture: bool,
392 #[serde(default)]
394 pub search: crate::core::hybrid_search::HybridConfig,
395 #[serde(default)]
397 pub llm: crate::core::llm_enhance::LlmConfig,
398 #[serde(default)]
401 pub shell_hook_disabled: bool,
402 #[serde(default)]
409 pub shell_activation: ShellActivation,
410 #[serde(default)]
413 pub update_check_disabled: bool,
414 #[serde(default)]
415 pub updates: UpdatesConfig,
416 #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
419 pub bm25_max_cache_mb: u64,
420 #[serde(default = "serde_defaults::default_graph_index_max_files")]
423 pub graph_index_max_files: u64,
424 #[serde(default)]
427 pub memory_profile: MemoryProfile,
428 #[serde(default)]
432 pub memory_cleanup: MemoryCleanup,
433 #[serde(default = "serde_defaults::default_max_ram_percent")]
436 pub max_ram_percent: u8,
437 #[serde(default)]
441 pub max_disk_mb: u64,
442 #[serde(default)]
445 pub max_staleness_days: u32,
446 #[serde(default)]
450 pub savings_footer: SavingsFooter,
451 #[serde(default)]
455 pub project_root: Option<String>,
456 #[serde(default)]
459 pub lsp: std::collections::HashMap<String, String>,
460 #[serde(default)]
464 pub ide_paths: HashMap<String, Vec<String>>,
465 #[serde(default)]
468 pub model_context_windows: HashMap<String, usize>,
469 #[serde(default)]
476 pub response_verbosity: ResponseVerbosity,
477 #[serde(default)]
482 pub bypass_hints: Option<String>,
483 #[serde(default)]
488 pub cache_policy: Option<String>,
489 #[serde(default)]
492 pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
493 #[serde(default)]
494 pub secret_detection: SecretDetectionConfig,
495 #[serde(default)]
499 pub allow_auto_reroot: bool,
500 #[serde(default)]
503 pub path_jail: Option<bool>,
504 #[serde(default)]
508 pub sandbox_level: u8,
509 #[serde(default)]
513 pub reference_results: bool,
514 #[serde(default)]
517 pub agent_token_budget: usize,
518 #[serde(default = "default_shell_allowlist")]
523 pub shell_allowlist: Vec<String>,
524
525 #[serde(default)]
529 pub shell_strict_mode: bool,
530}
531
532#[derive(Debug, Clone, Serialize, Deserialize)]
533#[serde(default)]
534pub struct SecretDetectionConfig {
535 pub enabled: bool,
536 pub redact: bool,
537 pub custom_patterns: Vec<String>,
538}
539
540impl Default for SecretDetectionConfig {
541 fn default() -> Self {
542 Self {
543 enabled: true,
544 redact: true,
545 custom_patterns: Vec::new(),
546 }
547 }
548}
549
550#[derive(Debug, Clone, Serialize, Deserialize)]
552#[serde(default)]
553pub struct ArchiveConfig {
554 pub enabled: bool,
555 pub threshold_chars: usize,
556 pub max_age_hours: u64,
557 pub max_disk_mb: u64,
558 pub ephemeral: bool,
559}
560
561impl Default for ArchiveConfig {
562 fn default() -> Self {
563 Self {
564 enabled: true,
565 threshold_chars: 800,
566 max_age_hours: 48,
567 max_disk_mb: 500,
568 ephemeral: true,
569 }
570 }
571}
572
573impl ArchiveConfig {
574 pub fn ephemeral_effective(&self) -> bool {
575 if let Ok(v) = std::env::var("LEAN_CTX_EPHEMERAL") {
576 return !matches!(v.trim(), "0" | "false" | "off");
577 }
578 self.ephemeral && self.enabled
579 }
580}
581
582#[derive(Debug, Clone, Serialize, Deserialize)]
586#[serde(default)]
587pub struct ProvidersConfig {
588 pub enabled: bool,
590 pub github: ProviderEntryConfig,
592 pub gitlab: ProviderEntryConfig,
594 pub auto_index: bool,
596 pub cache_ttl_secs: u64,
598 #[serde(default)]
600 pub mcp_bridges: std::collections::HashMap<String, McpBridgeEntry>,
601}
602
603impl Default for ProvidersConfig {
604 fn default() -> Self {
605 Self {
606 enabled: true,
607 github: ProviderEntryConfig::default(),
608 gitlab: ProviderEntryConfig::default(),
609 auto_index: true,
610 cache_ttl_secs: 120,
611 mcp_bridges: std::collections::HashMap::new(),
612 }
613 }
614}
615
616#[derive(Debug, Clone, Serialize, Deserialize)]
617pub struct McpBridgeEntry {
618 #[serde(default)]
620 pub url: Option<String>,
621 #[serde(default)]
623 pub command: Option<String>,
624 #[serde(default)]
626 pub args: Vec<String>,
627 #[serde(default)]
629 pub description: Option<String>,
630 #[serde(default)]
632 pub auth_env: Option<String>,
633}
634
635#[derive(Debug, Clone, Serialize, Deserialize)]
637#[serde(default)]
638pub struct ProviderEntryConfig {
639 pub enabled: bool,
641 pub token: Option<String>,
643 pub api_url: Option<String>,
645 pub project: Option<String>,
647}
648
649impl Default for ProviderEntryConfig {
650 fn default() -> Self {
651 Self {
652 enabled: true,
653 token: None,
654 api_url: None,
655 project: None,
656 }
657 }
658}
659
660#[derive(Debug, Clone, Serialize, Deserialize)]
662#[serde(default)]
663pub struct AutonomyConfig {
664 pub enabled: bool,
665 pub auto_preload: bool,
666 pub auto_dedup: bool,
667 pub auto_related: bool,
668 pub auto_consolidate: bool,
669 pub silent_preload: bool,
670 pub dedup_threshold: usize,
671 pub consolidate_every_calls: u32,
672 pub consolidate_cooldown_secs: u64,
673 #[serde(default = "serde_defaults::default_true")]
674 pub cognition_loop_enabled: bool,
675 #[serde(default = "serde_defaults::default_cognition_loop_interval")]
676 pub cognition_loop_interval_secs: u64,
677 #[serde(default = "serde_defaults::default_cognition_loop_max_steps")]
678 pub cognition_loop_max_steps: u8,
679}
680
681impl Default for AutonomyConfig {
682 fn default() -> Self {
683 Self {
684 enabled: true,
685 auto_preload: true,
686 auto_dedup: true,
687 auto_related: true,
688 auto_consolidate: true,
689 silent_preload: true,
690 dedup_threshold: 8,
691 consolidate_every_calls: 25,
692 consolidate_cooldown_secs: 120,
693 cognition_loop_enabled: true,
694 cognition_loop_interval_secs: 3600,
695 cognition_loop_max_steps: 8,
696 }
697 }
698}
699
700#[derive(Debug, Clone, Serialize, Deserialize)]
703#[serde(default)]
704pub struct UpdatesConfig {
705 pub auto_update: bool,
706 pub check_interval_hours: u64,
707 pub notify_only: bool,
708}
709
710impl Default for UpdatesConfig {
711 fn default() -> Self {
712 Self {
713 auto_update: false,
714 check_interval_hours: 6,
715 notify_only: false,
716 }
717 }
718}
719
720impl UpdatesConfig {
721 pub fn from_env() -> Self {
722 let mut cfg = Self::default();
723 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_UPDATE") {
724 cfg.auto_update = v == "1" || v.eq_ignore_ascii_case("true");
725 }
726 if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_INTERVAL_HOURS") {
727 if let Ok(h) = v.parse::<u64>() {
728 cfg.check_interval_hours = h.clamp(1, 168);
729 }
730 }
731 if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_NOTIFY_ONLY") {
732 cfg.notify_only = v == "1" || v.eq_ignore_ascii_case("true");
733 }
734 cfg
735 }
736}
737
738impl AutonomyConfig {
739 pub fn from_env() -> Self {
741 let mut cfg = Self::default();
742 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
743 if v == "false" || v == "0" {
744 cfg.enabled = false;
745 }
746 }
747 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
748 cfg.auto_preload = v != "false" && v != "0";
749 }
750 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
751 cfg.auto_dedup = v != "false" && v != "0";
752 }
753 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
754 cfg.auto_related = v != "false" && v != "0";
755 }
756 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
757 cfg.auto_consolidate = v != "false" && v != "0";
758 }
759 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
760 cfg.silent_preload = v != "false" && v != "0";
761 }
762 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
763 if let Ok(n) = v.parse() {
764 cfg.dedup_threshold = n;
765 }
766 }
767 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
768 if let Ok(n) = v.parse() {
769 cfg.consolidate_every_calls = n;
770 }
771 }
772 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
773 if let Ok(n) = v.parse() {
774 cfg.consolidate_cooldown_secs = n;
775 }
776 }
777 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
778 cfg.cognition_loop_enabled = v != "false" && v != "0";
779 }
780 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
781 if let Ok(n) = v.parse() {
782 cfg.cognition_loop_interval_secs = n;
783 }
784 }
785 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
786 if let Ok(n) = v.parse() {
787 cfg.cognition_loop_max_steps = n;
788 }
789 }
790 cfg
791 }
792
793 pub fn load() -> Self {
795 let file_cfg = Config::load().autonomy;
796 let mut cfg = file_cfg;
797 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
798 if v == "false" || v == "0" {
799 cfg.enabled = false;
800 }
801 }
802 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
803 cfg.auto_preload = v != "false" && v != "0";
804 }
805 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
806 cfg.auto_dedup = v != "false" && v != "0";
807 }
808 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
809 cfg.auto_related = v != "false" && v != "0";
810 }
811 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
812 cfg.silent_preload = v != "false" && v != "0";
813 }
814 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
815 if let Ok(n) = v.parse() {
816 cfg.dedup_threshold = n;
817 }
818 }
819 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
820 cfg.cognition_loop_enabled = v != "false" && v != "0";
821 }
822 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
823 if let Ok(n) = v.parse() {
824 cfg.cognition_loop_interval_secs = n;
825 }
826 }
827 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
828 if let Ok(n) = v.parse() {
829 cfg.cognition_loop_max_steps = n;
830 }
831 }
832 cfg
833 }
834}
835
836#[derive(Debug, Clone, Serialize, Deserialize, Default)]
838#[serde(default)]
839pub struct CloudConfig {
840 pub contribute_enabled: bool,
841 pub last_contribute: Option<String>,
842 pub last_sync: Option<String>,
843 pub last_gain_sync: Option<String>,
844 pub last_model_pull: Option<String>,
845}
846
847#[derive(Debug, Clone, Serialize, Deserialize)]
849pub struct AliasEntry {
850 pub command: String,
851 pub alias: String,
852}
853
854#[derive(Debug, Clone, Serialize, Deserialize)]
856#[serde(default)]
857pub struct LoopDetectionConfig {
858 pub normal_threshold: u32,
859 pub reduced_threshold: u32,
860 pub blocked_threshold: u32,
861 pub window_secs: u64,
862 pub search_group_limit: u32,
863 pub tool_total_limits: HashMap<String, u32>,
864}
865
866impl Default for LoopDetectionConfig {
867 fn default() -> Self {
868 let mut tool_total_limits = HashMap::new();
869 tool_total_limits.insert("ctx_read".to_string(), 100);
870 tool_total_limits.insert("ctx_search".to_string(), 80);
871 tool_total_limits.insert("ctx_shell".to_string(), 50);
872 tool_total_limits.insert("ctx_semantic_search".to_string(), 60);
873 Self {
874 normal_threshold: 2,
875 reduced_threshold: 4,
876 blocked_threshold: 0,
877 window_secs: 300,
878 search_group_limit: 10,
879 tool_total_limits,
880 }
881 }
882}
883
884impl Default for Config {
885 fn default() -> Self {
886 Self {
887 ultra_compact: false,
888 tee_mode: TeeMode::default(),
889 output_density: OutputDensity::default(),
890 checkpoint_interval: 15,
891 excluded_commands: Vec::new(),
892 passthrough_urls: Vec::new(),
893 custom_aliases: Vec::new(),
894 slow_command_threshold_ms: 5000,
895 theme: serde_defaults::default_theme(),
896 cloud: CloudConfig::default(),
897 autonomy: AutonomyConfig::default(),
898 providers: ProvidersConfig::default(),
899 proxy: ProxyConfig::default(),
900 proxy_enabled: None,
901 proxy_port: None,
902 proxy_timeout_ms: None,
903 buddy_enabled: serde_defaults::default_buddy_enabled(),
904 enable_wakeup_ctx: true,
905 redirect_exclude: Vec::new(),
906 disabled_tools: Vec::new(),
907 default_tool_categories: Vec::new(),
908 no_degrade: false,
909 profile: None,
910 loop_detection: LoopDetectionConfig::default(),
911 rules_scope: None,
912 extra_ignore_patterns: Vec::new(),
913 terse_agent: TerseAgent::default(),
914 compression_level: CompressionLevel::default(),
915 archive: ArchiveConfig::default(),
916 memory: MemoryPolicy::default(),
917 allow_paths: Vec::new(),
918 extra_roots: Vec::new(),
919 content_defined_chunking: false,
920 minimal_overhead: true,
921 symbol_map_auto: true,
922 journal_enabled: true,
923 auto_capture: true,
924 search: crate::core::hybrid_search::HybridConfig::default(),
925 llm: crate::core::llm_enhance::LlmConfig::default(),
926 shell_hook_disabled: false,
927 shell_activation: ShellActivation::default(),
928 update_check_disabled: false,
929 updates: UpdatesConfig::default(),
930 graph_index_max_files: serde_defaults::default_graph_index_max_files(),
931 bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
932 memory_profile: MemoryProfile::default(),
933 memory_cleanup: MemoryCleanup::default(),
934 max_ram_percent: serde_defaults::default_max_ram_percent(),
935 max_disk_mb: 0,
936 max_staleness_days: 0,
937 savings_footer: SavingsFooter::default(),
938 project_root: None,
939 lsp: std::collections::HashMap::new(),
940 ide_paths: HashMap::new(),
941 model_context_windows: HashMap::new(),
942 response_verbosity: ResponseVerbosity::default(),
943 bypass_hints: None,
944 cache_policy: None,
945 boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
946 secret_detection: SecretDetectionConfig::default(),
947 allow_auto_reroot: false,
948 path_jail: None,
949 sandbox_level: 0,
950 reference_results: false,
951 agent_token_budget: 0,
952 shell_allowlist: default_shell_allowlist(),
953 shell_strict_mode: false,
954 }
955 }
956}
957
958pub(crate) fn default_shell_allowlist() -> Vec<String> {
959 [
960 "git",
962 "gh",
963 "svn",
964 "hg",
965 "cargo",
967 "npm",
968 "npx",
969 "yarn",
970 "pnpm",
971 "bun",
972 "bunx",
973 "make",
974 "cmake",
975 "pip",
976 "pip3",
977 "poetry",
978 "uv",
979 "go",
980 "mvn",
981 "gradle",
982 "mix",
983 "dotnet",
984 "swift",
985 "zig",
986 "rustup",
987 "rustc",
988 "deno",
989 "bazel",
990 "pipenv",
992 "conda",
993 "mamba",
994 "brew",
995 "apt",
996 "apt-get",
997 "apk",
998 "nix",
999 "ls",
1001 "cat",
1002 "head",
1003 "tail",
1004 "wc",
1005 "sort",
1006 "uniq",
1007 "tr",
1008 "cut",
1009 "grep",
1010 "rg",
1011 "find",
1012 "fd",
1013 "ag",
1014 "ack",
1015 "sed",
1016 "awk",
1017 "echo",
1018 "printf",
1019 "true",
1020 "false",
1021 "test",
1022 "expr",
1023 "cd",
1024 "pwd",
1025 "basename",
1026 "dirname",
1027 "realpath",
1028 "readlink",
1029 "cp",
1030 "mv",
1031 "mkdir",
1032 "rm",
1033 "rmdir",
1034 "touch",
1035 "ln",
1036 "chmod",
1037 "chown",
1038 "diff",
1039 "patch",
1040 "tar",
1041 "zip",
1042 "unzip",
1043 "gzip",
1044 "gunzip",
1045 "zstd",
1046 "curl",
1047 "wget",
1048 "tree",
1049 "du",
1050 "df",
1051 "ps",
1052 "lsof",
1053 "watch",
1054 "tee",
1055 "less",
1056 "more",
1057 "id",
1058 "whoami",
1059 "uname",
1060 "hostname",
1061 "node",
1065 "python",
1066 "python3",
1067 "ruby",
1068 "perl",
1069 "java",
1070 "javac",
1071 "tsc",
1072 "eslint",
1073 "prettier",
1074 "black",
1075 "ruff",
1076 "clippy",
1077 "jq",
1078 "yq",
1079 "which",
1080 "type",
1081 "file",
1082 "stat",
1083 "date",
1084 "sleep",
1085 "timeout",
1086 "nice",
1087 "ionice",
1088 "pytest",
1090 "py.test",
1091 "jest",
1092 "vitest",
1093 "mocha",
1094 "cypress",
1095 "playwright",
1096 "puppeteer",
1097 "pre-commit",
1099 "husky",
1100 "lint-staged",
1101 "lefthook",
1102 "overcommit",
1103 "commitlint",
1104 "mypy",
1106 "pyright",
1107 "pylint",
1108 "flake8",
1109 "bandit",
1110 "isort",
1111 "autopep8",
1112 "yapf",
1113 "golangci-lint",
1114 "shellcheck",
1115 "markdownlint",
1116 "stylelint",
1117 "webpack",
1119 "vite",
1120 "esbuild",
1121 "rollup",
1122 "turbo",
1123 "nx",
1124 "lerna",
1125 "next",
1126 "nuxt",
1127 "bundle",
1129 "bundler",
1130 "rake",
1131 "rails",
1132 "rspec",
1133 "rubocop",
1134 "php",
1136 "composer",
1137 "phpunit",
1138 "artisan",
1139 "flutter",
1141 "dart",
1142 "xcodebuild",
1143 "xcrun",
1144 "pod",
1145 "fastlane",
1146 "terraform",
1148 "ansible",
1149 "kubectl",
1150 "helm",
1151 "az",
1152 "aws",
1153 "gcloud",
1154 "firebase",
1155 "heroku",
1156 "vercel",
1157 "netlify",
1158 "fly",
1159 "wrangler",
1160 "pulumi",
1161 "psql",
1163 "mysql",
1164 "sqlite3",
1165 "mongosh",
1166 "redis-cli",
1167 "pg_dump",
1168 "pg_restore",
1169 "mysqldump",
1170 "scala",
1172 "sbt",
1173 "kotlin",
1174 "kotlinc",
1175 "elixir",
1177 "iex",
1178 "lean-ctx",
1180 ]
1181 .iter()
1182 .map(|s| (*s).to_string())
1183 .collect()
1184}
1185
1186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1188pub enum RulesScope {
1189 Both,
1190 Global,
1191 Project,
1192}
1193
1194impl Config {
1195 pub fn rules_scope_effective(&self) -> RulesScope {
1197 let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
1198 .ok()
1199 .or_else(|| self.rules_scope.clone())
1200 .unwrap_or_default();
1201 match raw.trim().to_lowercase().as_str() {
1202 "global" => RulesScope::Global,
1203 "project" => RulesScope::Project,
1204 _ => RulesScope::Both,
1205 }
1206 }
1207
1208 fn parse_disabled_tools_env(val: &str) -> Vec<String> {
1209 val.split(',')
1210 .map(|s| s.trim().to_string())
1211 .filter(|s| !s.is_empty())
1212 .collect()
1213 }
1214
1215 pub fn disabled_tools_effective(&self) -> Vec<String> {
1217 if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
1218 Self::parse_disabled_tools_env(&val)
1219 } else {
1220 self.disabled_tools.clone()
1221 }
1222 }
1223
1224 pub fn minimal_overhead_effective(&self) -> bool {
1226 std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
1227 }
1228
1229 pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
1237 if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
1238 match raw.trim().to_lowercase().as_str() {
1239 "minimal" => return true,
1240 "full" => return self.minimal_overhead_effective(),
1241 _ => {}
1242 }
1243 }
1244
1245 if self.minimal_overhead_effective() {
1246 return true;
1247 }
1248
1249 let client_lower = client_name.trim().to_lowercase();
1250 if !client_lower.is_empty() {
1251 if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
1252 for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
1253 if !needle.is_empty() && client_lower.contains(&needle) {
1254 return true;
1255 }
1256 }
1257 } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
1258 return true;
1259 }
1260 }
1261
1262 let model = std::env::var("LEAN_CTX_MODEL")
1263 .or_else(|_| std::env::var("LCTX_MODEL"))
1264 .unwrap_or_default();
1265 let model = model.trim().to_lowercase();
1266 if !model.is_empty() {
1267 let m = model.replace(['_', ' '], "-");
1268 if m.contains("minimax")
1269 || m.contains("mini-max")
1270 || m.contains("m2.7")
1271 || m.contains("m2-7")
1272 {
1273 return true;
1274 }
1275 }
1276
1277 false
1278 }
1279
1280 pub fn shell_hook_disabled_effective(&self) -> bool {
1282 std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
1283 }
1284
1285 pub fn shell_activation_effective(&self) -> ShellActivation {
1287 ShellActivation::effective(self)
1288 }
1289
1290 pub fn update_check_disabled_effective(&self) -> bool {
1292 std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
1293 }
1294
1295 pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
1296 let mut policy = self.memory.clone();
1297 policy.apply_env_overrides();
1298
1299 let budget = self.max_disk_mb_effective();
1302 if budget > 0 {
1303 let scale_factor = (budget as f64 / 500.0).clamp(0.5, 10.0);
1304 let default_policy = MemoryPolicy::default();
1305 if policy.knowledge.max_facts == default_policy.knowledge.max_facts {
1306 policy.knowledge.max_facts = (200.0 * scale_factor) as usize;
1307 }
1308 if policy.knowledge.max_patterns == default_policy.knowledge.max_patterns {
1309 policy.knowledge.max_patterns = (50.0 * scale_factor) as usize;
1310 }
1311 if policy.episodic.max_episodes == default_policy.episodic.max_episodes {
1312 policy.episodic.max_episodes = (500.0 * scale_factor) as usize;
1313 }
1314 if policy.procedural.max_procedures == default_policy.procedural.max_procedures {
1315 policy.procedural.max_procedures = (100.0 * scale_factor) as usize;
1316 }
1317 }
1318
1319 policy.validate()?;
1320 Ok(policy)
1321 }
1322
1323 pub fn default_tool_categories_effective(&self) -> Vec<String> {
1326 if let Ok(val) = std::env::var("LCTX_DEFAULT_CATEGORIES") {
1327 return val
1328 .split(',')
1329 .map(|s| s.trim().to_lowercase())
1330 .filter(|s| !s.is_empty())
1331 .collect();
1332 }
1333 if !self.default_tool_categories.is_empty() {
1334 return self
1335 .default_tool_categories
1336 .iter()
1337 .map(|s| s.to_lowercase())
1338 .collect();
1339 }
1340 vec!["core".to_string(), "session".to_string()]
1341 }
1342
1343 pub fn no_degrade_effective(&self) -> bool {
1346 if let Ok(val) = std::env::var("LCTX_NO_DEGRADE") {
1347 return val == "1" || val.eq_ignore_ascii_case("true");
1348 }
1349 self.no_degrade
1350 }
1351
1352 pub fn max_disk_mb_effective(&self) -> u64 {
1354 std::env::var("LEAN_CTX_MAX_DISK_MB")
1355 .ok()
1356 .and_then(|v| v.parse().ok())
1357 .unwrap_or(self.max_disk_mb)
1358 }
1359
1360 pub fn max_staleness_days_effective(&self) -> u32 {
1362 std::env::var("LEAN_CTX_MAX_STALENESS_DAYS")
1363 .ok()
1364 .and_then(|v| v.parse().ok())
1365 .unwrap_or(self.max_staleness_days)
1366 }
1367
1368 pub fn archive_max_disk_mb_effective(&self) -> u64 {
1371 let budget = self.max_disk_mb_effective();
1372 if budget > 0 && self.archive.max_disk_mb == ArchiveConfig::default().max_disk_mb {
1373 budget * 25 / 100
1374 } else {
1375 self.archive.max_disk_mb
1376 }
1377 }
1378
1379 pub fn archive_max_age_hours_effective(&self) -> u64 {
1382 let staleness = self.max_staleness_days_effective();
1383 if staleness > 0 && self.archive.max_age_hours == ArchiveConfig::default().max_age_hours {
1384 staleness as u64 * 24
1385 } else {
1386 self.archive.max_age_hours
1387 }
1388 }
1389
1390 pub fn bm25_max_cache_mb_effective(&self) -> u64 {
1393 let budget = self.max_disk_mb_effective();
1394 if budget > 0 && self.bm25_max_cache_mb == serde_defaults::default_bm25_max_cache_mb() {
1395 budget * 10 / 100
1396 } else {
1397 let profile = MemoryProfile::effective(self);
1398 if self.bm25_max_cache_mb == serde_defaults::default_bm25_max_cache_mb() {
1399 profile.bm25_max_cache_mb()
1400 } else {
1401 self.bm25_max_cache_mb
1402 }
1403 }
1404 }
1405}
1406
1407#[cfg(test)]
1408mod disabled_tools_tests {
1409 use super::*;
1410
1411 #[test]
1412 fn config_field_default_is_empty() {
1413 let cfg = Config::default();
1414 assert!(cfg.disabled_tools.is_empty());
1415 }
1416
1417 #[test]
1418 fn effective_returns_config_field_when_no_env_var() {
1419 if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
1421 return;
1422 }
1423 let cfg = Config {
1424 disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
1425 ..Default::default()
1426 };
1427 assert_eq!(
1428 cfg.disabled_tools_effective(),
1429 vec!["ctx_graph", "ctx_agent"]
1430 );
1431 }
1432
1433 #[test]
1434 fn parse_env_basic() {
1435 let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
1436 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
1437 }
1438
1439 #[test]
1440 fn parse_env_trims_whitespace_and_skips_empty() {
1441 let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
1442 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
1443 }
1444
1445 #[test]
1446 fn parse_env_single_entry() {
1447 let result = Config::parse_disabled_tools_env("ctx_graph");
1448 assert_eq!(result, vec!["ctx_graph"]);
1449 }
1450
1451 #[test]
1452 fn parse_env_empty_string_returns_empty() {
1453 let result = Config::parse_disabled_tools_env("");
1454 assert!(result.is_empty());
1455 }
1456
1457 #[test]
1458 fn disabled_tools_deserialization_defaults_to_empty() {
1459 let cfg: Config = toml::from_str("").unwrap();
1460 assert!(cfg.disabled_tools.is_empty());
1461 }
1462
1463 #[test]
1464 fn disabled_tools_deserialization_from_toml() {
1465 let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
1466 assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
1467 }
1468}
1469
1470#[cfg(test)]
1471mod default_tool_categories_tests {
1472 use super::*;
1473
1474 #[test]
1477 fn default_returns_core_and_session() {
1478 if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1479 return;
1480 }
1481 let cfg = Config::default();
1482 assert_eq!(
1483 cfg.default_tool_categories_effective(),
1484 vec!["core", "session"]
1485 );
1486 }
1487
1488 #[test]
1489 fn default_struct_field_is_empty_vec() {
1490 let cfg = Config::default();
1491 assert!(cfg.default_tool_categories.is_empty());
1492 }
1493
1494 #[test]
1497 fn config_field_overrides_default() {
1498 if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1499 return;
1500 }
1501 let cfg = Config {
1502 default_tool_categories: vec![
1503 "core".to_string(),
1504 "arch".to_string(),
1505 "memory".to_string(),
1506 ],
1507 ..Default::default()
1508 };
1509 assert_eq!(
1510 cfg.default_tool_categories_effective(),
1511 vec!["core", "arch", "memory"]
1512 );
1513 }
1514
1515 #[test]
1516 fn single_category_in_config() {
1517 if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1518 return;
1519 }
1520 let cfg = Config {
1521 default_tool_categories: vec!["debug".to_string()],
1522 ..Default::default()
1523 };
1524 assert_eq!(cfg.default_tool_categories_effective(), vec!["debug"]);
1525 }
1526
1527 #[test]
1528 fn all_six_categories_in_config() {
1529 if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1530 return;
1531 }
1532 let cfg = Config {
1533 default_tool_categories: vec![
1534 "core".to_string(),
1535 "arch".to_string(),
1536 "debug".to_string(),
1537 "memory".to_string(),
1538 "metrics".to_string(),
1539 "session".to_string(),
1540 ],
1541 ..Default::default()
1542 };
1543 let effective = cfg.default_tool_categories_effective();
1544 assert_eq!(effective.len(), 6);
1545 assert!(effective.contains(&"core".to_string()));
1546 assert!(effective.contains(&"metrics".to_string()));
1547 }
1548
1549 #[test]
1552 fn deserialization_defaults_to_empty() {
1553 let cfg: Config = toml::from_str("").unwrap();
1554 assert!(cfg.default_tool_categories.is_empty());
1555 }
1556
1557 #[test]
1558 fn deserialization_from_toml() {
1559 let cfg: Config =
1560 toml::from_str(r#"default_tool_categories = ["core", "arch", "debug"]"#).unwrap();
1561 assert_eq!(cfg.default_tool_categories, vec!["core", "arch", "debug"]);
1562 }
1563
1564 #[test]
1565 fn deserialization_empty_array() {
1566 let cfg: Config = toml::from_str(r"default_tool_categories = []").unwrap();
1567 assert!(cfg.default_tool_categories.is_empty());
1568 }
1569
1570 #[test]
1571 fn deserialization_single_entry() {
1572 let cfg: Config = toml::from_str(r#"default_tool_categories = ["memory"]"#).unwrap();
1573 assert_eq!(cfg.default_tool_categories, vec!["memory"]);
1574 }
1575
1576 #[test]
1579 fn effective_normalizes_config_to_lowercase() {
1580 if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1581 return;
1582 }
1583 let cfg = Config {
1584 default_tool_categories: vec!["ARCH".to_string(), "Debug".to_string()],
1585 ..Default::default()
1586 };
1587 let effective = cfg.default_tool_categories_effective();
1588 assert_eq!(effective, vec!["arch", "debug"]);
1589 }
1590}
1591
1592#[cfg(test)]
1593mod no_degrade_tests {
1594 use super::*;
1595
1596 #[test]
1599 fn default_is_false() {
1600 let cfg = Config::default();
1601 assert!(!cfg.no_degrade);
1602 }
1603
1604 #[test]
1605 fn effective_false_when_unset() {
1606 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1607 return;
1608 }
1609 let cfg = Config::default();
1610 assert!(!cfg.no_degrade_effective());
1611 }
1612
1613 #[test]
1616 fn config_field_true_respected_when_no_env() {
1617 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1618 return;
1619 }
1620 let cfg = Config {
1621 no_degrade: true,
1622 ..Default::default()
1623 };
1624 assert!(cfg.no_degrade_effective());
1625 }
1626
1627 #[test]
1628 fn config_field_false_respected_when_no_env() {
1629 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1630 return;
1631 }
1632 let cfg = Config {
1633 no_degrade: false,
1634 ..Default::default()
1635 };
1636 assert!(!cfg.no_degrade_effective());
1637 }
1638
1639 #[test]
1642 fn deserialization_true() {
1643 let cfg: Config = toml::from_str("no_degrade = true").unwrap();
1644 assert!(cfg.no_degrade);
1645 }
1646
1647 #[test]
1648 fn deserialization_false() {
1649 let cfg: Config = toml::from_str("no_degrade = false").unwrap();
1650 assert!(!cfg.no_degrade);
1651 }
1652
1653 #[test]
1654 fn deserialization_absent_defaults_false() {
1655 let cfg: Config = toml::from_str("").unwrap();
1656 assert!(!cfg.no_degrade);
1657 }
1658
1659 #[test]
1662 fn no_degrade_independent_of_disabled_tools() {
1663 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1664 return;
1665 }
1666 let cfg = Config {
1667 no_degrade: true,
1668 disabled_tools: vec!["ctx_graph".to_string()],
1669 ..Default::default()
1670 };
1671 assert!(cfg.no_degrade_effective());
1672 assert!(!cfg.disabled_tools.is_empty());
1673 }
1674
1675 #[test]
1676 fn no_degrade_independent_of_tool_categories() {
1677 if std::env::var("LCTX_NO_DEGRADE").is_ok()
1678 || std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok()
1679 {
1680 return;
1681 }
1682 let cfg = Config {
1683 no_degrade: true,
1684 default_tool_categories: vec!["core".to_string(), "arch".to_string()],
1685 ..Default::default()
1686 };
1687 assert!(cfg.no_degrade_effective());
1688 assert_eq!(
1689 cfg.default_tool_categories_effective(),
1690 vec!["core", "arch"]
1691 );
1692 }
1693}
1694
1695#[cfg(test)]
1696mod rules_scope_tests {
1697 use super::*;
1698
1699 #[test]
1700 fn default_is_both() {
1701 let cfg = Config::default();
1702 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1703 }
1704
1705 #[test]
1706 fn config_global() {
1707 let cfg = Config {
1708 rules_scope: Some("global".to_string()),
1709 ..Default::default()
1710 };
1711 assert_eq!(cfg.rules_scope_effective(), RulesScope::Global);
1712 }
1713
1714 #[test]
1715 fn config_project() {
1716 let cfg = Config {
1717 rules_scope: Some("project".to_string()),
1718 ..Default::default()
1719 };
1720 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1721 }
1722
1723 #[test]
1724 fn unknown_value_falls_back_to_both() {
1725 let cfg = Config {
1726 rules_scope: Some("nonsense".to_string()),
1727 ..Default::default()
1728 };
1729 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1730 }
1731
1732 #[test]
1733 fn deserialization_none_by_default() {
1734 let cfg: Config = toml::from_str("").unwrap();
1735 assert!(cfg.rules_scope.is_none());
1736 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1737 }
1738
1739 #[test]
1740 fn deserialization_from_toml() {
1741 let cfg: Config = toml::from_str(r#"rules_scope = "project""#).unwrap();
1742 assert_eq!(cfg.rules_scope.as_deref(), Some("project"));
1743 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1744 }
1745}
1746
1747#[cfg(test)]
1748mod loop_detection_config_tests {
1749 use super::*;
1750
1751 #[test]
1752 fn defaults_are_reasonable() {
1753 let cfg = LoopDetectionConfig::default();
1754 assert_eq!(cfg.normal_threshold, 2);
1755 assert_eq!(cfg.reduced_threshold, 4);
1756 assert_eq!(cfg.blocked_threshold, 0);
1758 assert_eq!(cfg.window_secs, 300);
1759 assert_eq!(cfg.search_group_limit, 10);
1760 }
1761
1762 #[test]
1763 fn deserialization_defaults_when_missing() {
1764 let cfg: Config = toml::from_str("").unwrap();
1765 assert_eq!(cfg.loop_detection.blocked_threshold, 0);
1767 assert_eq!(cfg.loop_detection.search_group_limit, 10);
1768 }
1769
1770 #[test]
1771 fn deserialization_from_toml() {
1772 let cfg: Config = toml::from_str(
1773 r"
1774 [loop_detection]
1775 normal_threshold = 1
1776 reduced_threshold = 3
1777 blocked_threshold = 5
1778 window_secs = 120
1779 search_group_limit = 8
1780 ",
1781 )
1782 .unwrap();
1783 assert_eq!(cfg.loop_detection.normal_threshold, 1);
1784 assert_eq!(cfg.loop_detection.reduced_threshold, 3);
1785 assert_eq!(cfg.loop_detection.blocked_threshold, 5);
1786 assert_eq!(cfg.loop_detection.window_secs, 120);
1787 assert_eq!(cfg.loop_detection.search_group_limit, 8);
1788 }
1789
1790 #[test]
1791 fn partial_override_keeps_defaults() {
1792 let cfg: Config = toml::from_str(
1793 r"
1794 [loop_detection]
1795 blocked_threshold = 10
1796 ",
1797 )
1798 .unwrap();
1799 assert_eq!(cfg.loop_detection.blocked_threshold, 10);
1800 assert_eq!(cfg.loop_detection.normal_threshold, 2);
1801 assert_eq!(cfg.loop_detection.search_group_limit, 10);
1802 }
1803}
1804
1805impl Config {
1806 pub fn path() -> Option<PathBuf> {
1808 crate::core::data_dir::lean_ctx_data_dir()
1809 .ok()
1810 .map(|d| d.join("config.toml"))
1811 }
1812
1813 pub fn local_path(project_root: &str) -> PathBuf {
1815 PathBuf::from(project_root).join(".lean-ctx.toml")
1816 }
1817
1818 fn find_project_root() -> Option<String> {
1819 static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
1820 ROOT_CACHE
1821 .get_or_init(Self::find_project_root_inner)
1822 .clone()
1823 }
1824
1825 fn find_project_root_inner() -> Option<String> {
1826 if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
1827 if !env_root.is_empty() {
1828 return Some(env_root);
1829 }
1830 }
1831
1832 let cwd = std::env::current_dir().ok();
1833
1834 if let Some(root) =
1835 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
1836 {
1837 let root_path = std::path::Path::new(&root);
1838 let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
1839 let has_marker = root_path.join(".git").exists()
1840 || root_path.join("Cargo.toml").exists()
1841 || root_path.join("package.json").exists()
1842 || root_path.join("go.mod").exists()
1843 || root_path.join("pyproject.toml").exists()
1844 || root_path.join(".lean-ctx.toml").exists();
1845
1846 if cwd_is_under_root || has_marker {
1847 return Some(root);
1848 }
1849 }
1850
1851 if let Some(ref cwd) = cwd {
1852 let git_root = std::process::Command::new("git")
1853 .args(["rev-parse", "--show-toplevel"])
1854 .current_dir(cwd)
1855 .stdout(std::process::Stdio::piped())
1856 .stderr(std::process::Stdio::null())
1857 .output()
1858 .ok()
1859 .and_then(|o| {
1860 if o.status.success() {
1861 String::from_utf8(o.stdout)
1862 .ok()
1863 .map(|s| s.trim().to_string())
1864 } else {
1865 None
1866 }
1867 });
1868 if let Some(root) = git_root {
1869 return Some(root);
1870 }
1871 if !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
1872 return Some(cwd.to_string_lossy().to_string());
1873 }
1874 }
1875 None
1876 }
1877
1878 pub fn load() -> Self {
1880 static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
1881
1882 let Some(path) = Self::path() else {
1883 return Self::default();
1884 };
1885
1886 let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
1887
1888 let mtime = std::fs::metadata(&path)
1889 .and_then(|m| m.modified())
1890 .unwrap_or(SystemTime::UNIX_EPOCH);
1891
1892 let local_mtime = local_path
1893 .as_ref()
1894 .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
1895
1896 if let Ok(guard) = CACHE.lock() {
1897 if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
1898 if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
1899 return cfg.clone();
1900 }
1901 }
1902 }
1903
1904 let mut cfg: Config = match std::fs::read_to_string(&path) {
1905 Ok(content) => match toml::from_str(&content) {
1906 Ok(c) => c,
1907 Err(e) => {
1908 tracing::warn!("config parse error in {}: {e}", path.display());
1909 eprintln!(
1910 "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n \
1911 Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
1912 path.display()
1913 );
1914 Self::default()
1915 }
1916 },
1917 Err(_) => Self::default(),
1918 };
1919
1920 if let Some(ref lp) = local_path {
1921 if let Ok(local_content) = std::fs::read_to_string(lp) {
1922 cfg.merge_local(&local_content);
1923 }
1924 }
1925
1926 if let Ok(mut guard) = CACHE.lock() {
1927 *guard = Some((cfg.clone(), mtime, local_mtime));
1928 }
1929
1930 cfg
1931 }
1932
1933 fn merge_local(&mut self, local_toml: &str) {
1934 let local: Config = match toml::from_str(local_toml) {
1935 Ok(c) => c,
1936 Err(e) => {
1937 tracing::warn!("local config parse error: {e}");
1938 eprintln!(
1939 "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n \
1940 Local overrides skipped.\x1b[0m"
1941 );
1942 return;
1943 }
1944 };
1945 if local.ultra_compact {
1946 self.ultra_compact = true;
1947 }
1948 if local.tee_mode != TeeMode::default() {
1949 self.tee_mode = local.tee_mode;
1950 }
1951 if local.output_density != OutputDensity::default() {
1952 self.output_density = local.output_density;
1953 }
1954 if local.checkpoint_interval != 15 {
1955 self.checkpoint_interval = local.checkpoint_interval;
1956 }
1957 if !local.excluded_commands.is_empty() {
1958 self.excluded_commands.extend(local.excluded_commands);
1959 }
1960 if !local.passthrough_urls.is_empty() {
1961 self.passthrough_urls.extend(local.passthrough_urls);
1962 }
1963 if !local.custom_aliases.is_empty() {
1964 self.custom_aliases.extend(local.custom_aliases);
1965 }
1966 if local.slow_command_threshold_ms != 5000 {
1967 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
1968 }
1969 if local.theme != "default" {
1970 self.theme = local.theme;
1971 }
1972 if !local.buddy_enabled {
1973 self.buddy_enabled = false;
1974 }
1975 if !local.enable_wakeup_ctx {
1976 self.enable_wakeup_ctx = false;
1977 }
1978 if !local.redirect_exclude.is_empty() {
1979 self.redirect_exclude.extend(local.redirect_exclude);
1980 }
1981 if !local.disabled_tools.is_empty() {
1982 self.disabled_tools.extend(local.disabled_tools);
1983 }
1984 if !local.extra_ignore_patterns.is_empty() {
1985 self.extra_ignore_patterns
1986 .extend(local.extra_ignore_patterns);
1987 }
1988 if local.rules_scope.is_some() {
1989 self.rules_scope = local.rules_scope;
1990 }
1991 if local.proxy.anthropic_upstream.is_some() {
1992 self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
1993 }
1994 if local.proxy.openai_upstream.is_some() {
1995 self.proxy.openai_upstream = local.proxy.openai_upstream;
1996 }
1997 if local.proxy.gemini_upstream.is_some() {
1998 self.proxy.gemini_upstream = local.proxy.gemini_upstream;
1999 }
2000 if !local.autonomy.enabled {
2001 self.autonomy.enabled = false;
2002 }
2003 if !local.autonomy.auto_preload {
2004 self.autonomy.auto_preload = false;
2005 }
2006 if !local.autonomy.auto_dedup {
2007 self.autonomy.auto_dedup = false;
2008 }
2009 if !local.autonomy.auto_related {
2010 self.autonomy.auto_related = false;
2011 }
2012 if !local.autonomy.auto_consolidate {
2013 self.autonomy.auto_consolidate = false;
2014 }
2015 if local.autonomy.silent_preload {
2016 self.autonomy.silent_preload = true;
2017 }
2018 if !local.autonomy.silent_preload && self.autonomy.silent_preload {
2019 self.autonomy.silent_preload = false;
2020 }
2021 if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
2022 self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
2023 }
2024 if local.autonomy.consolidate_every_calls
2025 != AutonomyConfig::default().consolidate_every_calls
2026 {
2027 self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
2028 }
2029 if local.autonomy.consolidate_cooldown_secs
2030 != AutonomyConfig::default().consolidate_cooldown_secs
2031 {
2032 self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
2033 }
2034 if !local.autonomy.cognition_loop_enabled {
2035 self.autonomy.cognition_loop_enabled = false;
2036 }
2037 if local.autonomy.cognition_loop_interval_secs
2038 != AutonomyConfig::default().cognition_loop_interval_secs
2039 {
2040 self.autonomy.cognition_loop_interval_secs =
2041 local.autonomy.cognition_loop_interval_secs;
2042 }
2043 if local.autonomy.cognition_loop_max_steps
2044 != AutonomyConfig::default().cognition_loop_max_steps
2045 {
2046 self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
2047 }
2048 if local_toml.contains("compression_level") {
2049 self.compression_level = local.compression_level;
2050 }
2051 if local_toml.contains("terse_agent") {
2052 self.terse_agent = local.terse_agent;
2053 }
2054 if !local.archive.enabled {
2055 self.archive.enabled = false;
2056 }
2057 if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
2058 self.archive.threshold_chars = local.archive.threshold_chars;
2059 }
2060 if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
2061 self.archive.max_age_hours = local.archive.max_age_hours;
2062 }
2063 if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
2064 self.archive.max_disk_mb = local.archive.max_disk_mb;
2065 }
2066 if !local.archive.ephemeral {
2067 self.archive.ephemeral = false;
2068 }
2069 let mem_def = MemoryPolicy::default();
2070 if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
2071 self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
2072 }
2073 if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
2074 self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
2075 }
2076 if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
2077 self.memory.knowledge.max_history = local.memory.knowledge.max_history;
2078 }
2079 if local.memory.knowledge.contradiction_threshold
2080 != mem_def.knowledge.contradiction_threshold
2081 {
2082 self.memory.knowledge.contradiction_threshold =
2083 local.memory.knowledge.contradiction_threshold;
2084 }
2085
2086 if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
2087 self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
2088 }
2089 if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
2090 {
2091 self.memory.episodic.max_actions_per_episode =
2092 local.memory.episodic.max_actions_per_episode;
2093 }
2094 if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
2095 self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
2096 }
2097
2098 if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
2099 self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
2100 }
2101 if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
2102 self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
2103 }
2104 if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
2105 self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
2106 }
2107 if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
2108 self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
2109 }
2110
2111 if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
2112 self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
2113 }
2114 if local.memory.lifecycle.low_confidence_threshold
2115 != mem_def.lifecycle.low_confidence_threshold
2116 {
2117 self.memory.lifecycle.low_confidence_threshold =
2118 local.memory.lifecycle.low_confidence_threshold;
2119 }
2120 if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
2121 self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
2122 }
2123 if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
2124 self.memory.lifecycle.similarity_threshold =
2125 local.memory.lifecycle.similarity_threshold;
2126 }
2127
2128 if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
2129 self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
2130 }
2131 if !local.allow_paths.is_empty() {
2132 self.allow_paths.extend(local.allow_paths);
2133 }
2134 if !local.extra_roots.is_empty() {
2135 self.extra_roots.extend(local.extra_roots);
2136 }
2137 if local.minimal_overhead {
2138 self.minimal_overhead = true;
2139 }
2140 if local.shell_hook_disabled {
2141 self.shell_hook_disabled = true;
2142 }
2143 if local.shell_activation != ShellActivation::default() {
2144 self.shell_activation = local.shell_activation.clone();
2145 }
2146 if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
2147 self.bm25_max_cache_mb = local.bm25_max_cache_mb;
2148 }
2149 if local.memory_profile != MemoryProfile::default() {
2150 self.memory_profile = local.memory_profile;
2151 }
2152 if local.memory_cleanup != MemoryCleanup::default() {
2153 self.memory_cleanup = local.memory_cleanup;
2154 }
2155 if !local.shell_allowlist.is_empty() {
2156 self.shell_allowlist = local.shell_allowlist;
2157 }
2158 if !local.default_tool_categories.is_empty() {
2159 self.default_tool_categories = local.default_tool_categories;
2160 }
2161 if local.no_degrade {
2162 self.no_degrade = true;
2163 }
2164 if local.profile.is_some() {
2165 self.profile = local.profile;
2166 }
2167 if local.proxy_timeout_ms.is_some() {
2168 self.proxy_timeout_ms = local.proxy_timeout_ms;
2169 }
2170 }
2171
2172 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
2174 let path = Self::path().ok_or_else(|| {
2175 super::error::LeanCtxError::Config("cannot determine home directory".into())
2176 })?;
2177 if let Some(parent) = path.parent() {
2178 std::fs::create_dir_all(parent)?;
2179 }
2180 let content = toml::to_string_pretty(self)
2181 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
2182 std::fs::write(&path, content)?;
2183 Ok(())
2184 }
2185
2186 pub fn show(&self) -> String {
2188 let global_path = Self::path().map_or_else(
2189 || "~/.lean-ctx/config.toml".to_string(),
2190 |p| p.to_string_lossy().to_string(),
2191 );
2192 let content = toml::to_string_pretty(self).unwrap_or_default();
2193 let mut out = format!("Global config: {global_path}\n\n{content}");
2194
2195 if let Some(root) = Self::find_project_root() {
2196 let local = Self::local_path(&root);
2197 if local.exists() {
2198 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
2199 } else {
2200 out.push_str(&format!(
2201 "\n\nLocal config: not found (create {} to override per-project)\n",
2202 local.display()
2203 ));
2204 }
2205 }
2206 out
2207 }
2208}
2209
2210#[cfg(test)]
2211mod extra_roots_tests {
2212 use super::*;
2213
2214 #[test]
2215 fn default_is_empty() {
2216 let cfg = Config::default();
2217 assert!(cfg.extra_roots.is_empty());
2218 }
2219
2220 #[test]
2221 fn deserialization_from_toml() {
2222 let cfg: Config = toml::from_str(r#"extra_roots = ["/data/store", "/test/env"]"#).unwrap();
2223 assert_eq!(cfg.extra_roots, vec!["/data/store", "/test/env"]);
2224 }
2225
2226 #[test]
2227 fn merge_extends() {
2228 let mut base = Config {
2229 extra_roots: vec!["/base".to_string()],
2230 ..Config::default()
2231 };
2232 base.merge_local(r#"extra_roots = ["/local"]"#);
2233 assert_eq!(base.extra_roots, vec!["/base", "/local"]);
2234 }
2235}
2236
2237#[cfg(test)]
2238mod compression_level_tests {
2239 use super::*;
2240
2241 #[test]
2242 fn default_is_lite() {
2243 assert_eq!(CompressionLevel::default(), CompressionLevel::Lite);
2246 }
2247
2248 #[test]
2249 fn to_components_off() {
2250 let (ta, od, crp, tm) = CompressionLevel::Off.to_components();
2251 assert_eq!(ta, TerseAgent::Off);
2252 assert_eq!(od, OutputDensity::Normal);
2253 assert_eq!(crp, "off");
2254 assert!(!tm);
2255 }
2256
2257 #[test]
2258 fn to_components_lite() {
2259 let (ta, od, crp, tm) = CompressionLevel::Lite.to_components();
2260 assert_eq!(ta, TerseAgent::Lite);
2261 assert_eq!(od, OutputDensity::Terse);
2262 assert_eq!(crp, "off");
2263 assert!(tm);
2264 }
2265
2266 #[test]
2267 fn to_components_standard() {
2268 let (ta, od, crp, tm) = CompressionLevel::Standard.to_components();
2269 assert_eq!(ta, TerseAgent::Full);
2270 assert_eq!(od, OutputDensity::Terse);
2271 assert_eq!(crp, "compact");
2272 assert!(tm);
2273 }
2274
2275 #[test]
2276 fn to_components_max() {
2277 let (ta, od, crp, tm) = CompressionLevel::Max.to_components();
2278 assert_eq!(ta, TerseAgent::Ultra);
2279 assert_eq!(od, OutputDensity::Ultra);
2280 assert_eq!(crp, "tdd");
2281 assert!(tm);
2282 }
2283
2284 #[test]
2285 fn from_legacy_ultra_agent_maps_to_max() {
2286 assert_eq!(
2287 CompressionLevel::from_legacy(&TerseAgent::Ultra, &OutputDensity::Normal),
2288 CompressionLevel::Max
2289 );
2290 }
2291
2292 #[test]
2293 fn from_legacy_ultra_density_maps_to_max() {
2294 assert_eq!(
2295 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Ultra),
2296 CompressionLevel::Max
2297 );
2298 }
2299
2300 #[test]
2301 fn from_legacy_full_agent_maps_to_standard() {
2302 assert_eq!(
2303 CompressionLevel::from_legacy(&TerseAgent::Full, &OutputDensity::Normal),
2304 CompressionLevel::Standard
2305 );
2306 }
2307
2308 #[test]
2309 fn from_legacy_lite_agent_maps_to_lite() {
2310 assert_eq!(
2311 CompressionLevel::from_legacy(&TerseAgent::Lite, &OutputDensity::Normal),
2312 CompressionLevel::Lite
2313 );
2314 }
2315
2316 #[test]
2317 fn from_legacy_terse_density_maps_to_lite() {
2318 assert_eq!(
2319 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Terse),
2320 CompressionLevel::Lite
2321 );
2322 }
2323
2324 #[test]
2325 fn from_legacy_both_off_maps_to_off() {
2326 assert_eq!(
2327 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Normal),
2328 CompressionLevel::Off
2329 );
2330 }
2331
2332 #[test]
2333 fn labels_match() {
2334 assert_eq!(CompressionLevel::Off.label(), "off");
2335 assert_eq!(CompressionLevel::Lite.label(), "lite");
2336 assert_eq!(CompressionLevel::Standard.label(), "standard");
2337 assert_eq!(CompressionLevel::Max.label(), "max");
2338 }
2339
2340 #[test]
2341 fn is_active_false_for_off() {
2342 assert!(!CompressionLevel::Off.is_active());
2343 }
2344
2345 #[test]
2346 fn is_active_true_for_all_others() {
2347 assert!(CompressionLevel::Lite.is_active());
2348 assert!(CompressionLevel::Standard.is_active());
2349 assert!(CompressionLevel::Max.is_active());
2350 }
2351
2352 #[test]
2353 fn deserialization_defaults_to_lite() {
2354 let cfg: Config = toml::from_str("").unwrap();
2355 assert_eq!(cfg.compression_level, CompressionLevel::Lite);
2356 }
2357
2358 #[test]
2359 fn deserialization_from_toml() {
2360 let cfg: Config = toml::from_str(r#"compression_level = "standard""#).unwrap();
2361 assert_eq!(cfg.compression_level, CompressionLevel::Standard);
2362 }
2363
2364 #[test]
2365 fn roundtrip_all_levels() {
2366 for level in [
2367 CompressionLevel::Off,
2368 CompressionLevel::Lite,
2369 CompressionLevel::Standard,
2370 CompressionLevel::Max,
2371 ] {
2372 let (ta, od, crp, tm) = level.to_components();
2373 assert!(!crp.is_empty());
2374 if level == CompressionLevel::Off {
2375 assert!(!tm);
2376 assert_eq!(ta, TerseAgent::Off);
2377 assert_eq!(od, OutputDensity::Normal);
2378 } else {
2379 assert!(tm);
2380 }
2381 }
2382 }
2383}
2384
2385#[cfg(test)]
2386mod memory_cleanup_tests {
2387 use super::*;
2388
2389 #[test]
2390 fn default_is_aggressive() {
2391 assert_eq!(MemoryCleanup::default(), MemoryCleanup::Aggressive);
2392 }
2393
2394 #[test]
2395 fn aggressive_ttl_is_300() {
2396 assert_eq!(MemoryCleanup::Aggressive.idle_ttl_secs(), 300);
2397 }
2398
2399 #[test]
2400 fn shared_ttl_is_1800() {
2401 assert_eq!(MemoryCleanup::Shared.idle_ttl_secs(), 1800);
2402 }
2403
2404 #[test]
2405 fn index_retention_multiplier_values() {
2406 assert!(
2407 (MemoryCleanup::Aggressive.index_retention_multiplier() - 1.0).abs() < f64::EPSILON
2408 );
2409 assert!((MemoryCleanup::Shared.index_retention_multiplier() - 3.0).abs() < f64::EPSILON);
2410 }
2411
2412 #[test]
2413 fn deserialization_defaults_to_aggressive() {
2414 let cfg: Config = toml::from_str("").unwrap();
2415 assert_eq!(cfg.memory_cleanup, MemoryCleanup::Aggressive);
2416 }
2417
2418 #[test]
2419 fn deserialization_from_toml() {
2420 let cfg: Config = toml::from_str(r#"memory_cleanup = "shared""#).unwrap();
2421 assert_eq!(cfg.memory_cleanup, MemoryCleanup::Shared);
2422 }
2423
2424 #[test]
2425 fn effective_uses_config_when_no_env() {
2426 let cfg = Config {
2427 memory_cleanup: MemoryCleanup::Shared,
2428 ..Default::default()
2429 };
2430 let eff = MemoryCleanup::effective(&cfg);
2431 assert_eq!(eff, MemoryCleanup::Shared);
2432 }
2433}
2434
2435#[cfg(test)]
2436mod simplified_config_tests {
2437 use super::*;
2438
2439 #[test]
2440 fn max_disk_mb_zero_means_disabled() {
2441 let cfg = Config::default();
2442 assert_eq!(cfg.max_disk_mb, 0);
2443 assert_eq!(cfg.max_disk_mb_effective(), 0);
2444 }
2445
2446 #[test]
2447 fn archive_derives_from_disk_budget() {
2448 let cfg = Config {
2449 max_disk_mb: 4000,
2450 ..Default::default()
2451 };
2452 assert_eq!(cfg.archive_max_disk_mb_effective(), 1000);
2453 }
2454
2455 #[test]
2456 fn archive_explicit_overrides_derived() {
2457 let cfg = Config {
2458 max_disk_mb: 4000,
2459 archive: ArchiveConfig {
2460 max_disk_mb: 800,
2461 ..Default::default()
2462 },
2463 ..Default::default()
2464 };
2465 assert_eq!(cfg.archive_max_disk_mb_effective(), 800);
2466 }
2467
2468 #[test]
2469 fn bm25_derives_from_disk_budget() {
2470 let cfg = Config {
2471 max_disk_mb: 4000,
2472 ..Default::default()
2473 };
2474 assert_eq!(cfg.bm25_max_cache_mb_effective(), 400);
2475 }
2476
2477 #[test]
2478 fn bm25_explicit_overrides_derived() {
2479 let cfg = Config {
2480 max_disk_mb: 4000,
2481 bm25_max_cache_mb: 256,
2482 ..Default::default()
2483 };
2484 assert_eq!(cfg.bm25_max_cache_mb_effective(), 256);
2485 }
2486
2487 #[test]
2488 fn staleness_days_derives_archive_age() {
2489 let cfg = Config {
2490 max_staleness_days: 30,
2491 ..Default::default()
2492 };
2493 assert_eq!(cfg.archive_max_age_hours_effective(), 720);
2494 }
2495
2496 #[test]
2497 fn staleness_explicit_archive_age_overrides() {
2498 let cfg = Config {
2499 max_staleness_days: 30,
2500 archive: ArchiveConfig {
2501 max_age_hours: 96,
2502 ..Default::default()
2503 },
2504 ..Default::default()
2505 };
2506 assert_eq!(cfg.archive_max_age_hours_effective(), 96);
2507 }
2508
2509 #[test]
2510 fn no_budget_returns_defaults() {
2511 let cfg = Config::default();
2512 assert_eq!(
2513 cfg.archive_max_disk_mb_effective(),
2514 ArchiveConfig::default().max_disk_mb
2515 );
2516 assert_eq!(
2517 cfg.archive_max_age_hours_effective(),
2518 ArchiveConfig::default().max_age_hours
2519 );
2520 }
2521
2522 #[test]
2523 fn memory_limits_scale_with_disk_budget() {
2524 let cfg = Config {
2525 max_disk_mb: 2000,
2526 ..Default::default()
2527 };
2528 let policy = cfg.memory_policy_effective().unwrap();
2529 assert_eq!(policy.knowledge.max_facts, 800);
2531 assert_eq!(policy.knowledge.max_patterns, 200);
2532 assert_eq!(policy.episodic.max_episodes, 2000);
2533 assert_eq!(policy.procedural.max_procedures, 400);
2534 }
2535
2536 #[test]
2537 fn memory_limits_clamped_at_max_factor() {
2538 let cfg = Config {
2539 max_disk_mb: 50_000,
2540 ..Default::default()
2541 };
2542 let policy = cfg.memory_policy_effective().unwrap();
2543 assert_eq!(policy.knowledge.max_facts, 2000);
2545 assert_eq!(policy.episodic.max_episodes, 5000);
2546 }
2547
2548 #[test]
2549 fn memory_limits_unchanged_when_no_budget() {
2550 let cfg = Config::default();
2551 let policy = cfg.memory_policy_effective().unwrap();
2552 assert_eq!(policy.knowledge.max_facts, 200);
2553 assert_eq!(policy.episodic.max_episodes, 500);
2554 }
2555
2556 #[test]
2557 fn simplified_template_is_valid_toml() {
2558 let parsed: Result<toml::Table, _> = toml::from_str(crate::cli::SIMPLIFIED_TEMPLATE);
2559 assert!(parsed.is_ok(), "Template must be valid TOML");
2560 }
2561}