Skip to main content

lean_ctx/core/
config.rs

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