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) => toml::from_str(&content).unwrap_or_default(),
911            Err(_) => Self::default(),
912        };
913
914        if let Some(ref lp) = local_path {
915            if let Ok(local_content) = std::fs::read_to_string(lp) {
916                cfg.merge_local(&local_content);
917            }
918        }
919
920        if let Ok(mut guard) = CACHE.lock() {
921            *guard = Some((cfg.clone(), mtime, local_mtime));
922        }
923
924        cfg
925    }
926
927    fn merge_local(&mut self, local_toml: &str) {
928        let local: Config = match toml::from_str(local_toml) {
929            Ok(c) => c,
930            Err(_) => return,
931        };
932        if local.ultra_compact {
933            self.ultra_compact = true;
934        }
935        if local.tee_mode != TeeMode::default() {
936            self.tee_mode = local.tee_mode;
937        }
938        if local.output_density != OutputDensity::default() {
939            self.output_density = local.output_density;
940        }
941        if local.checkpoint_interval != 15 {
942            self.checkpoint_interval = local.checkpoint_interval;
943        }
944        if !local.excluded_commands.is_empty() {
945            self.excluded_commands.extend(local.excluded_commands);
946        }
947        if !local.passthrough_urls.is_empty() {
948            self.passthrough_urls.extend(local.passthrough_urls);
949        }
950        if !local.custom_aliases.is_empty() {
951            self.custom_aliases.extend(local.custom_aliases);
952        }
953        if local.slow_command_threshold_ms != 5000 {
954            self.slow_command_threshold_ms = local.slow_command_threshold_ms;
955        }
956        if local.theme != "default" {
957            self.theme = local.theme;
958        }
959        if !local.buddy_enabled {
960            self.buddy_enabled = false;
961        }
962        if !local.redirect_exclude.is_empty() {
963            self.redirect_exclude.extend(local.redirect_exclude);
964        }
965        if !local.disabled_tools.is_empty() {
966            self.disabled_tools.extend(local.disabled_tools);
967        }
968        if !local.extra_ignore_patterns.is_empty() {
969            self.extra_ignore_patterns
970                .extend(local.extra_ignore_patterns);
971        }
972        if local.rules_scope.is_some() {
973            self.rules_scope = local.rules_scope;
974        }
975        if local.proxy.anthropic_upstream.is_some() {
976            self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
977        }
978        if local.proxy.openai_upstream.is_some() {
979            self.proxy.openai_upstream = local.proxy.openai_upstream;
980        }
981        if local.proxy.gemini_upstream.is_some() {
982            self.proxy.gemini_upstream = local.proxy.gemini_upstream;
983        }
984        if !local.autonomy.enabled {
985            self.autonomy.enabled = false;
986        }
987        if !local.autonomy.auto_preload {
988            self.autonomy.auto_preload = false;
989        }
990        if !local.autonomy.auto_dedup {
991            self.autonomy.auto_dedup = false;
992        }
993        if !local.autonomy.auto_related {
994            self.autonomy.auto_related = false;
995        }
996        if !local.autonomy.auto_consolidate {
997            self.autonomy.auto_consolidate = false;
998        }
999        if local.autonomy.silent_preload {
1000            self.autonomy.silent_preload = true;
1001        }
1002        if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1003            self.autonomy.silent_preload = false;
1004        }
1005        if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1006            self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1007        }
1008        if local.autonomy.consolidate_every_calls
1009            != AutonomyConfig::default().consolidate_every_calls
1010        {
1011            self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1012        }
1013        if local.autonomy.consolidate_cooldown_secs
1014            != AutonomyConfig::default().consolidate_cooldown_secs
1015        {
1016            self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1017        }
1018        if local.compression_level != CompressionLevel::default() {
1019            self.compression_level = local.compression_level;
1020        }
1021        if local.terse_agent != TerseAgent::default() {
1022            self.terse_agent = local.terse_agent;
1023        }
1024        if !local.archive.enabled {
1025            self.archive.enabled = false;
1026        }
1027        if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1028            self.archive.threshold_chars = local.archive.threshold_chars;
1029        }
1030        if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1031            self.archive.max_age_hours = local.archive.max_age_hours;
1032        }
1033        if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1034            self.archive.max_disk_mb = local.archive.max_disk_mb;
1035        }
1036        let mem_def = MemoryPolicy::default();
1037        if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1038            self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1039        }
1040        if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1041            self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1042        }
1043        if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1044            self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1045        }
1046        if local.memory.knowledge.contradiction_threshold
1047            != mem_def.knowledge.contradiction_threshold
1048        {
1049            self.memory.knowledge.contradiction_threshold =
1050                local.memory.knowledge.contradiction_threshold;
1051        }
1052
1053        if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1054            self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1055        }
1056        if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1057        {
1058            self.memory.episodic.max_actions_per_episode =
1059                local.memory.episodic.max_actions_per_episode;
1060        }
1061        if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1062            self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1063        }
1064
1065        if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1066            self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1067        }
1068        if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1069            self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1070        }
1071        if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1072            self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1073        }
1074        if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1075            self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1076        }
1077
1078        if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1079            self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1080        }
1081        if local.memory.lifecycle.low_confidence_threshold
1082            != mem_def.lifecycle.low_confidence_threshold
1083        {
1084            self.memory.lifecycle.low_confidence_threshold =
1085                local.memory.lifecycle.low_confidence_threshold;
1086        }
1087        if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1088            self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1089        }
1090        if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1091            self.memory.lifecycle.similarity_threshold =
1092                local.memory.lifecycle.similarity_threshold;
1093        }
1094
1095        if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1096            self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1097        }
1098        if !local.allow_paths.is_empty() {
1099            self.allow_paths.extend(local.allow_paths);
1100        }
1101        if local.minimal_overhead {
1102            self.minimal_overhead = true;
1103        }
1104        if local.shell_hook_disabled {
1105            self.shell_hook_disabled = true;
1106        }
1107        if local.shell_activation != ShellActivation::default() {
1108            self.shell_activation = local.shell_activation.clone();
1109        }
1110        if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1111            self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1112        }
1113        if local.memory_profile != MemoryProfile::default() {
1114            self.memory_profile = local.memory_profile;
1115        }
1116        if local.memory_cleanup != MemoryCleanup::default() {
1117            self.memory_cleanup = local.memory_cleanup;
1118        }
1119    }
1120
1121    /// Persists the current config to the global config file.
1122    pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1123        let path = Self::path().ok_or_else(|| {
1124            super::error::LeanCtxError::Config("cannot determine home directory".into())
1125        })?;
1126        if let Some(parent) = path.parent() {
1127            std::fs::create_dir_all(parent)?;
1128        }
1129        let content = toml::to_string_pretty(self)
1130            .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1131        std::fs::write(&path, content)?;
1132        Ok(())
1133    }
1134
1135    /// Formats the current config as a human-readable string with file paths.
1136    pub fn show(&self) -> String {
1137        let global_path = Self::path().map_or_else(
1138            || "~/.lean-ctx/config.toml".to_string(),
1139            |p| p.to_string_lossy().to_string(),
1140        );
1141        let content = toml::to_string_pretty(self).unwrap_or_default();
1142        let mut out = format!("Global config: {global_path}\n\n{content}");
1143
1144        if let Some(root) = Self::find_project_root() {
1145            let local = Self::local_path(&root);
1146            if local.exists() {
1147                out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1148            } else {
1149                out.push_str(&format!(
1150                    "\n\nLocal config: not found (create {} to override per-project)\n",
1151                    local.display()
1152                ));
1153            }
1154        }
1155        out
1156    }
1157}
1158
1159#[cfg(test)]
1160mod compression_level_tests {
1161    use super::*;
1162
1163    #[test]
1164    fn default_is_off() {
1165        assert_eq!(CompressionLevel::default(), CompressionLevel::Off);
1166    }
1167
1168    #[test]
1169    fn to_components_off() {
1170        let (ta, od, crp, tm) = CompressionLevel::Off.to_components();
1171        assert_eq!(ta, TerseAgent::Off);
1172        assert_eq!(od, OutputDensity::Normal);
1173        assert_eq!(crp, "off");
1174        assert!(!tm);
1175    }
1176
1177    #[test]
1178    fn to_components_lite() {
1179        let (ta, od, crp, tm) = CompressionLevel::Lite.to_components();
1180        assert_eq!(ta, TerseAgent::Lite);
1181        assert_eq!(od, OutputDensity::Terse);
1182        assert_eq!(crp, "off");
1183        assert!(tm);
1184    }
1185
1186    #[test]
1187    fn to_components_standard() {
1188        let (ta, od, crp, tm) = CompressionLevel::Standard.to_components();
1189        assert_eq!(ta, TerseAgent::Full);
1190        assert_eq!(od, OutputDensity::Terse);
1191        assert_eq!(crp, "compact");
1192        assert!(tm);
1193    }
1194
1195    #[test]
1196    fn to_components_max() {
1197        let (ta, od, crp, tm) = CompressionLevel::Max.to_components();
1198        assert_eq!(ta, TerseAgent::Ultra);
1199        assert_eq!(od, OutputDensity::Ultra);
1200        assert_eq!(crp, "tdd");
1201        assert!(tm);
1202    }
1203
1204    #[test]
1205    fn from_legacy_ultra_agent_maps_to_max() {
1206        assert_eq!(
1207            CompressionLevel::from_legacy(&TerseAgent::Ultra, &OutputDensity::Normal),
1208            CompressionLevel::Max
1209        );
1210    }
1211
1212    #[test]
1213    fn from_legacy_ultra_density_maps_to_max() {
1214        assert_eq!(
1215            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Ultra),
1216            CompressionLevel::Max
1217        );
1218    }
1219
1220    #[test]
1221    fn from_legacy_full_agent_maps_to_standard() {
1222        assert_eq!(
1223            CompressionLevel::from_legacy(&TerseAgent::Full, &OutputDensity::Normal),
1224            CompressionLevel::Standard
1225        );
1226    }
1227
1228    #[test]
1229    fn from_legacy_lite_agent_maps_to_lite() {
1230        assert_eq!(
1231            CompressionLevel::from_legacy(&TerseAgent::Lite, &OutputDensity::Normal),
1232            CompressionLevel::Lite
1233        );
1234    }
1235
1236    #[test]
1237    fn from_legacy_terse_density_maps_to_lite() {
1238        assert_eq!(
1239            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Terse),
1240            CompressionLevel::Lite
1241        );
1242    }
1243
1244    #[test]
1245    fn from_legacy_both_off_maps_to_off() {
1246        assert_eq!(
1247            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Normal),
1248            CompressionLevel::Off
1249        );
1250    }
1251
1252    #[test]
1253    fn labels_match() {
1254        assert_eq!(CompressionLevel::Off.label(), "off");
1255        assert_eq!(CompressionLevel::Lite.label(), "lite");
1256        assert_eq!(CompressionLevel::Standard.label(), "standard");
1257        assert_eq!(CompressionLevel::Max.label(), "max");
1258    }
1259
1260    #[test]
1261    fn is_active_false_for_off() {
1262        assert!(!CompressionLevel::Off.is_active());
1263    }
1264
1265    #[test]
1266    fn is_active_true_for_all_others() {
1267        assert!(CompressionLevel::Lite.is_active());
1268        assert!(CompressionLevel::Standard.is_active());
1269        assert!(CompressionLevel::Max.is_active());
1270    }
1271
1272    #[test]
1273    fn deserialization_defaults_to_off() {
1274        let cfg: Config = toml::from_str("").unwrap();
1275        assert_eq!(cfg.compression_level, CompressionLevel::Off);
1276    }
1277
1278    #[test]
1279    fn deserialization_from_toml() {
1280        let cfg: Config = toml::from_str(r#"compression_level = "standard""#).unwrap();
1281        assert_eq!(cfg.compression_level, CompressionLevel::Standard);
1282    }
1283
1284    #[test]
1285    fn roundtrip_all_levels() {
1286        for level in [
1287            CompressionLevel::Off,
1288            CompressionLevel::Lite,
1289            CompressionLevel::Standard,
1290            CompressionLevel::Max,
1291        ] {
1292            let (ta, od, crp, tm) = level.to_components();
1293            assert!(!crp.is_empty());
1294            if level == CompressionLevel::Off {
1295                assert!(!tm);
1296                assert_eq!(ta, TerseAgent::Off);
1297                assert_eq!(od, OutputDensity::Normal);
1298            } else {
1299                assert!(tm);
1300            }
1301        }
1302    }
1303}
1304
1305#[cfg(test)]
1306mod memory_cleanup_tests {
1307    use super::*;
1308
1309    #[test]
1310    fn default_is_aggressive() {
1311        assert_eq!(MemoryCleanup::default(), MemoryCleanup::Aggressive);
1312    }
1313
1314    #[test]
1315    fn aggressive_ttl_is_300() {
1316        assert_eq!(MemoryCleanup::Aggressive.idle_ttl_secs(), 300);
1317    }
1318
1319    #[test]
1320    fn shared_ttl_is_1800() {
1321        assert_eq!(MemoryCleanup::Shared.idle_ttl_secs(), 1800);
1322    }
1323
1324    #[test]
1325    fn index_retention_multiplier_values() {
1326        assert!(
1327            (MemoryCleanup::Aggressive.index_retention_multiplier() - 1.0).abs() < f64::EPSILON
1328        );
1329        assert!((MemoryCleanup::Shared.index_retention_multiplier() - 3.0).abs() < f64::EPSILON);
1330    }
1331
1332    #[test]
1333    fn deserialization_defaults_to_aggressive() {
1334        let cfg: Config = toml::from_str("").unwrap();
1335        assert_eq!(cfg.memory_cleanup, MemoryCleanup::Aggressive);
1336    }
1337
1338    #[test]
1339    fn deserialization_from_toml() {
1340        let cfg: Config = toml::from_str(r#"memory_cleanup = "shared""#).unwrap();
1341        assert_eq!(cfg.memory_cleanup, MemoryCleanup::Shared);
1342    }
1343
1344    #[test]
1345    fn effective_uses_config_when_no_env() {
1346        let cfg = Config {
1347            memory_cleanup: MemoryCleanup::Shared,
1348            ..Default::default()
1349        };
1350        let eff = MemoryCleanup::effective(&cfg);
1351        assert_eq!(eff, MemoryCleanup::Shared);
1352    }
1353}