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