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