Skip to main content

lean_ctx/core/config/
mod.rs

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