Skip to main content

lean_ctx/core/
config.rs

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