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