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