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