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