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