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