Skip to main content

lean_ctx/core/config/
mod.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3use std::sync::Mutex;
4use std::time::SystemTime;
5
6use super::memory_policy::MemoryPolicy;
7
8mod memory;
9mod proxy;
10mod serde_defaults;
11mod shell_activation;
12
13pub use memory::{MemoryCleanup, MemoryProfile};
14pub use proxy::{is_local_proxy_url, normalize_url, normalize_url_opt, ProxyConfig, ProxyProvider};
15pub use shell_activation::ShellActivation;
16
17/// Default BM25 cache cap from config (also used by `bm25_index` heuristics).
18pub fn default_bm25_max_cache_mb() -> u64 {
19    serde_defaults::default_bm25_max_cache_mb()
20}
21
22/// Controls when shell output is tee'd to disk for later retrieval.
23#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
24#[serde(rename_all = "lowercase")]
25pub enum TeeMode {
26    Never,
27    #[default]
28    Failures,
29    Always,
30}
31
32/// Controls agent output verbosity level injected into MCP instructions.
33#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
34#[serde(rename_all = "lowercase")]
35pub enum TerseAgent {
36    #[default]
37    Off,
38    Lite,
39    Full,
40    Ultra,
41}
42
43impl TerseAgent {
44    /// Reads the terse-agent level from the `LEAN_CTX_TERSE_AGENT` env var.
45    pub fn from_env() -> Self {
46        match std::env::var("LEAN_CTX_TERSE_AGENT")
47            .unwrap_or_default()
48            .to_lowercase()
49            .as_str()
50        {
51            "lite" => Self::Lite,
52            "full" => Self::Full,
53            "ultra" => Self::Ultra,
54            _ => Self::Off,
55        }
56    }
57
58    /// Returns the effective terse level, preferring env var over config value.
59    pub fn effective(config_val: &TerseAgent) -> Self {
60        match std::env::var("LEAN_CTX_TERSE_AGENT") {
61            Ok(val) if !val.is_empty() => match val.to_lowercase().as_str() {
62                "lite" => Self::Lite,
63                "full" => Self::Full,
64                "ultra" => Self::Ultra,
65                _ => Self::Off,
66            },
67            _ => config_val.clone(),
68        }
69    }
70
71    /// Returns `true` if any terse level is enabled (not `Off`).
72    pub fn is_active(&self) -> bool {
73        !matches!(self, Self::Off)
74    }
75}
76
77/// Controls how dense/compact MCP tool output is formatted.
78#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
79#[serde(rename_all = "lowercase")]
80pub enum OutputDensity {
81    #[default]
82    Normal,
83    Terse,
84    Ultra,
85}
86
87impl OutputDensity {
88    /// Reads the output density from the `LEAN_CTX_OUTPUT_DENSITY` env var.
89    pub fn from_env() -> Self {
90        match std::env::var("LEAN_CTX_OUTPUT_DENSITY")
91            .unwrap_or_default()
92            .to_lowercase()
93            .as_str()
94        {
95            "terse" => Self::Terse,
96            "ultra" => Self::Ultra,
97            _ => Self::Normal,
98        }
99    }
100
101    /// Returns the effective density, preferring env var over config value.
102    pub fn effective(config_val: &OutputDensity) -> Self {
103        let env_val = Self::from_env();
104        if env_val != Self::Normal {
105            return env_val;
106        }
107        let profile_val = crate::core::profiles::active_profile()
108            .compression
109            .output_density_effective()
110            .to_lowercase();
111        let profile_density = match profile_val.as_str() {
112            "terse" => Self::Terse,
113            "ultra" => Self::Ultra,
114            _ => Self::Normal,
115        };
116        if profile_density != Self::Normal {
117            return profile_density;
118        }
119        config_val.clone()
120    }
121}
122
123/// Unified compression level that replaces the 4 separate legacy concepts:
124/// `terse_agent`, `output_density`, `terse_mode`, and `crp_mode`.
125///
126/// Each level maps to specific component settings via `to_components()`.
127#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
128#[serde(rename_all = "lowercase")]
129pub enum CompressionLevel {
130    #[default]
131    Off,
132    Lite,
133    Standard,
134    Max,
135}
136
137impl CompressionLevel {
138    /// Decomposes the unified level into legacy component settings.
139    /// Returns (TerseAgent, OutputDensity, crp_mode_str, terse_mode_bool).
140    pub fn to_components(&self) -> (TerseAgent, OutputDensity, &'static str, bool) {
141        match self {
142            Self::Off => (TerseAgent::Off, OutputDensity::Normal, "off", false),
143            Self::Lite => (TerseAgent::Lite, OutputDensity::Terse, "off", true),
144            Self::Standard => (TerseAgent::Full, OutputDensity::Terse, "compact", true),
145            Self::Max => (TerseAgent::Ultra, OutputDensity::Ultra, "tdd", true),
146        }
147    }
148
149    /// Infers a `CompressionLevel` from legacy config keys for backward compatibility.
150    /// Priority: terse_agent > output_density (picks the highest implied level).
151    pub fn from_legacy(terse_agent: &TerseAgent, output_density: &OutputDensity) -> Self {
152        match (terse_agent, output_density) {
153            (TerseAgent::Ultra, _) | (_, OutputDensity::Ultra) => Self::Max,
154            (TerseAgent::Full, _) => Self::Standard,
155            (TerseAgent::Lite, _) | (_, OutputDensity::Terse) => Self::Lite,
156            _ => Self::Off,
157        }
158    }
159
160    /// Reads the compression level from the `LEAN_CTX_COMPRESSION` env var.
161    pub fn from_env() -> Option<Self> {
162        std::env::var("LEAN_CTX_COMPRESSION").ok().and_then(|v| {
163            match v.trim().to_lowercase().as_str() {
164                "off" => Some(Self::Off),
165                "lite" => Some(Self::Lite),
166                "standard" => Some(Self::Standard),
167                "max" => Some(Self::Max),
168                _ => None,
169            }
170        })
171    }
172
173    /// Returns the effective compression level with resolution order:
174    /// 1. `LEAN_CTX_COMPRESSION` env var
175    /// 2. `compression_level` in config
176    /// 3. Legacy `ultra_compact` flag (maps to `Max`)
177    /// 4. Inferred from legacy `terse_agent` + `output_density`
178    pub fn effective(config: &Config) -> Self {
179        if let Some(env_level) = Self::from_env() {
180            return env_level;
181        }
182        if config.compression_level != Self::Off {
183            return config.compression_level.clone();
184        }
185        if config.ultra_compact {
186            return Self::Max;
187        }
188        Self::from_legacy(&config.terse_agent, &config.output_density)
189    }
190
191    pub fn from_str_label(s: &str) -> Option<Self> {
192        match s.trim().to_lowercase().as_str() {
193            "off" => Some(Self::Off),
194            "lite" => Some(Self::Lite),
195            "standard" | "std" => Some(Self::Standard),
196            "max" => Some(Self::Max),
197            _ => None,
198        }
199    }
200
201    pub fn is_active(&self) -> bool {
202        !matches!(self, Self::Off)
203    }
204
205    pub fn label(&self) -> &'static str {
206        match self {
207            Self::Off => "off",
208            Self::Lite => "lite",
209            Self::Standard => "standard",
210            Self::Max => "max",
211        }
212    }
213
214    pub fn description(&self) -> &'static str {
215        match self {
216            Self::Off => "No compression — full verbose output",
217            Self::Lite => "Light compression — concise output, basic terse filtering",
218            Self::Standard => {
219                "Standard compression — dense output, compact protocol, pattern-aware"
220            }
221            Self::Max => "Maximum compression — expert mode, TDD protocol, all layers active",
222        }
223    }
224}
225
226/// Global lean-ctx configuration loaded from `config.toml`, merged with project-local overrides.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228#[serde(default)]
229pub struct Config {
230    pub ultra_compact: bool,
231    #[serde(default, deserialize_with = "serde_defaults::deserialize_tee_mode")]
232    pub tee_mode: TeeMode,
233    #[serde(default)]
234    pub output_density: OutputDensity,
235    pub checkpoint_interval: u32,
236    pub excluded_commands: Vec<String>,
237    pub passthrough_urls: Vec<String>,
238    pub custom_aliases: Vec<AliasEntry>,
239    /// Commands taking longer than this threshold (ms) are recorded in the slow log.
240    /// Set to 0 to disable slow logging.
241    pub slow_command_threshold_ms: u64,
242    #[serde(default = "serde_defaults::default_theme")]
243    pub theme: String,
244    #[serde(default)]
245    pub cloud: CloudConfig,
246    #[serde(default)]
247    pub autonomy: AutonomyConfig,
248    #[serde(default)]
249    pub proxy: ProxyConfig,
250    #[serde(default = "serde_defaults::default_buddy_enabled")]
251    pub buddy_enabled: bool,
252    #[serde(default)]
253    pub redirect_exclude: Vec<String>,
254    /// Tools to exclude from the MCP tool list returned by list_tools.
255    /// Accepts exact tool names (e.g. `["ctx_graph", "ctx_agent"]`).
256    /// Empty by default — all tools listed, no behaviour change.
257    #[serde(default)]
258    pub disabled_tools: Vec<String>,
259    #[serde(default)]
260    pub loop_detection: LoopDetectionConfig,
261    /// Controls where lean-ctx installs agent rule files.
262    /// Values: "both" (default), "global" (home-dir only), "project" (repo-local only).
263    /// Override via LEAN_CTX_RULES_SCOPE env var.
264    #[serde(default)]
265    pub rules_scope: Option<String>,
266    /// Extra glob patterns to ignore in graph/overview/preload (repo-local).
267    /// Example: `["externals/**", "target/**", "temp/**"]`
268    #[serde(default)]
269    pub extra_ignore_patterns: Vec<String>,
270    /// Controls agent output verbosity via instructions injection.
271    /// Values: "off" (default), "lite", "full", "ultra".
272    /// Override via LEAN_CTX_TERSE_AGENT env var.
273    #[serde(default)]
274    pub terse_agent: TerseAgent,
275    /// Unified compression level (replaces separate terse_agent + output_density).
276    /// Values: "off" (default), "lite", "standard", "max".
277    /// Override via LEAN_CTX_COMPRESSION env var.
278    #[serde(default)]
279    pub compression_level: CompressionLevel,
280    /// Archive configuration for zero-loss compression.
281    #[serde(default)]
282    pub archive: ArchiveConfig,
283    /// Memory policy (knowledge/episodic/procedural/lifecycle budgets & thresholds).
284    #[serde(default)]
285    pub memory: MemoryPolicy,
286    /// Additional paths allowed by PathJail (absolute).
287    /// Useful for multi-project workspaces where the jail root is a parent directory.
288    /// Override via LEAN_CTX_ALLOW_PATH env var (path-list separator).
289    #[serde(default)]
290    pub allow_paths: Vec<String>,
291    /// Enable content-defined chunking (Rabin-Karp) for cache-optimal output ordering.
292    /// Stable chunks are emitted first to maximize prompt cache hits.
293    #[serde(default)]
294    pub content_defined_chunking: bool,
295    /// Skip session/knowledge/gotcha blocks in MCP instructions to minimize token overhead.
296    /// Override via LEAN_CTX_MINIMAL env var.
297    #[serde(default)]
298    pub minimal_overhead: bool,
299    /// Disable shell hook injection (the _lc() function that wraps CLI commands).
300    /// Override via LEAN_CTX_NO_HOOK env var.
301    #[serde(default)]
302    pub shell_hook_disabled: bool,
303    /// Controls when the shell hook auto-activates aliases.
304    /// - `always`: (Default) Aliases active in every interactive shell.
305    /// - `agents-only`: Aliases only active when an AI agent env var is detected.
306    /// - `off`: Aliases never auto-activate (user must call `lean-ctx-on` manually).
307    ///
308    /// Override via `LEAN_CTX_SHELL_ACTIVATION` env var.
309    #[serde(default)]
310    pub shell_activation: ShellActivation,
311    /// Disable the daily version check against leanctx.com/version.txt.
312    /// Override via LEAN_CTX_NO_UPDATE_CHECK env var.
313    #[serde(default)]
314    pub update_check_disabled: bool,
315    /// Maximum BM25 cache file size in MB. Indexes exceeding this are quarantined on load
316    /// and refused on save. Override via LEAN_CTX_BM25_MAX_CACHE_MB env var.
317    #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
318    pub bm25_max_cache_mb: u64,
319    /// Controls RAM vs feature trade-off. Values: "low", "balanced" (default), "performance".
320    /// Override via LEAN_CTX_MEMORY_PROFILE env var.
321    #[serde(default)]
322    pub memory_profile: MemoryProfile,
323    /// Controls how aggressively memory is freed when idle.
324    /// Values: "aggressive" (default, 5 min TTL), "shared" (30 min TTL for multi-IDE use).
325    /// Override via LEAN_CTX_MEMORY_CLEANUP env var.
326    #[serde(default)]
327    pub memory_cleanup: MemoryCleanup,
328}
329
330/// Settings for the zero-loss compression archive (large tool outputs saved to disk).
331#[derive(Debug, Clone, Serialize, Deserialize)]
332#[serde(default)]
333pub struct ArchiveConfig {
334    pub enabled: bool,
335    pub threshold_chars: usize,
336    pub max_age_hours: u64,
337    pub max_disk_mb: u64,
338}
339
340impl Default for ArchiveConfig {
341    fn default() -> Self {
342        Self {
343            enabled: true,
344            threshold_chars: 4096,
345            max_age_hours: 48,
346            max_disk_mb: 500,
347        }
348    }
349}
350
351/// Controls autonomous background behaviors (preload, dedup, consolidation).
352#[derive(Debug, Clone, Serialize, Deserialize)]
353#[serde(default)]
354pub struct AutonomyConfig {
355    pub enabled: bool,
356    pub auto_preload: bool,
357    pub auto_dedup: bool,
358    pub auto_related: bool,
359    pub auto_consolidate: bool,
360    pub silent_preload: bool,
361    pub dedup_threshold: usize,
362    pub consolidate_every_calls: u32,
363    pub consolidate_cooldown_secs: u64,
364}
365
366impl Default for AutonomyConfig {
367    fn default() -> Self {
368        Self {
369            enabled: true,
370            auto_preload: true,
371            auto_dedup: true,
372            auto_related: true,
373            auto_consolidate: true,
374            silent_preload: true,
375            dedup_threshold: 8,
376            consolidate_every_calls: 25,
377            consolidate_cooldown_secs: 120,
378        }
379    }
380}
381
382impl AutonomyConfig {
383    /// Creates an autonomy config from env vars, falling back to defaults.
384    pub fn from_env() -> Self {
385        let mut cfg = Self::default();
386        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
387            if v == "false" || v == "0" {
388                cfg.enabled = false;
389            }
390        }
391        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
392            cfg.auto_preload = v != "false" && v != "0";
393        }
394        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
395            cfg.auto_dedup = v != "false" && v != "0";
396        }
397        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
398            cfg.auto_related = v != "false" && v != "0";
399        }
400        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
401            cfg.auto_consolidate = v != "false" && v != "0";
402        }
403        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
404            cfg.silent_preload = v != "false" && v != "0";
405        }
406        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
407            if let Ok(n) = v.parse() {
408                cfg.dedup_threshold = n;
409            }
410        }
411        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
412            if let Ok(n) = v.parse() {
413                cfg.consolidate_every_calls = n;
414            }
415        }
416        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
417            if let Ok(n) = v.parse() {
418                cfg.consolidate_cooldown_secs = n;
419            }
420        }
421        cfg
422    }
423
424    /// Loads autonomy config from disk, with env var overrides applied.
425    pub fn load() -> Self {
426        let file_cfg = Config::load().autonomy;
427        let mut cfg = file_cfg;
428        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
429            if v == "false" || v == "0" {
430                cfg.enabled = false;
431            }
432        }
433        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
434            cfg.auto_preload = v != "false" && v != "0";
435        }
436        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
437            cfg.auto_dedup = v != "false" && v != "0";
438        }
439        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
440            cfg.auto_related = v != "false" && v != "0";
441        }
442        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
443            cfg.silent_preload = v != "false" && v != "0";
444        }
445        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
446            if let Ok(n) = v.parse() {
447                cfg.dedup_threshold = n;
448            }
449        }
450        cfg
451    }
452}
453
454/// Cloud sync and contribution settings (pattern sharing, model pulls).
455#[derive(Debug, Clone, Serialize, Deserialize, Default)]
456#[serde(default)]
457pub struct CloudConfig {
458    pub contribute_enabled: bool,
459    pub last_contribute: Option<String>,
460    pub last_sync: Option<String>,
461    pub last_gain_sync: Option<String>,
462    pub last_model_pull: Option<String>,
463}
464
465/// A user-defined command alias mapping for shell compression patterns.
466#[derive(Debug, Clone, Serialize, Deserialize)]
467pub struct AliasEntry {
468    pub command: String,
469    pub alias: String,
470}
471
472/// Thresholds for detecting and throttling repetitive agent tool call loops.
473#[derive(Debug, Clone, Serialize, Deserialize)]
474#[serde(default)]
475pub struct LoopDetectionConfig {
476    pub normal_threshold: u32,
477    pub reduced_threshold: u32,
478    pub blocked_threshold: u32,
479    pub window_secs: u64,
480    pub search_group_limit: u32,
481}
482
483impl Default for LoopDetectionConfig {
484    fn default() -> Self {
485        Self {
486            normal_threshold: 2,
487            reduced_threshold: 4,
488            // 0 = blocking disabled (LeanCTX philosophy: always help, never block)
489            // Set to 6+ in config to enable blocking for strict governance
490            blocked_threshold: 0,
491            window_secs: 300,
492            search_group_limit: 10,
493        }
494    }
495}
496
497impl Default for Config {
498    fn default() -> Self {
499        Self {
500            ultra_compact: false,
501            tee_mode: TeeMode::default(),
502            output_density: OutputDensity::default(),
503            checkpoint_interval: 15,
504            excluded_commands: Vec::new(),
505            passthrough_urls: Vec::new(),
506            custom_aliases: Vec::new(),
507            slow_command_threshold_ms: 5000,
508            theme: serde_defaults::default_theme(),
509            cloud: CloudConfig::default(),
510            autonomy: AutonomyConfig::default(),
511            proxy: ProxyConfig::default(),
512            buddy_enabled: serde_defaults::default_buddy_enabled(),
513            redirect_exclude: Vec::new(),
514            disabled_tools: Vec::new(),
515            loop_detection: LoopDetectionConfig::default(),
516            rules_scope: None,
517            extra_ignore_patterns: Vec::new(),
518            terse_agent: TerseAgent::default(),
519            compression_level: CompressionLevel::default(),
520            archive: ArchiveConfig::default(),
521            memory: MemoryPolicy::default(),
522            allow_paths: Vec::new(),
523            content_defined_chunking: false,
524            minimal_overhead: false,
525            shell_hook_disabled: false,
526            shell_activation: ShellActivation::default(),
527            update_check_disabled: false,
528            bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
529            memory_profile: MemoryProfile::default(),
530            memory_cleanup: MemoryCleanup::default(),
531        }
532    }
533}
534
535/// Where agent rule files are installed: global home dir, project-local, or both.
536#[derive(Debug, Clone, Copy, PartialEq, Eq)]
537pub enum RulesScope {
538    Both,
539    Global,
540    Project,
541}
542
543impl Config {
544    /// Returns the effective rules scope, preferring env var over config file.
545    pub fn rules_scope_effective(&self) -> RulesScope {
546        let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
547            .ok()
548            .or_else(|| self.rules_scope.clone())
549            .unwrap_or_default();
550        match raw.trim().to_lowercase().as_str() {
551            "global" => RulesScope::Global,
552            "project" => RulesScope::Project,
553            _ => RulesScope::Both,
554        }
555    }
556
557    fn parse_disabled_tools_env(val: &str) -> Vec<String> {
558        val.split(',')
559            .map(|s| s.trim().to_string())
560            .filter(|s| !s.is_empty())
561            .collect()
562    }
563
564    /// Returns the effective disabled tools list, preferring env var over config file.
565    pub fn disabled_tools_effective(&self) -> Vec<String> {
566        if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
567            Self::parse_disabled_tools_env(&val)
568        } else {
569            self.disabled_tools.clone()
570        }
571    }
572
573    /// Returns `true` if minimal overhead is enabled via env var or config.
574    pub fn minimal_overhead_effective(&self) -> bool {
575        std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
576    }
577
578    /// Returns `true` if minimal overhead should be enabled for this MCP client.
579    ///
580    /// This is a superset of `minimal_overhead_effective()`:
581    /// - `LEAN_CTX_OVERHEAD_MODE=minimal` forces minimal overhead
582    /// - `LEAN_CTX_OVERHEAD_MODE=full` disables client/model heuristics (still honors LEAN_CTX_MINIMAL / config)
583    /// - In auto mode (default), certain low-context clients/models are treated as minimal to prevent
584    ///   large metadata blocks from destabilizing smaller context windows (e.g. Hermes + MiniMax).
585    pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
586        if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
587            match raw.trim().to_lowercase().as_str() {
588                "minimal" => return true,
589                "full" => return self.minimal_overhead_effective(),
590                _ => {}
591            }
592        }
593
594        if self.minimal_overhead_effective() {
595            return true;
596        }
597
598        let client_lower = client_name.trim().to_lowercase();
599        if !client_lower.is_empty() {
600            if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
601                for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
602                    if !needle.is_empty() && client_lower.contains(&needle) {
603                        return true;
604                    }
605                }
606            } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
607                return true;
608            }
609        }
610
611        let model = std::env::var("LEAN_CTX_MODEL")
612            .or_else(|_| std::env::var("LCTX_MODEL"))
613            .unwrap_or_default();
614        let model = model.trim().to_lowercase();
615        if !model.is_empty() {
616            let m = model.replace(['_', ' '], "-");
617            if m.contains("minimax")
618                || m.contains("mini-max")
619                || m.contains("m2.7")
620                || m.contains("m2-7")
621            {
622                return true;
623            }
624        }
625
626        false
627    }
628
629    /// Returns `true` if shell hook injection is disabled via env var or config.
630    pub fn shell_hook_disabled_effective(&self) -> bool {
631        std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
632    }
633
634    /// Returns the effective shell activation mode (env var > config > default).
635    pub fn shell_activation_effective(&self) -> ShellActivation {
636        ShellActivation::effective(self)
637    }
638
639    /// Returns `true` if the daily update check is disabled via env var or config.
640    pub fn update_check_disabled_effective(&self) -> bool {
641        std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
642    }
643
644    pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
645        let mut policy = self.memory.clone();
646        policy.apply_env_overrides();
647        policy.validate()?;
648        Ok(policy)
649    }
650}
651
652#[cfg(test)]
653mod disabled_tools_tests {
654    use super::*;
655
656    #[test]
657    fn config_field_default_is_empty() {
658        let cfg = Config::default();
659        assert!(cfg.disabled_tools.is_empty());
660    }
661
662    #[test]
663    fn effective_returns_config_field_when_no_env_var() {
664        // Only meaningful when LEAN_CTX_DISABLED_TOOLS is unset; skip otherwise.
665        if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
666            return;
667        }
668        let cfg = Config {
669            disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
670            ..Default::default()
671        };
672        assert_eq!(
673            cfg.disabled_tools_effective(),
674            vec!["ctx_graph", "ctx_agent"]
675        );
676    }
677
678    #[test]
679    fn parse_env_basic() {
680        let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
681        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
682    }
683
684    #[test]
685    fn parse_env_trims_whitespace_and_skips_empty() {
686        let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
687        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
688    }
689
690    #[test]
691    fn parse_env_single_entry() {
692        let result = Config::parse_disabled_tools_env("ctx_graph");
693        assert_eq!(result, vec!["ctx_graph"]);
694    }
695
696    #[test]
697    fn parse_env_empty_string_returns_empty() {
698        let result = Config::parse_disabled_tools_env("");
699        assert!(result.is_empty());
700    }
701
702    #[test]
703    fn disabled_tools_deserialization_defaults_to_empty() {
704        let cfg: Config = toml::from_str("").unwrap();
705        assert!(cfg.disabled_tools.is_empty());
706    }
707
708    #[test]
709    fn disabled_tools_deserialization_from_toml() {
710        let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
711        assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
712    }
713}
714
715#[cfg(test)]
716mod rules_scope_tests {
717    use super::*;
718
719    #[test]
720    fn default_is_both() {
721        let cfg = Config::default();
722        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
723    }
724
725    #[test]
726    fn config_global() {
727        let cfg = Config {
728            rules_scope: Some("global".to_string()),
729            ..Default::default()
730        };
731        assert_eq!(cfg.rules_scope_effective(), RulesScope::Global);
732    }
733
734    #[test]
735    fn config_project() {
736        let cfg = Config {
737            rules_scope: Some("project".to_string()),
738            ..Default::default()
739        };
740        assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
741    }
742
743    #[test]
744    fn unknown_value_falls_back_to_both() {
745        let cfg = Config {
746            rules_scope: Some("nonsense".to_string()),
747            ..Default::default()
748        };
749        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
750    }
751
752    #[test]
753    fn deserialization_none_by_default() {
754        let cfg: Config = toml::from_str("").unwrap();
755        assert!(cfg.rules_scope.is_none());
756        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
757    }
758
759    #[test]
760    fn deserialization_from_toml() {
761        let cfg: Config = toml::from_str(r#"rules_scope = "project""#).unwrap();
762        assert_eq!(cfg.rules_scope.as_deref(), Some("project"));
763        assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
764    }
765}
766
767#[cfg(test)]
768mod loop_detection_config_tests {
769    use super::*;
770
771    #[test]
772    fn defaults_are_reasonable() {
773        let cfg = LoopDetectionConfig::default();
774        assert_eq!(cfg.normal_threshold, 2);
775        assert_eq!(cfg.reduced_threshold, 4);
776        // 0 = blocking disabled by default (LeanCTX philosophy: always help, never block)
777        assert_eq!(cfg.blocked_threshold, 0);
778        assert_eq!(cfg.window_secs, 300);
779        assert_eq!(cfg.search_group_limit, 10);
780    }
781
782    #[test]
783    fn deserialization_defaults_when_missing() {
784        let cfg: Config = toml::from_str("").unwrap();
785        // 0 = blocking disabled by default
786        assert_eq!(cfg.loop_detection.blocked_threshold, 0);
787        assert_eq!(cfg.loop_detection.search_group_limit, 10);
788    }
789
790    #[test]
791    fn deserialization_from_toml() {
792        let cfg: Config = toml::from_str(
793            r"
794            [loop_detection]
795            normal_threshold = 1
796            reduced_threshold = 3
797            blocked_threshold = 5
798            window_secs = 120
799            search_group_limit = 8
800            ",
801        )
802        .unwrap();
803        assert_eq!(cfg.loop_detection.normal_threshold, 1);
804        assert_eq!(cfg.loop_detection.reduced_threshold, 3);
805        assert_eq!(cfg.loop_detection.blocked_threshold, 5);
806        assert_eq!(cfg.loop_detection.window_secs, 120);
807        assert_eq!(cfg.loop_detection.search_group_limit, 8);
808    }
809
810    #[test]
811    fn partial_override_keeps_defaults() {
812        let cfg: Config = toml::from_str(
813            r"
814            [loop_detection]
815            blocked_threshold = 10
816            ",
817        )
818        .unwrap();
819        assert_eq!(cfg.loop_detection.blocked_threshold, 10);
820        assert_eq!(cfg.loop_detection.normal_threshold, 2);
821        assert_eq!(cfg.loop_detection.search_group_limit, 10);
822    }
823}
824
825impl Config {
826    /// Returns the path to the global config file (`~/.lean-ctx/config.toml`).
827    pub fn path() -> Option<PathBuf> {
828        crate::core::data_dir::lean_ctx_data_dir()
829            .ok()
830            .map(|d| d.join("config.toml"))
831    }
832
833    /// Returns the path to the project-local config override file.
834    pub fn local_path(project_root: &str) -> PathBuf {
835        PathBuf::from(project_root).join(".lean-ctx.toml")
836    }
837
838    fn find_project_root() -> Option<String> {
839        let cwd = std::env::current_dir().ok();
840
841        if let Some(root) =
842            crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
843        {
844            let root_path = std::path::Path::new(&root);
845            let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
846            let has_marker = root_path.join(".git").exists()
847                || root_path.join("Cargo.toml").exists()
848                || root_path.join("package.json").exists()
849                || root_path.join("go.mod").exists()
850                || root_path.join("pyproject.toml").exists()
851                || root_path.join(".lean-ctx.toml").exists();
852
853            if cwd_is_under_root || has_marker {
854                return Some(root);
855            }
856        }
857
858        if let Some(ref cwd) = cwd {
859            let git_root = std::process::Command::new("git")
860                .args(["rev-parse", "--show-toplevel"])
861                .current_dir(cwd)
862                .stdout(std::process::Stdio::piped())
863                .stderr(std::process::Stdio::null())
864                .output()
865                .ok()
866                .and_then(|o| {
867                    if o.status.success() {
868                        String::from_utf8(o.stdout)
869                            .ok()
870                            .map(|s| s.trim().to_string())
871                    } else {
872                        None
873                    }
874                });
875            if let Some(root) = git_root {
876                return Some(root);
877            }
878            return Some(cwd.to_string_lossy().to_string());
879        }
880        None
881    }
882
883    /// Loads config from disk with caching, merging global + project-local overrides.
884    pub fn load() -> Self {
885        static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
886
887        let Some(path) = Self::path() else {
888            return Self::default();
889        };
890
891        let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
892
893        let mtime = std::fs::metadata(&path)
894            .and_then(|m| m.modified())
895            .unwrap_or(SystemTime::UNIX_EPOCH);
896
897        let local_mtime = local_path
898            .as_ref()
899            .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
900
901        if let Ok(guard) = CACHE.lock() {
902            if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
903                if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
904                    return cfg.clone();
905                }
906            }
907        }
908
909        let mut cfg: Config = match std::fs::read_to_string(&path) {
910            Ok(content) => match toml::from_str(&content) {
911                Ok(c) => c,
912                Err(e) => {
913                    tracing::warn!("config parse error in {}: {e}", path.display());
914                    Self::default()
915                }
916            },
917            Err(_) => Self::default(),
918        };
919
920        if let Some(ref lp) = local_path {
921            if let Ok(local_content) = std::fs::read_to_string(lp) {
922                cfg.merge_local(&local_content);
923            }
924        }
925
926        if let Ok(mut guard) = CACHE.lock() {
927            *guard = Some((cfg.clone(), mtime, local_mtime));
928        }
929
930        cfg
931    }
932
933    fn merge_local(&mut self, local_toml: &str) {
934        let local: Config = match toml::from_str(local_toml) {
935            Ok(c) => c,
936            Err(e) => {
937                tracing::warn!("local config parse error: {e}");
938                return;
939            }
940        };
941        if local.ultra_compact {
942            self.ultra_compact = true;
943        }
944        if local.tee_mode != TeeMode::default() {
945            self.tee_mode = local.tee_mode;
946        }
947        if local.output_density != OutputDensity::default() {
948            self.output_density = local.output_density;
949        }
950        if local.checkpoint_interval != 15 {
951            self.checkpoint_interval = local.checkpoint_interval;
952        }
953        if !local.excluded_commands.is_empty() {
954            self.excluded_commands.extend(local.excluded_commands);
955        }
956        if !local.passthrough_urls.is_empty() {
957            self.passthrough_urls.extend(local.passthrough_urls);
958        }
959        if !local.custom_aliases.is_empty() {
960            self.custom_aliases.extend(local.custom_aliases);
961        }
962        if local.slow_command_threshold_ms != 5000 {
963            self.slow_command_threshold_ms = local.slow_command_threshold_ms;
964        }
965        if local.theme != "default" {
966            self.theme = local.theme;
967        }
968        if !local.buddy_enabled {
969            self.buddy_enabled = false;
970        }
971        if !local.redirect_exclude.is_empty() {
972            self.redirect_exclude.extend(local.redirect_exclude);
973        }
974        if !local.disabled_tools.is_empty() {
975            self.disabled_tools.extend(local.disabled_tools);
976        }
977        if !local.extra_ignore_patterns.is_empty() {
978            self.extra_ignore_patterns
979                .extend(local.extra_ignore_patterns);
980        }
981        if local.rules_scope.is_some() {
982            self.rules_scope = local.rules_scope;
983        }
984        if local.proxy.anthropic_upstream.is_some() {
985            self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
986        }
987        if local.proxy.openai_upstream.is_some() {
988            self.proxy.openai_upstream = local.proxy.openai_upstream;
989        }
990        if local.proxy.gemini_upstream.is_some() {
991            self.proxy.gemini_upstream = local.proxy.gemini_upstream;
992        }
993        if !local.autonomy.enabled {
994            self.autonomy.enabled = false;
995        }
996        if !local.autonomy.auto_preload {
997            self.autonomy.auto_preload = false;
998        }
999        if !local.autonomy.auto_dedup {
1000            self.autonomy.auto_dedup = false;
1001        }
1002        if !local.autonomy.auto_related {
1003            self.autonomy.auto_related = false;
1004        }
1005        if !local.autonomy.auto_consolidate {
1006            self.autonomy.auto_consolidate = false;
1007        }
1008        if local.autonomy.silent_preload {
1009            self.autonomy.silent_preload = true;
1010        }
1011        if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1012            self.autonomy.silent_preload = false;
1013        }
1014        if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1015            self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1016        }
1017        if local.autonomy.consolidate_every_calls
1018            != AutonomyConfig::default().consolidate_every_calls
1019        {
1020            self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1021        }
1022        if local.autonomy.consolidate_cooldown_secs
1023            != AutonomyConfig::default().consolidate_cooldown_secs
1024        {
1025            self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1026        }
1027        if local.compression_level != CompressionLevel::default() {
1028            self.compression_level = local.compression_level;
1029        }
1030        if local.terse_agent != TerseAgent::default() {
1031            self.terse_agent = local.terse_agent;
1032        }
1033        if !local.archive.enabled {
1034            self.archive.enabled = false;
1035        }
1036        if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1037            self.archive.threshold_chars = local.archive.threshold_chars;
1038        }
1039        if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1040            self.archive.max_age_hours = local.archive.max_age_hours;
1041        }
1042        if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1043            self.archive.max_disk_mb = local.archive.max_disk_mb;
1044        }
1045        let mem_def = MemoryPolicy::default();
1046        if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1047            self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1048        }
1049        if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1050            self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1051        }
1052        if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1053            self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1054        }
1055        if local.memory.knowledge.contradiction_threshold
1056            != mem_def.knowledge.contradiction_threshold
1057        {
1058            self.memory.knowledge.contradiction_threshold =
1059                local.memory.knowledge.contradiction_threshold;
1060        }
1061
1062        if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1063            self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1064        }
1065        if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1066        {
1067            self.memory.episodic.max_actions_per_episode =
1068                local.memory.episodic.max_actions_per_episode;
1069        }
1070        if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1071            self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1072        }
1073
1074        if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1075            self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1076        }
1077        if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1078            self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1079        }
1080        if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1081            self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1082        }
1083        if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1084            self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1085        }
1086
1087        if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1088            self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1089        }
1090        if local.memory.lifecycle.low_confidence_threshold
1091            != mem_def.lifecycle.low_confidence_threshold
1092        {
1093            self.memory.lifecycle.low_confidence_threshold =
1094                local.memory.lifecycle.low_confidence_threshold;
1095        }
1096        if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1097            self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1098        }
1099        if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1100            self.memory.lifecycle.similarity_threshold =
1101                local.memory.lifecycle.similarity_threshold;
1102        }
1103
1104        if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1105            self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1106        }
1107        if !local.allow_paths.is_empty() {
1108            self.allow_paths.extend(local.allow_paths);
1109        }
1110        if local.minimal_overhead {
1111            self.minimal_overhead = true;
1112        }
1113        if local.shell_hook_disabled {
1114            self.shell_hook_disabled = true;
1115        }
1116        if local.shell_activation != ShellActivation::default() {
1117            self.shell_activation = local.shell_activation.clone();
1118        }
1119        if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1120            self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1121        }
1122        if local.memory_profile != MemoryProfile::default() {
1123            self.memory_profile = local.memory_profile;
1124        }
1125        if local.memory_cleanup != MemoryCleanup::default() {
1126            self.memory_cleanup = local.memory_cleanup;
1127        }
1128    }
1129
1130    /// Persists the current config to the global config file.
1131    pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1132        let path = Self::path().ok_or_else(|| {
1133            super::error::LeanCtxError::Config("cannot determine home directory".into())
1134        })?;
1135        if let Some(parent) = path.parent() {
1136            std::fs::create_dir_all(parent)?;
1137        }
1138        let content = toml::to_string_pretty(self)
1139            .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1140        std::fs::write(&path, content)?;
1141        Ok(())
1142    }
1143
1144    /// Formats the current config as a human-readable string with file paths.
1145    pub fn show(&self) -> String {
1146        let global_path = Self::path().map_or_else(
1147            || "~/.lean-ctx/config.toml".to_string(),
1148            |p| p.to_string_lossy().to_string(),
1149        );
1150        let content = toml::to_string_pretty(self).unwrap_or_default();
1151        let mut out = format!("Global config: {global_path}\n\n{content}");
1152
1153        if let Some(root) = Self::find_project_root() {
1154            let local = Self::local_path(&root);
1155            if local.exists() {
1156                out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1157            } else {
1158                out.push_str(&format!(
1159                    "\n\nLocal config: not found (create {} to override per-project)\n",
1160                    local.display()
1161                ));
1162            }
1163        }
1164        out
1165    }
1166}
1167
1168#[cfg(test)]
1169mod compression_level_tests {
1170    use super::*;
1171
1172    #[test]
1173    fn default_is_off() {
1174        assert_eq!(CompressionLevel::default(), CompressionLevel::Off);
1175    }
1176
1177    #[test]
1178    fn to_components_off() {
1179        let (ta, od, crp, tm) = CompressionLevel::Off.to_components();
1180        assert_eq!(ta, TerseAgent::Off);
1181        assert_eq!(od, OutputDensity::Normal);
1182        assert_eq!(crp, "off");
1183        assert!(!tm);
1184    }
1185
1186    #[test]
1187    fn to_components_lite() {
1188        let (ta, od, crp, tm) = CompressionLevel::Lite.to_components();
1189        assert_eq!(ta, TerseAgent::Lite);
1190        assert_eq!(od, OutputDensity::Terse);
1191        assert_eq!(crp, "off");
1192        assert!(tm);
1193    }
1194
1195    #[test]
1196    fn to_components_standard() {
1197        let (ta, od, crp, tm) = CompressionLevel::Standard.to_components();
1198        assert_eq!(ta, TerseAgent::Full);
1199        assert_eq!(od, OutputDensity::Terse);
1200        assert_eq!(crp, "compact");
1201        assert!(tm);
1202    }
1203
1204    #[test]
1205    fn to_components_max() {
1206        let (ta, od, crp, tm) = CompressionLevel::Max.to_components();
1207        assert_eq!(ta, TerseAgent::Ultra);
1208        assert_eq!(od, OutputDensity::Ultra);
1209        assert_eq!(crp, "tdd");
1210        assert!(tm);
1211    }
1212
1213    #[test]
1214    fn from_legacy_ultra_agent_maps_to_max() {
1215        assert_eq!(
1216            CompressionLevel::from_legacy(&TerseAgent::Ultra, &OutputDensity::Normal),
1217            CompressionLevel::Max
1218        );
1219    }
1220
1221    #[test]
1222    fn from_legacy_ultra_density_maps_to_max() {
1223        assert_eq!(
1224            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Ultra),
1225            CompressionLevel::Max
1226        );
1227    }
1228
1229    #[test]
1230    fn from_legacy_full_agent_maps_to_standard() {
1231        assert_eq!(
1232            CompressionLevel::from_legacy(&TerseAgent::Full, &OutputDensity::Normal),
1233            CompressionLevel::Standard
1234        );
1235    }
1236
1237    #[test]
1238    fn from_legacy_lite_agent_maps_to_lite() {
1239        assert_eq!(
1240            CompressionLevel::from_legacy(&TerseAgent::Lite, &OutputDensity::Normal),
1241            CompressionLevel::Lite
1242        );
1243    }
1244
1245    #[test]
1246    fn from_legacy_terse_density_maps_to_lite() {
1247        assert_eq!(
1248            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Terse),
1249            CompressionLevel::Lite
1250        );
1251    }
1252
1253    #[test]
1254    fn from_legacy_both_off_maps_to_off() {
1255        assert_eq!(
1256            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Normal),
1257            CompressionLevel::Off
1258        );
1259    }
1260
1261    #[test]
1262    fn labels_match() {
1263        assert_eq!(CompressionLevel::Off.label(), "off");
1264        assert_eq!(CompressionLevel::Lite.label(), "lite");
1265        assert_eq!(CompressionLevel::Standard.label(), "standard");
1266        assert_eq!(CompressionLevel::Max.label(), "max");
1267    }
1268
1269    #[test]
1270    fn is_active_false_for_off() {
1271        assert!(!CompressionLevel::Off.is_active());
1272    }
1273
1274    #[test]
1275    fn is_active_true_for_all_others() {
1276        assert!(CompressionLevel::Lite.is_active());
1277        assert!(CompressionLevel::Standard.is_active());
1278        assert!(CompressionLevel::Max.is_active());
1279    }
1280
1281    #[test]
1282    fn deserialization_defaults_to_off() {
1283        let cfg: Config = toml::from_str("").unwrap();
1284        assert_eq!(cfg.compression_level, CompressionLevel::Off);
1285    }
1286
1287    #[test]
1288    fn deserialization_from_toml() {
1289        let cfg: Config = toml::from_str(r#"compression_level = "standard""#).unwrap();
1290        assert_eq!(cfg.compression_level, CompressionLevel::Standard);
1291    }
1292
1293    #[test]
1294    fn roundtrip_all_levels() {
1295        for level in [
1296            CompressionLevel::Off,
1297            CompressionLevel::Lite,
1298            CompressionLevel::Standard,
1299            CompressionLevel::Max,
1300        ] {
1301            let (ta, od, crp, tm) = level.to_components();
1302            assert!(!crp.is_empty());
1303            if level == CompressionLevel::Off {
1304                assert!(!tm);
1305                assert_eq!(ta, TerseAgent::Off);
1306                assert_eq!(od, OutputDensity::Normal);
1307            } else {
1308                assert!(tm);
1309            }
1310        }
1311    }
1312}
1313
1314#[cfg(test)]
1315mod memory_cleanup_tests {
1316    use super::*;
1317
1318    #[test]
1319    fn default_is_aggressive() {
1320        assert_eq!(MemoryCleanup::default(), MemoryCleanup::Aggressive);
1321    }
1322
1323    #[test]
1324    fn aggressive_ttl_is_300() {
1325        assert_eq!(MemoryCleanup::Aggressive.idle_ttl_secs(), 300);
1326    }
1327
1328    #[test]
1329    fn shared_ttl_is_1800() {
1330        assert_eq!(MemoryCleanup::Shared.idle_ttl_secs(), 1800);
1331    }
1332
1333    #[test]
1334    fn index_retention_multiplier_values() {
1335        assert!(
1336            (MemoryCleanup::Aggressive.index_retention_multiplier() - 1.0).abs() < f64::EPSILON
1337        );
1338        assert!((MemoryCleanup::Shared.index_retention_multiplier() - 3.0).abs() < f64::EPSILON);
1339    }
1340
1341    #[test]
1342    fn deserialization_defaults_to_aggressive() {
1343        let cfg: Config = toml::from_str("").unwrap();
1344        assert_eq!(cfg.memory_cleanup, MemoryCleanup::Aggressive);
1345    }
1346
1347    #[test]
1348    fn deserialization_from_toml() {
1349        let cfg: Config = toml::from_str(r#"memory_cleanup = "shared""#).unwrap();
1350        assert_eq!(cfg.memory_cleanup, MemoryCleanup::Shared);
1351    }
1352
1353    #[test]
1354    fn effective_uses_config_when_no_env() {
1355        let cfg = Config {
1356            memory_cleanup: MemoryCleanup::Shared,
1357            ..Default::default()
1358        };
1359        let eff = MemoryCleanup::effective(&cfg);
1360        assert_eq!(eff, MemoryCleanup::Shared);
1361    }
1362}