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