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