1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4use std::sync::Mutex;
5use std::time::SystemTime;
6
7use super::memory_policy::MemoryPolicy;
8
9mod memory;
10mod proxy;
11pub mod schema;
12mod serde_defaults;
13mod shell_activation;
14
15pub use memory::{MemoryCleanup, MemoryGuardConfig, MemoryProfile, SavingsFooter};
16pub use proxy::{is_local_proxy_url, normalize_url, normalize_url_opt, ProxyConfig, ProxyProvider};
17pub use shell_activation::ShellActivation;
18
19pub fn default_bm25_max_cache_mb() -> u64 {
21 serde_defaults::default_bm25_max_cache_mb()
22}
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
26#[serde(rename_all = "lowercase")]
27pub enum TeeMode {
28 Never,
29 #[default]
30 Failures,
31 Always,
32}
33
34#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
38#[serde(rename_all = "lowercase")]
39pub enum TerseAgent {
40 #[default]
41 Off,
42 Lite,
43 Full,
44 Ultra,
45}
46
47impl TerseAgent {
48 pub fn from_env() -> Self {
50 match std::env::var("LEAN_CTX_TERSE_AGENT")
51 .unwrap_or_default()
52 .to_lowercase()
53 .as_str()
54 {
55 "lite" => Self::Lite,
56 "full" => Self::Full,
57 "ultra" => Self::Ultra,
58 _ => Self::Off,
59 }
60 }
61}
62
63#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
67#[serde(rename_all = "lowercase")]
68pub enum OutputDensity {
69 #[default]
70 Normal,
71 Terse,
72 Ultra,
73}
74
75impl OutputDensity {
76 pub fn from_env() -> Self {
78 match std::env::var("LEAN_CTX_OUTPUT_DENSITY")
79 .unwrap_or_default()
80 .to_lowercase()
81 .as_str()
82 {
83 "terse" => Self::Terse,
84 "ultra" => Self::Ultra,
85 _ => Self::Normal,
86 }
87 }
88}
89
90#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
95#[serde(rename_all = "snake_case")]
96pub enum ResponseVerbosity {
97 #[default]
98 Full,
99 HeadersOnly,
100}
101
102impl ResponseVerbosity {
103 pub fn effective() -> Self {
104 if let Ok(v) = std::env::var("LEAN_CTX_RESPONSE_VERBOSITY") {
105 match v.trim().to_lowercase().as_str() {
106 "headers_only" | "headers" | "minimal" => return Self::HeadersOnly,
107 "full" | "" => return Self::Full,
108 _ => {}
109 }
110 }
111 Config::load().response_verbosity
112 }
113
114 pub fn is_headers_only(&self) -> bool {
115 matches!(self, Self::HeadersOnly)
116 }
117}
118
119#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
121#[serde(rename_all = "lowercase")]
122pub enum CompressionLevel {
123 Off,
124 Lite,
125 #[default]
126 Standard,
127 Max,
128}
129
130impl CompressionLevel {
131 pub fn to_components(&self) -> (TerseAgent, OutputDensity, &'static str, bool) {
134 match self {
135 Self::Off => (TerseAgent::Off, OutputDensity::Normal, "off", false),
136 Self::Lite => (TerseAgent::Lite, OutputDensity::Terse, "off", true),
137 Self::Standard => (TerseAgent::Full, OutputDensity::Terse, "compact", true),
138 Self::Max => (TerseAgent::Ultra, OutputDensity::Ultra, "tdd", true),
139 }
140 }
141
142 pub fn from_legacy(terse_agent: &TerseAgent, output_density: &OutputDensity) -> Self {
145 match (terse_agent, output_density) {
146 (TerseAgent::Ultra, _) | (_, OutputDensity::Ultra) => Self::Max,
147 (TerseAgent::Full, _) => Self::Standard,
148 (TerseAgent::Lite, _) | (_, OutputDensity::Terse) => Self::Lite,
149 _ => Self::Off,
150 }
151 }
152
153 pub fn from_env() -> Option<Self> {
155 std::env::var("LEAN_CTX_COMPRESSION").ok().and_then(|v| {
156 match v.trim().to_lowercase().as_str() {
157 "off" => Some(Self::Off),
158 "lite" => Some(Self::Lite),
159 "standard" => Some(Self::Standard),
160 "max" => Some(Self::Max),
161 _ => None,
162 }
163 })
164 }
165
166 pub fn effective(config: &Config) -> Self {
173 if let Some(env_level) = Self::from_env() {
174 return env_level;
175 }
176 if config.compression_level != Self::Off {
177 return config.compression_level.clone();
178 }
179 if config.ultra_compact {
180 return Self::Max;
181 }
182 let ta_env = TerseAgent::from_env();
183 let od_env = OutputDensity::from_env();
184 let ta = if ta_env == TerseAgent::Off {
185 config.terse_agent.clone()
186 } else {
187 ta_env
188 };
189 let od = if od_env == OutputDensity::Normal {
190 config.output_density.clone()
191 } else {
192 od_env
193 };
194 Self::from_legacy(&ta, &od)
195 }
196
197 pub fn from_str_label(s: &str) -> Option<Self> {
198 match s.trim().to_lowercase().as_str() {
199 "off" => Some(Self::Off),
200 "lite" => Some(Self::Lite),
201 "standard" | "std" => Some(Self::Standard),
202 "max" => Some(Self::Max),
203 _ => None,
204 }
205 }
206
207 pub fn is_active(&self) -> bool {
208 !matches!(self, Self::Off)
209 }
210
211 pub fn label(&self) -> &'static str {
212 match self {
213 Self::Off => "off",
214 Self::Lite => "lite",
215 Self::Standard => "standard",
216 Self::Max => "max",
217 }
218 }
219
220 pub fn description(&self) -> &'static str {
221 match self {
222 Self::Off => "No compression — full verbose output",
223 Self::Lite => "Light compression — concise output, basic terse filtering",
224 Self::Standard => {
225 "Standard compression — dense output, compact protocol, pattern-aware"
226 }
227 Self::Max => "Maximum compression — expert mode, TDD protocol, all layers active",
228 }
229 }
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize)]
234#[serde(default)]
235pub struct Config {
236 pub ultra_compact: bool,
237 #[serde(default, deserialize_with = "serde_defaults::deserialize_tee_mode")]
238 pub tee_mode: TeeMode,
239 #[serde(default)]
240 pub output_density: OutputDensity,
241 pub checkpoint_interval: u32,
242 pub excluded_commands: Vec<String>,
243 pub passthrough_urls: Vec<String>,
244 pub custom_aliases: Vec<AliasEntry>,
245 pub slow_command_threshold_ms: u64,
248 #[serde(default = "serde_defaults::default_theme")]
249 pub theme: String,
250 #[serde(default)]
251 pub cloud: CloudConfig,
252 #[serde(default)]
253 pub autonomy: AutonomyConfig,
254 #[serde(default)]
255 pub proxy: ProxyConfig,
256 #[serde(default = "serde_defaults::default_buddy_enabled")]
257 pub buddy_enabled: bool,
258 #[serde(default = "serde_defaults::default_true")]
259 pub enable_wakeup_ctx: bool,
260 #[serde(default)]
261 pub redirect_exclude: Vec<String>,
262 #[serde(default)]
266 pub disabled_tools: Vec<String>,
267 #[serde(default)]
268 pub loop_detection: LoopDetectionConfig,
269 #[serde(default)]
273 pub rules_scope: Option<String>,
274 #[serde(default)]
277 pub extra_ignore_patterns: Vec<String>,
278 #[serde(default)]
282 pub terse_agent: TerseAgent,
283 #[serde(default)]
287 pub compression_level: CompressionLevel,
288 #[serde(default)]
290 pub archive: ArchiveConfig,
291 #[serde(default)]
293 pub memory: MemoryPolicy,
294 #[serde(default)]
298 pub allow_paths: Vec<String>,
299 #[serde(default)]
302 pub content_defined_chunking: bool,
303 #[serde(default)]
306 pub minimal_overhead: bool,
307 #[serde(default)]
310 pub shell_hook_disabled: bool,
311 #[serde(default)]
318 pub shell_activation: ShellActivation,
319 #[serde(default)]
322 pub update_check_disabled: bool,
323 #[serde(default)]
324 pub updates: UpdatesConfig,
325 #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
328 pub bm25_max_cache_mb: u64,
329 #[serde(default = "serde_defaults::default_graph_index_max_files")]
332 pub graph_index_max_files: u64,
333 #[serde(default)]
336 pub memory_profile: MemoryProfile,
337 #[serde(default)]
341 pub memory_cleanup: MemoryCleanup,
342 #[serde(default = "serde_defaults::default_max_ram_percent")]
345 pub max_ram_percent: u8,
346 #[serde(default)]
350 pub savings_footer: SavingsFooter,
351 #[serde(default)]
355 pub project_root: Option<String>,
356 #[serde(default)]
359 pub lsp: std::collections::HashMap<String, String>,
360 #[serde(default)]
364 pub ide_paths: HashMap<String, Vec<String>>,
365 #[serde(default)]
372 pub response_verbosity: ResponseVerbosity,
373 #[serde(default)]
378 pub bypass_hints: Option<String>,
379 #[serde(default)]
382 pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
383 #[serde(default)]
384 pub secret_detection: SecretDetectionConfig,
385 #[serde(default)]
389 pub allow_auto_reroot: bool,
390 #[serde(default)]
394 pub sandbox_level: u8,
395 #[serde(default)]
399 pub reference_results: bool,
400 #[serde(default)]
403 pub agent_token_budget: usize,
404 #[serde(default)]
409 pub shell_allowlist: Vec<String>,
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize)]
413#[serde(default)]
414pub struct SecretDetectionConfig {
415 pub enabled: bool,
416 pub redact: bool,
417 pub custom_patterns: Vec<String>,
418}
419
420impl Default for SecretDetectionConfig {
421 fn default() -> Self {
422 Self {
423 enabled: true,
424 redact: false,
425 custom_patterns: Vec::new(),
426 }
427 }
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize)]
432#[serde(default)]
433pub struct ArchiveConfig {
434 pub enabled: bool,
435 pub threshold_chars: usize,
436 pub max_age_hours: u64,
437 pub max_disk_mb: u64,
438}
439
440impl Default for ArchiveConfig {
441 fn default() -> Self {
442 Self {
443 enabled: true,
444 threshold_chars: 4096,
445 max_age_hours: 48,
446 max_disk_mb: 500,
447 }
448 }
449}
450
451#[derive(Debug, Clone, Serialize, Deserialize)]
453#[serde(default)]
454pub struct AutonomyConfig {
455 pub enabled: bool,
456 pub auto_preload: bool,
457 pub auto_dedup: bool,
458 pub auto_related: bool,
459 pub auto_consolidate: bool,
460 pub silent_preload: bool,
461 pub dedup_threshold: usize,
462 pub consolidate_every_calls: u32,
463 pub consolidate_cooldown_secs: u64,
464 #[serde(default = "serde_defaults::default_true")]
465 pub cognition_loop_enabled: bool,
466 #[serde(default = "serde_defaults::default_cognition_loop_interval")]
467 pub cognition_loop_interval_secs: u64,
468 #[serde(default = "serde_defaults::default_cognition_loop_max_steps")]
469 pub cognition_loop_max_steps: u8,
470}
471
472impl Default for AutonomyConfig {
473 fn default() -> Self {
474 Self {
475 enabled: true,
476 auto_preload: true,
477 auto_dedup: true,
478 auto_related: true,
479 auto_consolidate: true,
480 silent_preload: true,
481 dedup_threshold: 8,
482 consolidate_every_calls: 25,
483 consolidate_cooldown_secs: 120,
484 cognition_loop_enabled: true,
485 cognition_loop_interval_secs: 3600,
486 cognition_loop_max_steps: 8,
487 }
488 }
489}
490
491#[derive(Debug, Clone, Serialize, Deserialize)]
494#[serde(default)]
495pub struct UpdatesConfig {
496 pub auto_update: bool,
497 pub check_interval_hours: u64,
498 pub notify_only: bool,
499}
500
501impl Default for UpdatesConfig {
502 fn default() -> Self {
503 Self {
504 auto_update: false,
505 check_interval_hours: 6,
506 notify_only: false,
507 }
508 }
509}
510
511impl UpdatesConfig {
512 pub fn from_env() -> Self {
513 let mut cfg = Self::default();
514 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_UPDATE") {
515 cfg.auto_update = v == "1" || v.eq_ignore_ascii_case("true");
516 }
517 if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_INTERVAL_HOURS") {
518 if let Ok(h) = v.parse::<u64>() {
519 cfg.check_interval_hours = h.clamp(1, 168);
520 }
521 }
522 if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_NOTIFY_ONLY") {
523 cfg.notify_only = v == "1" || v.eq_ignore_ascii_case("true");
524 }
525 cfg
526 }
527}
528
529impl AutonomyConfig {
530 pub fn from_env() -> Self {
532 let mut cfg = Self::default();
533 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
534 if v == "false" || v == "0" {
535 cfg.enabled = false;
536 }
537 }
538 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
539 cfg.auto_preload = v != "false" && v != "0";
540 }
541 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
542 cfg.auto_dedup = v != "false" && v != "0";
543 }
544 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
545 cfg.auto_related = v != "false" && v != "0";
546 }
547 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
548 cfg.auto_consolidate = v != "false" && v != "0";
549 }
550 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
551 cfg.silent_preload = v != "false" && v != "0";
552 }
553 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
554 if let Ok(n) = v.parse() {
555 cfg.dedup_threshold = n;
556 }
557 }
558 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
559 if let Ok(n) = v.parse() {
560 cfg.consolidate_every_calls = n;
561 }
562 }
563 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
564 if let Ok(n) = v.parse() {
565 cfg.consolidate_cooldown_secs = n;
566 }
567 }
568 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
569 cfg.cognition_loop_enabled = v != "false" && v != "0";
570 }
571 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
572 if let Ok(n) = v.parse() {
573 cfg.cognition_loop_interval_secs = n;
574 }
575 }
576 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
577 if let Ok(n) = v.parse() {
578 cfg.cognition_loop_max_steps = n;
579 }
580 }
581 cfg
582 }
583
584 pub fn load() -> Self {
586 let file_cfg = Config::load().autonomy;
587 let mut cfg = file_cfg;
588 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
589 if v == "false" || v == "0" {
590 cfg.enabled = false;
591 }
592 }
593 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
594 cfg.auto_preload = v != "false" && v != "0";
595 }
596 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
597 cfg.auto_dedup = v != "false" && v != "0";
598 }
599 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
600 cfg.auto_related = v != "false" && v != "0";
601 }
602 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
603 cfg.silent_preload = v != "false" && v != "0";
604 }
605 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
606 if let Ok(n) = v.parse() {
607 cfg.dedup_threshold = n;
608 }
609 }
610 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
611 cfg.cognition_loop_enabled = v != "false" && v != "0";
612 }
613 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
614 if let Ok(n) = v.parse() {
615 cfg.cognition_loop_interval_secs = n;
616 }
617 }
618 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
619 if let Ok(n) = v.parse() {
620 cfg.cognition_loop_max_steps = n;
621 }
622 }
623 cfg
624 }
625}
626
627#[derive(Debug, Clone, Serialize, Deserialize, Default)]
629#[serde(default)]
630pub struct CloudConfig {
631 pub contribute_enabled: bool,
632 pub last_contribute: Option<String>,
633 pub last_sync: Option<String>,
634 pub last_gain_sync: Option<String>,
635 pub last_model_pull: Option<String>,
636}
637
638#[derive(Debug, Clone, Serialize, Deserialize)]
640pub struct AliasEntry {
641 pub command: String,
642 pub alias: String,
643}
644
645#[derive(Debug, Clone, Serialize, Deserialize)]
647#[serde(default)]
648pub struct LoopDetectionConfig {
649 pub normal_threshold: u32,
650 pub reduced_threshold: u32,
651 pub blocked_threshold: u32,
652 pub window_secs: u64,
653 pub search_group_limit: u32,
654 pub tool_total_limits: HashMap<String, u32>,
655}
656
657impl Default for LoopDetectionConfig {
658 fn default() -> Self {
659 let mut tool_total_limits = HashMap::new();
660 tool_total_limits.insert("ctx_read".to_string(), 100);
661 tool_total_limits.insert("ctx_search".to_string(), 80);
662 tool_total_limits.insert("ctx_shell".to_string(), 50);
663 tool_total_limits.insert("ctx_semantic_search".to_string(), 60);
664 Self {
665 normal_threshold: 2,
666 reduced_threshold: 4,
667 blocked_threshold: 0,
668 window_secs: 300,
669 search_group_limit: 10,
670 tool_total_limits,
671 }
672 }
673}
674
675impl Default for Config {
676 fn default() -> Self {
677 Self {
678 ultra_compact: false,
679 tee_mode: TeeMode::default(),
680 output_density: OutputDensity::default(),
681 checkpoint_interval: 15,
682 excluded_commands: Vec::new(),
683 passthrough_urls: Vec::new(),
684 custom_aliases: Vec::new(),
685 slow_command_threshold_ms: 5000,
686 theme: serde_defaults::default_theme(),
687 cloud: CloudConfig::default(),
688 autonomy: AutonomyConfig::default(),
689 proxy: ProxyConfig::default(),
690 buddy_enabled: serde_defaults::default_buddy_enabled(),
691 enable_wakeup_ctx: true,
692 redirect_exclude: Vec::new(),
693 disabled_tools: Vec::new(),
694 loop_detection: LoopDetectionConfig::default(),
695 rules_scope: None,
696 extra_ignore_patterns: Vec::new(),
697 terse_agent: TerseAgent::default(),
698 compression_level: CompressionLevel::default(),
699 archive: ArchiveConfig::default(),
700 memory: MemoryPolicy::default(),
701 allow_paths: Vec::new(),
702 content_defined_chunking: false,
703 minimal_overhead: false,
704 shell_hook_disabled: false,
705 shell_activation: ShellActivation::default(),
706 update_check_disabled: false,
707 updates: UpdatesConfig::default(),
708 graph_index_max_files: serde_defaults::default_graph_index_max_files(),
709 bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
710 memory_profile: MemoryProfile::default(),
711 memory_cleanup: MemoryCleanup::default(),
712 max_ram_percent: serde_defaults::default_max_ram_percent(),
713 savings_footer: SavingsFooter::default(),
714 project_root: None,
715 lsp: std::collections::HashMap::new(),
716 ide_paths: HashMap::new(),
717 response_verbosity: ResponseVerbosity::default(),
718 bypass_hints: None,
719 boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
720 secret_detection: SecretDetectionConfig::default(),
721 allow_auto_reroot: false,
722 sandbox_level: 0,
723 reference_results: false,
724 agent_token_budget: 0,
725 shell_allowlist: Vec::new(),
726 }
727 }
728}
729
730#[derive(Debug, Clone, Copy, PartialEq, Eq)]
732pub enum RulesScope {
733 Both,
734 Global,
735 Project,
736}
737
738impl Config {
739 pub fn rules_scope_effective(&self) -> RulesScope {
741 let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
742 .ok()
743 .or_else(|| self.rules_scope.clone())
744 .unwrap_or_default();
745 match raw.trim().to_lowercase().as_str() {
746 "global" => RulesScope::Global,
747 "project" => RulesScope::Project,
748 _ => RulesScope::Both,
749 }
750 }
751
752 fn parse_disabled_tools_env(val: &str) -> Vec<String> {
753 val.split(',')
754 .map(|s| s.trim().to_string())
755 .filter(|s| !s.is_empty())
756 .collect()
757 }
758
759 pub fn disabled_tools_effective(&self) -> Vec<String> {
761 if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
762 Self::parse_disabled_tools_env(&val)
763 } else {
764 self.disabled_tools.clone()
765 }
766 }
767
768 pub fn minimal_overhead_effective(&self) -> bool {
770 std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
771 }
772
773 pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
781 if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
782 match raw.trim().to_lowercase().as_str() {
783 "minimal" => return true,
784 "full" => return self.minimal_overhead_effective(),
785 _ => {}
786 }
787 }
788
789 if self.minimal_overhead_effective() {
790 return true;
791 }
792
793 let client_lower = client_name.trim().to_lowercase();
794 if !client_lower.is_empty() {
795 if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
796 for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
797 if !needle.is_empty() && client_lower.contains(&needle) {
798 return true;
799 }
800 }
801 } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
802 return true;
803 }
804 }
805
806 let model = std::env::var("LEAN_CTX_MODEL")
807 .or_else(|_| std::env::var("LCTX_MODEL"))
808 .unwrap_or_default();
809 let model = model.trim().to_lowercase();
810 if !model.is_empty() {
811 let m = model.replace(['_', ' '], "-");
812 if m.contains("minimax")
813 || m.contains("mini-max")
814 || m.contains("m2.7")
815 || m.contains("m2-7")
816 {
817 return true;
818 }
819 }
820
821 false
822 }
823
824 pub fn shell_hook_disabled_effective(&self) -> bool {
826 std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
827 }
828
829 pub fn shell_activation_effective(&self) -> ShellActivation {
831 ShellActivation::effective(self)
832 }
833
834 pub fn update_check_disabled_effective(&self) -> bool {
836 std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
837 }
838
839 pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
840 let mut policy = self.memory.clone();
841 policy.apply_env_overrides();
842 policy.validate()?;
843 Ok(policy)
844 }
845}
846
847#[cfg(test)]
848mod disabled_tools_tests {
849 use super::*;
850
851 #[test]
852 fn config_field_default_is_empty() {
853 let cfg = Config::default();
854 assert!(cfg.disabled_tools.is_empty());
855 }
856
857 #[test]
858 fn effective_returns_config_field_when_no_env_var() {
859 if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
861 return;
862 }
863 let cfg = Config {
864 disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
865 ..Default::default()
866 };
867 assert_eq!(
868 cfg.disabled_tools_effective(),
869 vec!["ctx_graph", "ctx_agent"]
870 );
871 }
872
873 #[test]
874 fn parse_env_basic() {
875 let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
876 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
877 }
878
879 #[test]
880 fn parse_env_trims_whitespace_and_skips_empty() {
881 let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
882 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
883 }
884
885 #[test]
886 fn parse_env_single_entry() {
887 let result = Config::parse_disabled_tools_env("ctx_graph");
888 assert_eq!(result, vec!["ctx_graph"]);
889 }
890
891 #[test]
892 fn parse_env_empty_string_returns_empty() {
893 let result = Config::parse_disabled_tools_env("");
894 assert!(result.is_empty());
895 }
896
897 #[test]
898 fn disabled_tools_deserialization_defaults_to_empty() {
899 let cfg: Config = toml::from_str("").unwrap();
900 assert!(cfg.disabled_tools.is_empty());
901 }
902
903 #[test]
904 fn disabled_tools_deserialization_from_toml() {
905 let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
906 assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
907 }
908}
909
910#[cfg(test)]
911mod rules_scope_tests {
912 use super::*;
913
914 #[test]
915 fn default_is_both() {
916 let cfg = Config::default();
917 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
918 }
919
920 #[test]
921 fn config_global() {
922 let cfg = Config {
923 rules_scope: Some("global".to_string()),
924 ..Default::default()
925 };
926 assert_eq!(cfg.rules_scope_effective(), RulesScope::Global);
927 }
928
929 #[test]
930 fn config_project() {
931 let cfg = Config {
932 rules_scope: Some("project".to_string()),
933 ..Default::default()
934 };
935 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
936 }
937
938 #[test]
939 fn unknown_value_falls_back_to_both() {
940 let cfg = Config {
941 rules_scope: Some("nonsense".to_string()),
942 ..Default::default()
943 };
944 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
945 }
946
947 #[test]
948 fn deserialization_none_by_default() {
949 let cfg: Config = toml::from_str("").unwrap();
950 assert!(cfg.rules_scope.is_none());
951 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
952 }
953
954 #[test]
955 fn deserialization_from_toml() {
956 let cfg: Config = toml::from_str(r#"rules_scope = "project""#).unwrap();
957 assert_eq!(cfg.rules_scope.as_deref(), Some("project"));
958 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
959 }
960}
961
962#[cfg(test)]
963mod loop_detection_config_tests {
964 use super::*;
965
966 #[test]
967 fn defaults_are_reasonable() {
968 let cfg = LoopDetectionConfig::default();
969 assert_eq!(cfg.normal_threshold, 2);
970 assert_eq!(cfg.reduced_threshold, 4);
971 assert_eq!(cfg.blocked_threshold, 0);
973 assert_eq!(cfg.window_secs, 300);
974 assert_eq!(cfg.search_group_limit, 10);
975 }
976
977 #[test]
978 fn deserialization_defaults_when_missing() {
979 let cfg: Config = toml::from_str("").unwrap();
980 assert_eq!(cfg.loop_detection.blocked_threshold, 0);
982 assert_eq!(cfg.loop_detection.search_group_limit, 10);
983 }
984
985 #[test]
986 fn deserialization_from_toml() {
987 let cfg: Config = toml::from_str(
988 r"
989 [loop_detection]
990 normal_threshold = 1
991 reduced_threshold = 3
992 blocked_threshold = 5
993 window_secs = 120
994 search_group_limit = 8
995 ",
996 )
997 .unwrap();
998 assert_eq!(cfg.loop_detection.normal_threshold, 1);
999 assert_eq!(cfg.loop_detection.reduced_threshold, 3);
1000 assert_eq!(cfg.loop_detection.blocked_threshold, 5);
1001 assert_eq!(cfg.loop_detection.window_secs, 120);
1002 assert_eq!(cfg.loop_detection.search_group_limit, 8);
1003 }
1004
1005 #[test]
1006 fn partial_override_keeps_defaults() {
1007 let cfg: Config = toml::from_str(
1008 r"
1009 [loop_detection]
1010 blocked_threshold = 10
1011 ",
1012 )
1013 .unwrap();
1014 assert_eq!(cfg.loop_detection.blocked_threshold, 10);
1015 assert_eq!(cfg.loop_detection.normal_threshold, 2);
1016 assert_eq!(cfg.loop_detection.search_group_limit, 10);
1017 }
1018}
1019
1020impl Config {
1021 pub fn path() -> Option<PathBuf> {
1023 crate::core::data_dir::lean_ctx_data_dir()
1024 .ok()
1025 .map(|d| d.join("config.toml"))
1026 }
1027
1028 pub fn local_path(project_root: &str) -> PathBuf {
1030 PathBuf::from(project_root).join(".lean-ctx.toml")
1031 }
1032
1033 fn find_project_root() -> Option<String> {
1034 if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
1035 if !env_root.is_empty() {
1036 return Some(env_root);
1037 }
1038 }
1039
1040 let cwd = std::env::current_dir().ok();
1041
1042 if let Some(root) =
1043 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
1044 {
1045 let root_path = std::path::Path::new(&root);
1046 let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
1047 let has_marker = root_path.join(".git").exists()
1048 || root_path.join("Cargo.toml").exists()
1049 || root_path.join("package.json").exists()
1050 || root_path.join("go.mod").exists()
1051 || root_path.join("pyproject.toml").exists()
1052 || root_path.join(".lean-ctx.toml").exists();
1053
1054 if cwd_is_under_root || has_marker {
1055 return Some(root);
1056 }
1057 }
1058
1059 if let Some(ref cwd) = cwd {
1060 let git_root = std::process::Command::new("git")
1061 .args(["rev-parse", "--show-toplevel"])
1062 .current_dir(cwd)
1063 .stdout(std::process::Stdio::piped())
1064 .stderr(std::process::Stdio::null())
1065 .output()
1066 .ok()
1067 .and_then(|o| {
1068 if o.status.success() {
1069 String::from_utf8(o.stdout)
1070 .ok()
1071 .map(|s| s.trim().to_string())
1072 } else {
1073 None
1074 }
1075 });
1076 if let Some(root) = git_root {
1077 return Some(root);
1078 }
1079 return Some(cwd.to_string_lossy().to_string());
1080 }
1081 None
1082 }
1083
1084 pub fn load() -> Self {
1086 static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
1087
1088 let Some(path) = Self::path() else {
1089 return Self::default();
1090 };
1091
1092 let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
1093
1094 let mtime = std::fs::metadata(&path)
1095 .and_then(|m| m.modified())
1096 .unwrap_or(SystemTime::UNIX_EPOCH);
1097
1098 let local_mtime = local_path
1099 .as_ref()
1100 .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
1101
1102 if let Ok(guard) = CACHE.lock() {
1103 if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
1104 if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
1105 return cfg.clone();
1106 }
1107 }
1108 }
1109
1110 let mut cfg: Config = match std::fs::read_to_string(&path) {
1111 Ok(content) => match toml::from_str(&content) {
1112 Ok(c) => c,
1113 Err(e) => {
1114 tracing::warn!("config parse error in {}: {e}", path.display());
1115 Self::default()
1116 }
1117 },
1118 Err(_) => Self::default(),
1119 };
1120
1121 if let Some(ref lp) = local_path {
1122 if let Ok(local_content) = std::fs::read_to_string(lp) {
1123 cfg.merge_local(&local_content);
1124 }
1125 }
1126
1127 if let Ok(mut guard) = CACHE.lock() {
1128 *guard = Some((cfg.clone(), mtime, local_mtime));
1129 }
1130
1131 cfg
1132 }
1133
1134 fn merge_local(&mut self, local_toml: &str) {
1135 let local: Config = match toml::from_str(local_toml) {
1136 Ok(c) => c,
1137 Err(e) => {
1138 tracing::warn!("local config parse error: {e}");
1139 return;
1140 }
1141 };
1142 if local.ultra_compact {
1143 self.ultra_compact = true;
1144 }
1145 if local.tee_mode != TeeMode::default() {
1146 self.tee_mode = local.tee_mode;
1147 }
1148 if local.output_density != OutputDensity::default() {
1149 self.output_density = local.output_density;
1150 }
1151 if local.checkpoint_interval != 15 {
1152 self.checkpoint_interval = local.checkpoint_interval;
1153 }
1154 if !local.excluded_commands.is_empty() {
1155 self.excluded_commands.extend(local.excluded_commands);
1156 }
1157 if !local.passthrough_urls.is_empty() {
1158 self.passthrough_urls.extend(local.passthrough_urls);
1159 }
1160 if !local.custom_aliases.is_empty() {
1161 self.custom_aliases.extend(local.custom_aliases);
1162 }
1163 if local.slow_command_threshold_ms != 5000 {
1164 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
1165 }
1166 if local.theme != "default" {
1167 self.theme = local.theme;
1168 }
1169 if !local.buddy_enabled {
1170 self.buddy_enabled = false;
1171 }
1172 if !local.enable_wakeup_ctx {
1173 self.enable_wakeup_ctx = false;
1174 }
1175 if !local.redirect_exclude.is_empty() {
1176 self.redirect_exclude.extend(local.redirect_exclude);
1177 }
1178 if !local.disabled_tools.is_empty() {
1179 self.disabled_tools.extend(local.disabled_tools);
1180 }
1181 if !local.extra_ignore_patterns.is_empty() {
1182 self.extra_ignore_patterns
1183 .extend(local.extra_ignore_patterns);
1184 }
1185 if local.rules_scope.is_some() {
1186 self.rules_scope = local.rules_scope;
1187 }
1188 if local.proxy.anthropic_upstream.is_some() {
1189 self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
1190 }
1191 if local.proxy.openai_upstream.is_some() {
1192 self.proxy.openai_upstream = local.proxy.openai_upstream;
1193 }
1194 if local.proxy.gemini_upstream.is_some() {
1195 self.proxy.gemini_upstream = local.proxy.gemini_upstream;
1196 }
1197 if !local.autonomy.enabled {
1198 self.autonomy.enabled = false;
1199 }
1200 if !local.autonomy.auto_preload {
1201 self.autonomy.auto_preload = false;
1202 }
1203 if !local.autonomy.auto_dedup {
1204 self.autonomy.auto_dedup = false;
1205 }
1206 if !local.autonomy.auto_related {
1207 self.autonomy.auto_related = false;
1208 }
1209 if !local.autonomy.auto_consolidate {
1210 self.autonomy.auto_consolidate = false;
1211 }
1212 if local.autonomy.silent_preload {
1213 self.autonomy.silent_preload = true;
1214 }
1215 if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1216 self.autonomy.silent_preload = false;
1217 }
1218 if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1219 self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1220 }
1221 if local.autonomy.consolidate_every_calls
1222 != AutonomyConfig::default().consolidate_every_calls
1223 {
1224 self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1225 }
1226 if local.autonomy.consolidate_cooldown_secs
1227 != AutonomyConfig::default().consolidate_cooldown_secs
1228 {
1229 self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1230 }
1231 if !local.autonomy.cognition_loop_enabled {
1232 self.autonomy.cognition_loop_enabled = false;
1233 }
1234 if local.autonomy.cognition_loop_interval_secs
1235 != AutonomyConfig::default().cognition_loop_interval_secs
1236 {
1237 self.autonomy.cognition_loop_interval_secs =
1238 local.autonomy.cognition_loop_interval_secs;
1239 }
1240 if local.autonomy.cognition_loop_max_steps
1241 != AutonomyConfig::default().cognition_loop_max_steps
1242 {
1243 self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
1244 }
1245 if local_toml.contains("compression_level") {
1246 self.compression_level = local.compression_level;
1247 }
1248 if local_toml.contains("terse_agent") {
1249 self.terse_agent = local.terse_agent;
1250 }
1251 if !local.archive.enabled {
1252 self.archive.enabled = false;
1253 }
1254 if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1255 self.archive.threshold_chars = local.archive.threshold_chars;
1256 }
1257 if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1258 self.archive.max_age_hours = local.archive.max_age_hours;
1259 }
1260 if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1261 self.archive.max_disk_mb = local.archive.max_disk_mb;
1262 }
1263 let mem_def = MemoryPolicy::default();
1264 if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1265 self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1266 }
1267 if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1268 self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1269 }
1270 if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1271 self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1272 }
1273 if local.memory.knowledge.contradiction_threshold
1274 != mem_def.knowledge.contradiction_threshold
1275 {
1276 self.memory.knowledge.contradiction_threshold =
1277 local.memory.knowledge.contradiction_threshold;
1278 }
1279
1280 if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1281 self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1282 }
1283 if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1284 {
1285 self.memory.episodic.max_actions_per_episode =
1286 local.memory.episodic.max_actions_per_episode;
1287 }
1288 if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1289 self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1290 }
1291
1292 if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1293 self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1294 }
1295 if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1296 self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1297 }
1298 if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1299 self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1300 }
1301 if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1302 self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1303 }
1304
1305 if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1306 self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1307 }
1308 if local.memory.lifecycle.low_confidence_threshold
1309 != mem_def.lifecycle.low_confidence_threshold
1310 {
1311 self.memory.lifecycle.low_confidence_threshold =
1312 local.memory.lifecycle.low_confidence_threshold;
1313 }
1314 if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1315 self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1316 }
1317 if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1318 self.memory.lifecycle.similarity_threshold =
1319 local.memory.lifecycle.similarity_threshold;
1320 }
1321
1322 if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1323 self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1324 }
1325 if !local.allow_paths.is_empty() {
1326 self.allow_paths.extend(local.allow_paths);
1327 }
1328 if local.minimal_overhead {
1329 self.minimal_overhead = true;
1330 }
1331 if local.shell_hook_disabled {
1332 self.shell_hook_disabled = true;
1333 }
1334 if local.shell_activation != ShellActivation::default() {
1335 self.shell_activation = local.shell_activation.clone();
1336 }
1337 if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1338 self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1339 }
1340 if local.memory_profile != MemoryProfile::default() {
1341 self.memory_profile = local.memory_profile;
1342 }
1343 if local.memory_cleanup != MemoryCleanup::default() {
1344 self.memory_cleanup = local.memory_cleanup;
1345 }
1346 if !local.shell_allowlist.is_empty() {
1347 self.shell_allowlist = local.shell_allowlist;
1348 }
1349 }
1350
1351 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1353 let path = Self::path().ok_or_else(|| {
1354 super::error::LeanCtxError::Config("cannot determine home directory".into())
1355 })?;
1356 if let Some(parent) = path.parent() {
1357 std::fs::create_dir_all(parent)?;
1358 }
1359 let content = toml::to_string_pretty(self)
1360 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1361 std::fs::write(&path, content)?;
1362 Ok(())
1363 }
1364
1365 pub fn show(&self) -> String {
1367 let global_path = Self::path().map_or_else(
1368 || "~/.lean-ctx/config.toml".to_string(),
1369 |p| p.to_string_lossy().to_string(),
1370 );
1371 let content = toml::to_string_pretty(self).unwrap_or_default();
1372 let mut out = format!("Global config: {global_path}\n\n{content}");
1373
1374 if let Some(root) = Self::find_project_root() {
1375 let local = Self::local_path(&root);
1376 if local.exists() {
1377 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1378 } else {
1379 out.push_str(&format!(
1380 "\n\nLocal config: not found (create {} to override per-project)\n",
1381 local.display()
1382 ));
1383 }
1384 }
1385 out
1386 }
1387}
1388
1389#[cfg(test)]
1390mod compression_level_tests {
1391 use super::*;
1392
1393 #[test]
1394 fn default_is_standard() {
1395 assert_eq!(CompressionLevel::default(), CompressionLevel::Standard);
1396 }
1397
1398 #[test]
1399 fn to_components_off() {
1400 let (ta, od, crp, tm) = CompressionLevel::Off.to_components();
1401 assert_eq!(ta, TerseAgent::Off);
1402 assert_eq!(od, OutputDensity::Normal);
1403 assert_eq!(crp, "off");
1404 assert!(!tm);
1405 }
1406
1407 #[test]
1408 fn to_components_lite() {
1409 let (ta, od, crp, tm) = CompressionLevel::Lite.to_components();
1410 assert_eq!(ta, TerseAgent::Lite);
1411 assert_eq!(od, OutputDensity::Terse);
1412 assert_eq!(crp, "off");
1413 assert!(tm);
1414 }
1415
1416 #[test]
1417 fn to_components_standard() {
1418 let (ta, od, crp, tm) = CompressionLevel::Standard.to_components();
1419 assert_eq!(ta, TerseAgent::Full);
1420 assert_eq!(od, OutputDensity::Terse);
1421 assert_eq!(crp, "compact");
1422 assert!(tm);
1423 }
1424
1425 #[test]
1426 fn to_components_max() {
1427 let (ta, od, crp, tm) = CompressionLevel::Max.to_components();
1428 assert_eq!(ta, TerseAgent::Ultra);
1429 assert_eq!(od, OutputDensity::Ultra);
1430 assert_eq!(crp, "tdd");
1431 assert!(tm);
1432 }
1433
1434 #[test]
1435 fn from_legacy_ultra_agent_maps_to_max() {
1436 assert_eq!(
1437 CompressionLevel::from_legacy(&TerseAgent::Ultra, &OutputDensity::Normal),
1438 CompressionLevel::Max
1439 );
1440 }
1441
1442 #[test]
1443 fn from_legacy_ultra_density_maps_to_max() {
1444 assert_eq!(
1445 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Ultra),
1446 CompressionLevel::Max
1447 );
1448 }
1449
1450 #[test]
1451 fn from_legacy_full_agent_maps_to_standard() {
1452 assert_eq!(
1453 CompressionLevel::from_legacy(&TerseAgent::Full, &OutputDensity::Normal),
1454 CompressionLevel::Standard
1455 );
1456 }
1457
1458 #[test]
1459 fn from_legacy_lite_agent_maps_to_lite() {
1460 assert_eq!(
1461 CompressionLevel::from_legacy(&TerseAgent::Lite, &OutputDensity::Normal),
1462 CompressionLevel::Lite
1463 );
1464 }
1465
1466 #[test]
1467 fn from_legacy_terse_density_maps_to_lite() {
1468 assert_eq!(
1469 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Terse),
1470 CompressionLevel::Lite
1471 );
1472 }
1473
1474 #[test]
1475 fn from_legacy_both_off_maps_to_off() {
1476 assert_eq!(
1477 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Normal),
1478 CompressionLevel::Off
1479 );
1480 }
1481
1482 #[test]
1483 fn labels_match() {
1484 assert_eq!(CompressionLevel::Off.label(), "off");
1485 assert_eq!(CompressionLevel::Lite.label(), "lite");
1486 assert_eq!(CompressionLevel::Standard.label(), "standard");
1487 assert_eq!(CompressionLevel::Max.label(), "max");
1488 }
1489
1490 #[test]
1491 fn is_active_false_for_off() {
1492 assert!(!CompressionLevel::Off.is_active());
1493 }
1494
1495 #[test]
1496 fn is_active_true_for_all_others() {
1497 assert!(CompressionLevel::Lite.is_active());
1498 assert!(CompressionLevel::Standard.is_active());
1499 assert!(CompressionLevel::Max.is_active());
1500 }
1501
1502 #[test]
1503 fn deserialization_defaults_to_standard() {
1504 let cfg: Config = toml::from_str("").unwrap();
1505 assert_eq!(cfg.compression_level, CompressionLevel::Standard);
1506 }
1507
1508 #[test]
1509 fn deserialization_from_toml() {
1510 let cfg: Config = toml::from_str(r#"compression_level = "standard""#).unwrap();
1511 assert_eq!(cfg.compression_level, CompressionLevel::Standard);
1512 }
1513
1514 #[test]
1515 fn roundtrip_all_levels() {
1516 for level in [
1517 CompressionLevel::Off,
1518 CompressionLevel::Lite,
1519 CompressionLevel::Standard,
1520 CompressionLevel::Max,
1521 ] {
1522 let (ta, od, crp, tm) = level.to_components();
1523 assert!(!crp.is_empty());
1524 if level == CompressionLevel::Off {
1525 assert!(!tm);
1526 assert_eq!(ta, TerseAgent::Off);
1527 assert_eq!(od, OutputDensity::Normal);
1528 } else {
1529 assert!(tm);
1530 }
1531 }
1532 }
1533}
1534
1535#[cfg(test)]
1536mod memory_cleanup_tests {
1537 use super::*;
1538
1539 #[test]
1540 fn default_is_aggressive() {
1541 assert_eq!(MemoryCleanup::default(), MemoryCleanup::Aggressive);
1542 }
1543
1544 #[test]
1545 fn aggressive_ttl_is_300() {
1546 assert_eq!(MemoryCleanup::Aggressive.idle_ttl_secs(), 300);
1547 }
1548
1549 #[test]
1550 fn shared_ttl_is_1800() {
1551 assert_eq!(MemoryCleanup::Shared.idle_ttl_secs(), 1800);
1552 }
1553
1554 #[test]
1555 fn index_retention_multiplier_values() {
1556 assert!(
1557 (MemoryCleanup::Aggressive.index_retention_multiplier() - 1.0).abs() < f64::EPSILON
1558 );
1559 assert!((MemoryCleanup::Shared.index_retention_multiplier() - 3.0).abs() < f64::EPSILON);
1560 }
1561
1562 #[test]
1563 fn deserialization_defaults_to_aggressive() {
1564 let cfg: Config = toml::from_str("").unwrap();
1565 assert_eq!(cfg.memory_cleanup, MemoryCleanup::Aggressive);
1566 }
1567
1568 #[test]
1569 fn deserialization_from_toml() {
1570 let cfg: Config = toml::from_str(r#"memory_cleanup = "shared""#).unwrap();
1571 assert_eq!(cfg.memory_cleanup, MemoryCleanup::Shared);
1572 }
1573
1574 #[test]
1575 fn effective_uses_config_when_no_env() {
1576 let cfg = Config {
1577 memory_cleanup: MemoryCleanup::Shared,
1578 ..Default::default()
1579 };
1580 let eff = MemoryCleanup::effective(&cfg);
1581 assert_eq!(eff, MemoryCleanup::Shared);
1582 }
1583}