Skip to main content

lean_ctx/core/config/
mod.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4use std::sync::atomic::AtomicU8;
5use std::sync::Mutex;
6use std::time::SystemTime;
7
8static SESSION_DEGRADE_LEVEL: AtomicU8 = AtomicU8::new(0);
9
10use super::memory_policy::MemoryPolicy;
11
12mod memory;
13mod proxy;
14pub mod schema;
15mod serde_defaults;
16mod shell_activation;
17
18pub use memory::{MemoryCleanup, MemoryGuardConfig, MemoryProfile, SavingsFooter};
19pub use proxy::{is_local_proxy_url, normalize_url, normalize_url_opt, ProxyConfig, ProxyProvider};
20pub use shell_activation::ShellActivation;
21
22/// Default BM25 cache cap from config (also used by `bm25_index` heuristics).
23pub fn default_bm25_max_cache_mb() -> u64 {
24    serde_defaults::default_bm25_max_cache_mb()
25}
26
27/// Controls when shell output is tee'd to disk for later retrieval.
28#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
29#[serde(rename_all = "lowercase")]
30pub enum TeeMode {
31    Never,
32    #[default]
33    Failures,
34    HighCompression,
35    Always,
36}
37
38/// Legacy: Controls agent output verbosity level injected into MCP instructions.
39/// Superseded by `CompressionLevel`. Kept for backward compatibility with old config.toml files.
40/// New setups use `compression_level` instead. See `CompressionLevel::effective()`.
41#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
42#[serde(rename_all = "lowercase")]
43pub enum TerseAgent {
44    #[default]
45    Off,
46    Lite,
47    Full,
48    Ultra,
49}
50
51impl TerseAgent {
52    /// Reads the terse-agent level from the `LEAN_CTX_TERSE_AGENT` env var.
53    pub fn from_env() -> Self {
54        match std::env::var("LEAN_CTX_TERSE_AGENT")
55            .unwrap_or_default()
56            .to_lowercase()
57            .as_str()
58        {
59            "lite" => Self::Lite,
60            "full" => Self::Full,
61            "ultra" => Self::Ultra,
62            _ => Self::Off,
63        }
64    }
65}
66
67/// Legacy: Controls how dense/compact MCP tool output is formatted.
68/// Superseded by `CompressionLevel`. Kept for backward compatibility with old config.toml files.
69/// New setups use `compression_level` instead. See `CompressionLevel::effective()`.
70#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
71#[serde(rename_all = "lowercase")]
72pub enum OutputDensity {
73    #[default]
74    Normal,
75    Terse,
76    Ultra,
77}
78
79impl OutputDensity {
80    /// Reads the output density from the `LEAN_CTX_OUTPUT_DENSITY` env var.
81    pub fn from_env() -> Self {
82        match std::env::var("LEAN_CTX_OUTPUT_DENSITY")
83            .unwrap_or_default()
84            .to_lowercase()
85            .as_str()
86        {
87            "terse" => Self::Terse,
88            "ultra" => Self::Ultra,
89            _ => Self::Normal,
90        }
91    }
92}
93
94/// Unified compression level that replaces the 4 separate legacy concepts:
95/// `terse_agent`, `output_density`, `terse_mode`, and `crp_mode`.
96///
97/// Controls how much detail tool responses include.
98#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
99#[serde(rename_all = "snake_case")]
100pub enum ResponseVerbosity {
101    #[default]
102    Full,
103    HeadersOnly,
104}
105
106impl ResponseVerbosity {
107    pub fn effective() -> Self {
108        if let Ok(v) = std::env::var("LEAN_CTX_RESPONSE_VERBOSITY") {
109            match v.trim().to_lowercase().as_str() {
110                "headers_only" | "headers" | "minimal" => return Self::HeadersOnly,
111                "full" | "" => return Self::Full,
112                _ => {}
113            }
114        }
115        Config::load().response_verbosity
116    }
117
118    pub fn is_headers_only(&self) -> bool {
119        matches!(self, Self::HeadersOnly)
120    }
121}
122
123/// Each level maps to specific component settings via `to_components()`.
124#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
125#[serde(rename_all = "lowercase")]
126pub enum CompressionLevel {
127    Off,
128    Lite,
129    #[default]
130    Standard,
131    Max,
132}
133
134impl CompressionLevel {
135    /// Decomposes the unified level into legacy component settings.
136    /// Returns (TerseAgent, OutputDensity, crp_mode_str, terse_mode_bool).
137    pub fn to_components(&self) -> (TerseAgent, OutputDensity, &'static str, bool) {
138        match self {
139            Self::Off => (TerseAgent::Off, OutputDensity::Normal, "off", false),
140            Self::Lite => (TerseAgent::Lite, OutputDensity::Terse, "off", true),
141            Self::Standard => (TerseAgent::Full, OutputDensity::Terse, "compact", true),
142            Self::Max => (TerseAgent::Ultra, OutputDensity::Ultra, "tdd", true),
143        }
144    }
145
146    /// Infers a `CompressionLevel` from legacy config keys for backward compatibility.
147    /// Priority: terse_agent > output_density (picks the highest implied level).
148    pub fn from_legacy(terse_agent: &TerseAgent, output_density: &OutputDensity) -> Self {
149        match (terse_agent, output_density) {
150            (TerseAgent::Ultra, _) | (_, OutputDensity::Ultra) => Self::Max,
151            (TerseAgent::Full, _) => Self::Standard,
152            (TerseAgent::Lite, _) | (_, OutputDensity::Terse) => Self::Lite,
153            _ => Self::Off,
154        }
155    }
156
157    /// Reads the compression level from the `LEAN_CTX_COMPRESSION` env var.
158    pub fn from_env() -> Option<Self> {
159        std::env::var("LEAN_CTX_COMPRESSION").ok().and_then(|v| {
160            match v.trim().to_lowercase().as_str() {
161                "off" => Some(Self::Off),
162                "lite" => Some(Self::Lite),
163                "standard" => Some(Self::Standard),
164                "max" => Some(Self::Max),
165                _ => None,
166            }
167        })
168    }
169
170    /// Returns the effective compression level with resolution order:
171    /// 0. Session-level degrade override (set by correction-loop feedback)
172    /// 1. `LEAN_CTX_COMPRESSION` env var
173    /// 2. `compression_level` in config
174    /// 3. Legacy `ultra_compact` flag (maps to `Max`)
175    /// 4. Legacy env vars (`LEAN_CTX_TERSE_AGENT`, `LEAN_CTX_OUTPUT_DENSITY`)
176    /// 5. Legacy config fields (`terse_agent`, `output_density`)
177    pub fn effective(config: &Config) -> Self {
178        if let Some(degraded) = Self::session_degrade_level() {
179            return degraded;
180        }
181        if let Some(env_level) = Self::from_env() {
182            return env_level;
183        }
184        if config.compression_level != Self::Off {
185            return config.compression_level.clone();
186        }
187        if config.ultra_compact {
188            return Self::Max;
189        }
190        let ta_env = TerseAgent::from_env();
191        let od_env = OutputDensity::from_env();
192        let ta = if ta_env == TerseAgent::Off {
193            config.terse_agent.clone()
194        } else {
195            ta_env
196        };
197        let od = if od_env == OutputDensity::Normal {
198            config.output_density.clone()
199        } else {
200            od_env
201        };
202        Self::from_legacy(&ta, &od)
203    }
204
205    /// Session-level degrade: correction loop detected, temporarily reduce compression.
206    /// 0 = no override, 1 = Off, 2 = Lite
207    pub fn session_degrade_level() -> Option<Self> {
208        match SESSION_DEGRADE_LEVEL.load(std::sync::atomic::Ordering::Relaxed) {
209            1 => Some(Self::Off),
210            2 => Some(Self::Lite),
211            _ => None,
212        }
213    }
214
215    /// Sets a session-level compression degrade (called by correction loop detection).
216    pub fn set_session_degrade(level: &Self) {
217        let val = match level {
218            Self::Off => 1u8,
219            Self::Lite => 2u8,
220            _ => 0u8,
221        };
222        SESSION_DEGRADE_LEVEL.store(val, std::sync::atomic::Ordering::Relaxed);
223    }
224
225    /// Clears the session-level degrade (recovery after correction rate drops).
226    pub fn clear_session_degrade() {
227        SESSION_DEGRADE_LEVEL.store(0, std::sync::atomic::Ordering::Relaxed);
228    }
229
230    pub fn from_str_label(s: &str) -> Option<Self> {
231        match s.trim().to_lowercase().as_str() {
232            "off" => Some(Self::Off),
233            "lite" => Some(Self::Lite),
234            "standard" | "std" => Some(Self::Standard),
235            "max" => Some(Self::Max),
236            _ => None,
237        }
238    }
239
240    pub fn is_active(&self) -> bool {
241        !matches!(self, Self::Off)
242    }
243
244    pub fn label(&self) -> &'static str {
245        match self {
246            Self::Off => "off",
247            Self::Lite => "lite",
248            Self::Standard => "standard",
249            Self::Max => "max",
250        }
251    }
252
253    pub fn description(&self) -> &'static str {
254        match self {
255            Self::Off => "No compression — full verbose output",
256            Self::Lite => "Light compression — concise output, basic terse filtering",
257            Self::Standard => {
258                "Standard compression — dense output, compact protocol, pattern-aware"
259            }
260            Self::Max => "Maximum compression — expert mode, TDD protocol, all layers active",
261        }
262    }
263}
264
265/// Global lean-ctx configuration loaded from `config.toml`, merged with project-local overrides.
266#[derive(Debug, Clone, Serialize, Deserialize)]
267#[serde(default)]
268pub struct Config {
269    pub ultra_compact: bool,
270    #[serde(default, deserialize_with = "serde_defaults::deserialize_tee_mode")]
271    pub tee_mode: TeeMode,
272    #[serde(default)]
273    pub output_density: OutputDensity,
274    pub checkpoint_interval: u32,
275    pub excluded_commands: Vec<String>,
276    pub passthrough_urls: Vec<String>,
277    pub custom_aliases: Vec<AliasEntry>,
278    /// Commands taking longer than this threshold (ms) are recorded in the slow log.
279    /// Set to 0 to disable slow logging.
280    pub slow_command_threshold_ms: u64,
281    #[serde(default = "serde_defaults::default_theme")]
282    pub theme: String,
283    #[serde(default)]
284    pub cloud: CloudConfig,
285    #[serde(default)]
286    pub autonomy: AutonomyConfig,
287    #[serde(default)]
288    pub providers: ProvidersConfig,
289    #[serde(default)]
290    pub proxy: ProxyConfig,
291    /// Whether the API proxy is enabled. Tri-state:
292    /// - None: undecided (fresh install, will prompt on interactive setup)
293    /// - Some(true): user opted in, proxy managed by lean-ctx
294    /// - Some(false): user opted out, never touch proxy or endpoints
295    #[serde(default)]
296    pub proxy_enabled: Option<bool>,
297    #[serde(default)]
298    pub proxy_port: Option<u16>,
299    /// Proxy reachability timeout in milliseconds. Default: 200.
300    /// Override via LEAN_CTX_PROXY_TIMEOUT_MS env var.
301    #[serde(default)]
302    pub proxy_timeout_ms: Option<u64>,
303    #[serde(default = "serde_defaults::default_buddy_enabled")]
304    pub buddy_enabled: bool,
305    #[serde(default = "serde_defaults::default_true")]
306    pub enable_wakeup_ctx: bool,
307    #[serde(default)]
308    pub redirect_exclude: Vec<String>,
309    /// Tools to exclude from the MCP tool list returned by list_tools.
310    /// Accepts exact tool names (e.g. `["ctx_graph", "ctx_agent"]`).
311    /// Empty by default — all tools listed, no behaviour change.
312    #[serde(default)]
313    pub disabled_tools: Vec<String>,
314    /// Tool categories to activate by default for dynamic-tool-capable clients.
315    /// Values: "core" (always on), "arch", "debug", "memory", "metrics", "session".
316    /// Example: `default_tool_categories = ["core", "arch", "memory"]`
317    /// Override via LCTX_DEFAULT_CATEGORIES env var (comma-separated).
318    /// Empty = lean-ctx default (core + session).
319    #[serde(default)]
320    pub default_tool_categories: Vec<String>,
321    /// Disable all automatic read-mode degradation (auto_degrade + context_gate pressure).
322    /// When true, lean-ctx never downgrades requested read modes regardless of pressure.
323    /// Override via LCTX_NO_DEGRADE=1 env var.
324    #[serde(default)]
325    pub no_degrade: bool,
326    #[serde(default)]
327    pub loop_detection: LoopDetectionConfig,
328    /// Controls where lean-ctx installs agent rule files.
329    /// Values: "both" (default), "global" (home-dir only), "project" (repo-local only).
330    /// Override via LEAN_CTX_RULES_SCOPE env var.
331    #[serde(default)]
332    pub rules_scope: Option<String>,
333    /// Extra glob patterns to ignore in graph/overview/preload (repo-local).
334    /// Example: `["externals/**", "target/**", "temp/**"]`
335    #[serde(default)]
336    pub extra_ignore_patterns: Vec<String>,
337    /// Controls agent output verbosity via instructions injection.
338    /// Values: "off" (default), "lite", "full", "ultra".
339    /// Override via LEAN_CTX_TERSE_AGENT env var.
340    #[serde(default)]
341    pub terse_agent: TerseAgent,
342    /// Unified compression level (replaces separate terse_agent + output_density).
343    /// Values: "off" (default), "lite", "standard", "max".
344    /// Override via LEAN_CTX_COMPRESSION env var.
345    #[serde(default)]
346    pub compression_level: CompressionLevel,
347    /// Archive configuration for zero-loss compression.
348    #[serde(default)]
349    pub archive: ArchiveConfig,
350    /// Memory policy (knowledge/episodic/procedural/lifecycle budgets & thresholds).
351    #[serde(default)]
352    pub memory: MemoryPolicy,
353    /// Additional paths allowed by PathJail (absolute).
354    /// Useful for multi-project workspaces where the jail root is a parent directory.
355    /// Override via LEAN_CTX_ALLOW_PATH env var (path-list separator).
356    #[serde(default)]
357    pub allow_paths: Vec<String>,
358    /// Enable content-defined chunking (Rabin-Karp) for cache-optimal output ordering.
359    /// Stable chunks are emitted first to maximize prompt cache hits.
360    #[serde(default)]
361    pub content_defined_chunking: bool,
362    /// Skip session/knowledge/gotcha blocks in MCP instructions to minimize token overhead.
363    /// Override via LEAN_CTX_MINIMAL env var.
364    #[serde(default)]
365    pub minimal_overhead: bool,
366    /// Disable shell hook injection (the _lc() function that wraps CLI commands).
367    /// Override via LEAN_CTX_NO_HOOK env var.
368    #[serde(default)]
369    pub shell_hook_disabled: bool,
370    /// Controls when the shell hook auto-activates aliases.
371    /// - `always`: (Default) Aliases active in every interactive shell.
372    /// - `agents-only`: Aliases only active when an AI agent env var is detected.
373    /// - `off`: Aliases never auto-activate (user must call `lean-ctx-on` manually).
374    ///
375    /// Override via `LEAN_CTX_SHELL_ACTIVATION` env var.
376    #[serde(default)]
377    pub shell_activation: ShellActivation,
378    /// Disable the daily version check against leanctx.com/version.txt.
379    /// Override via LEAN_CTX_NO_UPDATE_CHECK env var.
380    #[serde(default)]
381    pub update_check_disabled: bool,
382    #[serde(default)]
383    pub updates: UpdatesConfig,
384    /// Maximum BM25 cache file size in MB. Indexes exceeding this are quarantined on load
385    /// and refused on save. Override via LEAN_CTX_BM25_MAX_CACHE_MB env var.
386    #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
387    pub bm25_max_cache_mb: u64,
388    /// Maximum number of files scanned by the lightweight JSON graph index.
389    /// Increase for large monorepos. Default: 5000.
390    #[serde(default = "serde_defaults::default_graph_index_max_files")]
391    pub graph_index_max_files: 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    /// Maximum percentage of system RAM that lean-ctx may use (default: 5).
402    /// Override via LEAN_CTX_MAX_RAM_PERCENT env var.
403    #[serde(default = "serde_defaults::default_max_ram_percent")]
404    pub max_ram_percent: u8,
405    /// Controls visibility of token savings footers in tool output.
406    /// Values: "never" (default, suppress everywhere), "always", "auto" (legacy compatibility).
407    /// Override via LEAN_CTX_SAVINGS_FOOTER env var.
408    #[serde(default)]
409    pub savings_footer: SavingsFooter,
410    /// Explicit project root override. When set, lean-ctx uses this instead of auto-detection.
411    /// This prevents accidental home-directory scans when running from $HOME.
412    /// Override via LEAN_CTX_PROJECT_ROOT env var.
413    #[serde(default)]
414    pub project_root: Option<String>,
415    /// LSP server overrides. Map language name to custom binary path.
416    /// Example: `[lsp]\nrust = "/opt/rust-analyzer"\npython = "~/.venvs/main/bin/pylsp"`
417    #[serde(default)]
418    pub lsp: std::collections::HashMap<String, String>,
419    /// Per-IDE allowed paths. Restricts which directories lean-ctx will scan/index for each IDE.
420    /// Example: `[ide_paths]\ncursor = ["/home/user/projects/app1"]\ncodex = ["/home/user/codex"]`
421    /// When set, only these paths are indexed for the matching agent. Global `allow_paths` still applies.
422    #[serde(default)]
423    pub ide_paths: HashMap<String, Vec<String>>,
424    /// Custom model context window overrides.
425    /// Example: `[model_context_windows]\n"my-custom-model" = 500000`
426    #[serde(default)]
427    pub model_context_windows: HashMap<String, usize>,
428    /// Controls how much detail tool responses include.
429    ///
430    /// - `full` (default): complete compressed output
431    /// - `headers_only`: metadata line only (path, mode, token count)
432    ///
433    /// Override via `LEAN_CTX_RESPONSE_VERBOSITY` env var.
434    #[serde(default)]
435    pub response_verbosity: ResponseVerbosity,
436    /// Bypass hint mode. When agents use native Read/Grep instead of lean-ctx tools,
437    /// a hint is appended to the next tool response.
438    /// Values: "on" (default), "off", "aggressive" (hint on every call, no cooldown).
439    /// Override via LEAN_CTX_BYPASS_HINTS env var.
440    #[serde(default)]
441    pub bypass_hints: Option<String>,
442    /// Cache policy for ctx_read. Controls behavior on cache hits.
443    /// Values: "aggressive" (default, 13-tok stubs + compaction-aware reset),
444    /// "safe" (delivers map instead of stub), "off" (no caching, always disk read).
445    /// Override via LEAN_CTX_CACHE_POLICY env var.
446    #[serde(default)]
447    pub cache_policy: Option<String>,
448    /// Cross-project boundary policy.
449    /// Controls whether cross-project search/import is allowed and whether access is audited.
450    #[serde(default)]
451    pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
452    #[serde(default)]
453    pub secret_detection: SecretDetectionConfig,
454    /// Allow automatic project-root re-rooting when absolute paths outside the jail are seen.
455    /// When false (default), absolute paths outside the jail are rejected without re-rooting.
456    /// Override via LEAN_CTX_ALLOW_REROOT env var.
457    #[serde(default)]
458    pub allow_auto_reroot: bool,
459    /// Disable PathJail entirely. Set to false to allow all paths.
460    /// Useful in container/Docker environments. Override via LEAN_CTX_NO_JAIL=1.
461    #[serde(default)]
462    pub path_jail: Option<bool>,
463    /// Sandbox level for code execution (ctx_exec).
464    /// 0 = subprocess only (current), 1 = OS-level restriction (Seatbelt/Landlock).
465    /// Override via LEAN_CTX_SANDBOX_LEVEL env var.
466    #[serde(default)]
467    pub sandbox_level: u8,
468    /// When true, large tool outputs (>4000 chars) are stored as references
469    /// and a short URI is returned instead of the full content.
470    /// Override via LEAN_CTX_REFERENCE_RESULTS env var.
471    #[serde(default)]
472    pub reference_results: bool,
473    /// Default per-agent token budget. 0 means unlimited.
474    /// Override per-agent via ctx_session or programmatically.
475    #[serde(default)]
476    pub agent_token_budget: usize,
477    /// Optional shell command allowlist. When non-empty, only commands whose base binary
478    /// is in this list are permitted by ctx_shell. Empty = disable allowlist (allow all).
479    /// Default includes common dev tools. Set to `[]` to disable.
480    /// Override via LEAN_CTX_SHELL_ALLOWLIST env var (comma-separated).
481    #[serde(default = "default_shell_allowlist")]
482    pub shell_allowlist: Vec<String>,
483}
484
485#[derive(Debug, Clone, Serialize, Deserialize)]
486#[serde(default)]
487pub struct SecretDetectionConfig {
488    pub enabled: bool,
489    pub redact: bool,
490    pub custom_patterns: Vec<String>,
491}
492
493impl Default for SecretDetectionConfig {
494    fn default() -> Self {
495        Self {
496            enabled: true,
497            redact: false,
498            custom_patterns: Vec::new(),
499        }
500    }
501}
502
503/// Settings for the zero-loss compression archive (large tool outputs saved to disk).
504#[derive(Debug, Clone, Serialize, Deserialize)]
505#[serde(default)]
506pub struct ArchiveConfig {
507    pub enabled: bool,
508    pub threshold_chars: usize,
509    pub max_age_hours: u64,
510    pub max_disk_mb: u64,
511}
512
513impl Default for ArchiveConfig {
514    fn default() -> Self {
515        Self {
516            enabled: true,
517            threshold_chars: 4096,
518            max_age_hours: 48,
519            max_disk_mb: 500,
520        }
521    }
522}
523
524/// Configuration for external context providers (GitHub, GitLab, Jira, etc.).
525/// Each provider can be enabled/disabled and configured with auth tokens.
526/// Override individual tokens via env vars (GITHUB_TOKEN, GITLAB_TOKEN, etc.).
527#[derive(Debug, Clone, Serialize, Deserialize)]
528#[serde(default)]
529pub struct ProvidersConfig {
530    /// Master switch for the provider subsystem.
531    pub enabled: bool,
532    /// GitHub provider configuration.
533    pub github: ProviderEntryConfig,
534    /// GitLab provider configuration.
535    pub gitlab: ProviderEntryConfig,
536    /// Auto-ingest provider results into BM25/embedding indexes.
537    pub auto_index: bool,
538    /// Default cache TTL for provider results (seconds).
539    pub cache_ttl_secs: u64,
540    /// MCP Bridge providers: `{ "name" = { url = "...", description = "..." } }`.
541    #[serde(default)]
542    pub mcp_bridges: std::collections::HashMap<String, McpBridgeEntry>,
543}
544
545impl Default for ProvidersConfig {
546    fn default() -> Self {
547        Self {
548            enabled: true,
549            github: ProviderEntryConfig::default(),
550            gitlab: ProviderEntryConfig::default(),
551            auto_index: true,
552            cache_ttl_secs: 120,
553            mcp_bridges: std::collections::HashMap::new(),
554        }
555    }
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize)]
559pub struct McpBridgeEntry {
560    /// HTTP/SSE URL for remote MCP servers.
561    #[serde(default)]
562    pub url: Option<String>,
563    /// Command to spawn a local MCP server (stdio transport).
564    #[serde(default)]
565    pub command: Option<String>,
566    /// Arguments for the command.
567    #[serde(default)]
568    pub args: Vec<String>,
569    /// Human-readable description.
570    #[serde(default)]
571    pub description: Option<String>,
572    /// Environment variable name containing an auth token.
573    #[serde(default)]
574    pub auth_env: Option<String>,
575}
576
577/// Per-provider configuration entry.
578#[derive(Debug, Clone, Serialize, Deserialize)]
579#[serde(default)]
580pub struct ProviderEntryConfig {
581    /// Whether this specific provider is enabled.
582    pub enabled: bool,
583    /// Auth token (prefer env var; only use this for project-local overrides).
584    pub token: Option<String>,
585    /// API base URL override (for GitHub Enterprise, self-hosted GitLab, etc.).
586    pub api_url: Option<String>,
587    /// Default project/repo for this provider (auto-detected from git remote if empty).
588    pub project: Option<String>,
589}
590
591impl Default for ProviderEntryConfig {
592    fn default() -> Self {
593        Self {
594            enabled: true,
595            token: None,
596            api_url: None,
597            project: None,
598        }
599    }
600}
601
602/// Controls autonomous background behaviors (preload, dedup, consolidation).
603#[derive(Debug, Clone, Serialize, Deserialize)]
604#[serde(default)]
605pub struct AutonomyConfig {
606    pub enabled: bool,
607    pub auto_preload: bool,
608    pub auto_dedup: bool,
609    pub auto_related: bool,
610    pub auto_consolidate: bool,
611    pub silent_preload: bool,
612    pub dedup_threshold: usize,
613    pub consolidate_every_calls: u32,
614    pub consolidate_cooldown_secs: u64,
615    #[serde(default = "serde_defaults::default_true")]
616    pub cognition_loop_enabled: bool,
617    #[serde(default = "serde_defaults::default_cognition_loop_interval")]
618    pub cognition_loop_interval_secs: u64,
619    #[serde(default = "serde_defaults::default_cognition_loop_max_steps")]
620    pub cognition_loop_max_steps: u8,
621}
622
623impl Default for AutonomyConfig {
624    fn default() -> Self {
625        Self {
626            enabled: true,
627            auto_preload: true,
628            auto_dedup: true,
629            auto_related: true,
630            auto_consolidate: true,
631            silent_preload: true,
632            dedup_threshold: 8,
633            consolidate_every_calls: 25,
634            consolidate_cooldown_secs: 120,
635            cognition_loop_enabled: true,
636            cognition_loop_interval_secs: 3600,
637            cognition_loop_max_steps: 8,
638        }
639    }
640}
641
642/// Controls automatic update behavior. All defaults are OFF — auto-updates
643/// require explicit opt-in via `lean-ctx setup` or `lean-ctx update --schedule`.
644#[derive(Debug, Clone, Serialize, Deserialize)]
645#[serde(default)]
646pub struct UpdatesConfig {
647    pub auto_update: bool,
648    pub check_interval_hours: u64,
649    pub notify_only: bool,
650}
651
652impl Default for UpdatesConfig {
653    fn default() -> Self {
654        Self {
655            auto_update: false,
656            check_interval_hours: 6,
657            notify_only: false,
658        }
659    }
660}
661
662impl UpdatesConfig {
663    pub fn from_env() -> Self {
664        let mut cfg = Self::default();
665        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_UPDATE") {
666            cfg.auto_update = v == "1" || v.eq_ignore_ascii_case("true");
667        }
668        if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_INTERVAL_HOURS") {
669            if let Ok(h) = v.parse::<u64>() {
670                cfg.check_interval_hours = h.clamp(1, 168);
671            }
672        }
673        if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_NOTIFY_ONLY") {
674            cfg.notify_only = v == "1" || v.eq_ignore_ascii_case("true");
675        }
676        cfg
677    }
678}
679
680impl AutonomyConfig {
681    /// Creates an autonomy config from env vars, falling back to defaults.
682    pub fn from_env() -> Self {
683        let mut cfg = Self::default();
684        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
685            if v == "false" || v == "0" {
686                cfg.enabled = false;
687            }
688        }
689        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
690            cfg.auto_preload = v != "false" && v != "0";
691        }
692        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
693            cfg.auto_dedup = v != "false" && v != "0";
694        }
695        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
696            cfg.auto_related = v != "false" && v != "0";
697        }
698        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
699            cfg.auto_consolidate = v != "false" && v != "0";
700        }
701        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
702            cfg.silent_preload = v != "false" && v != "0";
703        }
704        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
705            if let Ok(n) = v.parse() {
706                cfg.dedup_threshold = n;
707            }
708        }
709        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
710            if let Ok(n) = v.parse() {
711                cfg.consolidate_every_calls = n;
712            }
713        }
714        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
715            if let Ok(n) = v.parse() {
716                cfg.consolidate_cooldown_secs = n;
717            }
718        }
719        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
720            cfg.cognition_loop_enabled = v != "false" && v != "0";
721        }
722        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
723            if let Ok(n) = v.parse() {
724                cfg.cognition_loop_interval_secs = n;
725            }
726        }
727        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
728            if let Ok(n) = v.parse() {
729                cfg.cognition_loop_max_steps = n;
730            }
731        }
732        cfg
733    }
734
735    /// Loads autonomy config from disk, with env var overrides applied.
736    pub fn load() -> Self {
737        let file_cfg = Config::load().autonomy;
738        let mut cfg = file_cfg;
739        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
740            if v == "false" || v == "0" {
741                cfg.enabled = false;
742            }
743        }
744        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
745            cfg.auto_preload = v != "false" && v != "0";
746        }
747        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
748            cfg.auto_dedup = v != "false" && v != "0";
749        }
750        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
751            cfg.auto_related = v != "false" && v != "0";
752        }
753        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
754            cfg.silent_preload = v != "false" && v != "0";
755        }
756        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
757            if let Ok(n) = v.parse() {
758                cfg.dedup_threshold = n;
759            }
760        }
761        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
762            cfg.cognition_loop_enabled = v != "false" && v != "0";
763        }
764        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
765            if let Ok(n) = v.parse() {
766                cfg.cognition_loop_interval_secs = n;
767            }
768        }
769        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
770            if let Ok(n) = v.parse() {
771                cfg.cognition_loop_max_steps = n;
772            }
773        }
774        cfg
775    }
776}
777
778/// Cloud sync and contribution settings (pattern sharing, model pulls).
779#[derive(Debug, Clone, Serialize, Deserialize, Default)]
780#[serde(default)]
781pub struct CloudConfig {
782    pub contribute_enabled: bool,
783    pub last_contribute: Option<String>,
784    pub last_sync: Option<String>,
785    pub last_gain_sync: Option<String>,
786    pub last_model_pull: Option<String>,
787}
788
789/// A user-defined command alias mapping for shell compression patterns.
790#[derive(Debug, Clone, Serialize, Deserialize)]
791pub struct AliasEntry {
792    pub command: String,
793    pub alias: String,
794}
795
796/// Thresholds for detecting and throttling repetitive agent tool call loops.
797#[derive(Debug, Clone, Serialize, Deserialize)]
798#[serde(default)]
799pub struct LoopDetectionConfig {
800    pub normal_threshold: u32,
801    pub reduced_threshold: u32,
802    pub blocked_threshold: u32,
803    pub window_secs: u64,
804    pub search_group_limit: u32,
805    pub tool_total_limits: HashMap<String, u32>,
806}
807
808impl Default for LoopDetectionConfig {
809    fn default() -> Self {
810        let mut tool_total_limits = HashMap::new();
811        tool_total_limits.insert("ctx_read".to_string(), 100);
812        tool_total_limits.insert("ctx_search".to_string(), 80);
813        tool_total_limits.insert("ctx_shell".to_string(), 50);
814        tool_total_limits.insert("ctx_semantic_search".to_string(), 60);
815        Self {
816            normal_threshold: 2,
817            reduced_threshold: 4,
818            blocked_threshold: 0,
819            window_secs: 300,
820            search_group_limit: 10,
821            tool_total_limits,
822        }
823    }
824}
825
826impl Default for Config {
827    fn default() -> Self {
828        Self {
829            ultra_compact: false,
830            tee_mode: TeeMode::default(),
831            output_density: OutputDensity::default(),
832            checkpoint_interval: 15,
833            excluded_commands: Vec::new(),
834            passthrough_urls: Vec::new(),
835            custom_aliases: Vec::new(),
836            slow_command_threshold_ms: 5000,
837            theme: serde_defaults::default_theme(),
838            cloud: CloudConfig::default(),
839            autonomy: AutonomyConfig::default(),
840            providers: ProvidersConfig::default(),
841            proxy: ProxyConfig::default(),
842            proxy_enabled: None,
843            proxy_port: None,
844            proxy_timeout_ms: None,
845            buddy_enabled: serde_defaults::default_buddy_enabled(),
846            enable_wakeup_ctx: true,
847            redirect_exclude: Vec::new(),
848            disabled_tools: Vec::new(),
849            default_tool_categories: Vec::new(),
850            no_degrade: false,
851            loop_detection: LoopDetectionConfig::default(),
852            rules_scope: None,
853            extra_ignore_patterns: Vec::new(),
854            terse_agent: TerseAgent::default(),
855            compression_level: CompressionLevel::default(),
856            archive: ArchiveConfig::default(),
857            memory: MemoryPolicy::default(),
858            allow_paths: Vec::new(),
859            content_defined_chunking: false,
860            minimal_overhead: false,
861            shell_hook_disabled: false,
862            shell_activation: ShellActivation::default(),
863            update_check_disabled: false,
864            updates: UpdatesConfig::default(),
865            graph_index_max_files: serde_defaults::default_graph_index_max_files(),
866            bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
867            memory_profile: MemoryProfile::default(),
868            memory_cleanup: MemoryCleanup::default(),
869            max_ram_percent: serde_defaults::default_max_ram_percent(),
870            savings_footer: SavingsFooter::default(),
871            project_root: None,
872            lsp: std::collections::HashMap::new(),
873            ide_paths: HashMap::new(),
874            model_context_windows: HashMap::new(),
875            response_verbosity: ResponseVerbosity::default(),
876            bypass_hints: None,
877            cache_policy: None,
878            boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
879            secret_detection: SecretDetectionConfig::default(),
880            allow_auto_reroot: false,
881            path_jail: None,
882            sandbox_level: 0,
883            reference_results: false,
884            agent_token_budget: 0,
885            shell_allowlist: default_shell_allowlist(),
886        }
887    }
888}
889
890fn default_shell_allowlist() -> Vec<String> {
891    [
892        // VCS
893        "git",
894        "gh",
895        "svn",
896        // Build tools
897        "cargo",
898        "npm",
899        "npx",
900        "yarn",
901        "pnpm",
902        "bun",
903        "make",
904        "cmake",
905        "pip",
906        "pip3",
907        "poetry",
908        "uv",
909        "go",
910        "mvn",
911        "gradle",
912        "mix",
913        "dotnet",
914        "swift",
915        "zig",
916        "rustup",
917        "rustc",
918        // Common CLI
919        "ls",
920        "cat",
921        "head",
922        "tail",
923        "wc",
924        "sort",
925        "uniq",
926        "tr",
927        "cut",
928        "grep",
929        "rg",
930        "find",
931        "fd",
932        "ag",
933        "ack",
934        "sed",
935        "awk",
936        "echo",
937        "printf",
938        "true",
939        "false",
940        "test",
941        "expr",
942        "cd",
943        "pwd",
944        "basename",
945        "dirname",
946        "realpath",
947        "readlink",
948        "cp",
949        "mv",
950        "mkdir",
951        "rm",
952        "rmdir",
953        "touch",
954        "ln",
955        "chmod",
956        "diff",
957        "patch",
958        "tar",
959        "zip",
960        "unzip",
961        "gzip",
962        "gunzip",
963        "zstd",
964        "curl",
965        "wget",
966        // Dev tools
967        "docker",
968        "docker-compose",
969        "podman",
970        "node",
971        "python",
972        "python3",
973        "ruby",
974        "perl",
975        "java",
976        "javac",
977        "tsc",
978        "eslint",
979        "prettier",
980        "black",
981        "ruff",
982        "clippy",
983        "jq",
984        "yq",
985        "xargs",
986        "env",
987        "which",
988        "type",
989        "file",
990        "stat",
991        "date",
992        "sleep",
993        "timeout",
994        "nice",
995        "ionice",
996        // lean-ctx itself
997        "lean-ctx",
998    ]
999    .iter()
1000    .map(|s| (*s).to_string())
1001    .collect()
1002}
1003
1004/// Where agent rule files are installed: global home dir, project-local, or both.
1005#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1006pub enum RulesScope {
1007    Both,
1008    Global,
1009    Project,
1010}
1011
1012impl Config {
1013    /// Returns the effective rules scope, preferring env var over config file.
1014    pub fn rules_scope_effective(&self) -> RulesScope {
1015        let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
1016            .ok()
1017            .or_else(|| self.rules_scope.clone())
1018            .unwrap_or_default();
1019        match raw.trim().to_lowercase().as_str() {
1020            "global" => RulesScope::Global,
1021            "project" => RulesScope::Project,
1022            _ => RulesScope::Both,
1023        }
1024    }
1025
1026    fn parse_disabled_tools_env(val: &str) -> Vec<String> {
1027        val.split(',')
1028            .map(|s| s.trim().to_string())
1029            .filter(|s| !s.is_empty())
1030            .collect()
1031    }
1032
1033    /// Returns the effective disabled tools list, preferring env var over config file.
1034    pub fn disabled_tools_effective(&self) -> Vec<String> {
1035        if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
1036            Self::parse_disabled_tools_env(&val)
1037        } else {
1038            self.disabled_tools.clone()
1039        }
1040    }
1041
1042    /// Returns `true` if minimal overhead is enabled via env var or config.
1043    pub fn minimal_overhead_effective(&self) -> bool {
1044        std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
1045    }
1046
1047    /// Returns `true` if minimal overhead should be enabled for this MCP client.
1048    ///
1049    /// This is a superset of `minimal_overhead_effective()`:
1050    /// - `LEAN_CTX_OVERHEAD_MODE=minimal` forces minimal overhead
1051    /// - `LEAN_CTX_OVERHEAD_MODE=full` disables client/model heuristics (still honors LEAN_CTX_MINIMAL / config)
1052    /// - In auto mode (default), certain low-context clients/models are treated as minimal to prevent
1053    ///   large metadata blocks from destabilizing smaller context windows (e.g. Hermes + MiniMax).
1054    pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
1055        if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
1056            match raw.trim().to_lowercase().as_str() {
1057                "minimal" => return true,
1058                "full" => return self.minimal_overhead_effective(),
1059                _ => {}
1060            }
1061        }
1062
1063        if self.minimal_overhead_effective() {
1064            return true;
1065        }
1066
1067        let client_lower = client_name.trim().to_lowercase();
1068        if !client_lower.is_empty() {
1069            if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
1070                for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
1071                    if !needle.is_empty() && client_lower.contains(&needle) {
1072                        return true;
1073                    }
1074                }
1075            } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
1076                return true;
1077            }
1078        }
1079
1080        let model = std::env::var("LEAN_CTX_MODEL")
1081            .or_else(|_| std::env::var("LCTX_MODEL"))
1082            .unwrap_or_default();
1083        let model = model.trim().to_lowercase();
1084        if !model.is_empty() {
1085            let m = model.replace(['_', ' '], "-");
1086            if m.contains("minimax")
1087                || m.contains("mini-max")
1088                || m.contains("m2.7")
1089                || m.contains("m2-7")
1090            {
1091                return true;
1092            }
1093        }
1094
1095        false
1096    }
1097
1098    /// Returns `true` if shell hook injection is disabled via env var or config.
1099    pub fn shell_hook_disabled_effective(&self) -> bool {
1100        std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
1101    }
1102
1103    /// Returns the effective shell activation mode (env var > config > default).
1104    pub fn shell_activation_effective(&self) -> ShellActivation {
1105        ShellActivation::effective(self)
1106    }
1107
1108    /// Returns `true` if the daily update check is disabled via env var or config.
1109    pub fn update_check_disabled_effective(&self) -> bool {
1110        std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
1111    }
1112
1113    pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
1114        let mut policy = self.memory.clone();
1115        policy.apply_env_overrides();
1116        policy.validate()?;
1117        Ok(policy)
1118    }
1119
1120    /// Returns the effective set of default tool categories.
1121    /// Priority: LCTX_DEFAULT_CATEGORIES env var > config.toml > hardcoded default.
1122    pub fn default_tool_categories_effective(&self) -> Vec<String> {
1123        if let Ok(val) = std::env::var("LCTX_DEFAULT_CATEGORIES") {
1124            return val
1125                .split(',')
1126                .map(|s| s.trim().to_lowercase())
1127                .filter(|s| !s.is_empty())
1128                .collect();
1129        }
1130        if !self.default_tool_categories.is_empty() {
1131            return self
1132                .default_tool_categories
1133                .iter()
1134                .map(|s| s.to_lowercase())
1135                .collect();
1136        }
1137        vec!["core".to_string(), "session".to_string()]
1138    }
1139
1140    /// Returns `true` if all automatic read-mode degradation is disabled.
1141    /// Checks LCTX_NO_DEGRADE env var first, then config.toml field.
1142    pub fn no_degrade_effective(&self) -> bool {
1143        if let Ok(val) = std::env::var("LCTX_NO_DEGRADE") {
1144            return val == "1" || val.eq_ignore_ascii_case("true");
1145        }
1146        self.no_degrade
1147    }
1148}
1149
1150#[cfg(test)]
1151mod disabled_tools_tests {
1152    use super::*;
1153
1154    #[test]
1155    fn config_field_default_is_empty() {
1156        let cfg = Config::default();
1157        assert!(cfg.disabled_tools.is_empty());
1158    }
1159
1160    #[test]
1161    fn effective_returns_config_field_when_no_env_var() {
1162        // Only meaningful when LEAN_CTX_DISABLED_TOOLS is unset; skip otherwise.
1163        if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
1164            return;
1165        }
1166        let cfg = Config {
1167            disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
1168            ..Default::default()
1169        };
1170        assert_eq!(
1171            cfg.disabled_tools_effective(),
1172            vec!["ctx_graph", "ctx_agent"]
1173        );
1174    }
1175
1176    #[test]
1177    fn parse_env_basic() {
1178        let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
1179        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
1180    }
1181
1182    #[test]
1183    fn parse_env_trims_whitespace_and_skips_empty() {
1184        let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
1185        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
1186    }
1187
1188    #[test]
1189    fn parse_env_single_entry() {
1190        let result = Config::parse_disabled_tools_env("ctx_graph");
1191        assert_eq!(result, vec!["ctx_graph"]);
1192    }
1193
1194    #[test]
1195    fn parse_env_empty_string_returns_empty() {
1196        let result = Config::parse_disabled_tools_env("");
1197        assert!(result.is_empty());
1198    }
1199
1200    #[test]
1201    fn disabled_tools_deserialization_defaults_to_empty() {
1202        let cfg: Config = toml::from_str("").unwrap();
1203        assert!(cfg.disabled_tools.is_empty());
1204    }
1205
1206    #[test]
1207    fn disabled_tools_deserialization_from_toml() {
1208        let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
1209        assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
1210    }
1211}
1212
1213#[cfg(test)]
1214mod default_tool_categories_tests {
1215    use super::*;
1216
1217    // --- Defaults ---
1218
1219    #[test]
1220    fn default_returns_core_and_session() {
1221        if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1222            return;
1223        }
1224        let cfg = Config::default();
1225        assert_eq!(
1226            cfg.default_tool_categories_effective(),
1227            vec!["core", "session"]
1228        );
1229    }
1230
1231    #[test]
1232    fn default_struct_field_is_empty_vec() {
1233        let cfg = Config::default();
1234        assert!(cfg.default_tool_categories.is_empty());
1235    }
1236
1237    // --- Config field overrides ---
1238
1239    #[test]
1240    fn config_field_overrides_default() {
1241        if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1242            return;
1243        }
1244        let cfg = Config {
1245            default_tool_categories: vec![
1246                "core".to_string(),
1247                "arch".to_string(),
1248                "memory".to_string(),
1249            ],
1250            ..Default::default()
1251        };
1252        assert_eq!(
1253            cfg.default_tool_categories_effective(),
1254            vec!["core", "arch", "memory"]
1255        );
1256    }
1257
1258    #[test]
1259    fn single_category_in_config() {
1260        if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1261            return;
1262        }
1263        let cfg = Config {
1264            default_tool_categories: vec!["debug".to_string()],
1265            ..Default::default()
1266        };
1267        assert_eq!(cfg.default_tool_categories_effective(), vec!["debug"]);
1268    }
1269
1270    #[test]
1271    fn all_six_categories_in_config() {
1272        if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1273            return;
1274        }
1275        let cfg = Config {
1276            default_tool_categories: vec![
1277                "core".to_string(),
1278                "arch".to_string(),
1279                "debug".to_string(),
1280                "memory".to_string(),
1281                "metrics".to_string(),
1282                "session".to_string(),
1283            ],
1284            ..Default::default()
1285        };
1286        let effective = cfg.default_tool_categories_effective();
1287        assert_eq!(effective.len(), 6);
1288        assert!(effective.contains(&"core".to_string()));
1289        assert!(effective.contains(&"metrics".to_string()));
1290    }
1291
1292    // --- TOML deserialization ---
1293
1294    #[test]
1295    fn deserialization_defaults_to_empty() {
1296        let cfg: Config = toml::from_str("").unwrap();
1297        assert!(cfg.default_tool_categories.is_empty());
1298    }
1299
1300    #[test]
1301    fn deserialization_from_toml() {
1302        let cfg: Config =
1303            toml::from_str(r#"default_tool_categories = ["core", "arch", "debug"]"#).unwrap();
1304        assert_eq!(cfg.default_tool_categories, vec!["core", "arch", "debug"]);
1305    }
1306
1307    #[test]
1308    fn deserialization_empty_array() {
1309        let cfg: Config = toml::from_str(r"default_tool_categories = []").unwrap();
1310        assert!(cfg.default_tool_categories.is_empty());
1311    }
1312
1313    #[test]
1314    fn deserialization_single_entry() {
1315        let cfg: Config = toml::from_str(r#"default_tool_categories = ["memory"]"#).unwrap();
1316        assert_eq!(cfg.default_tool_categories, vec!["memory"]);
1317    }
1318
1319    // --- Edge cases ---
1320
1321    #[test]
1322    fn effective_normalizes_config_to_lowercase() {
1323        if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1324            return;
1325        }
1326        let cfg = Config {
1327            default_tool_categories: vec!["ARCH".to_string(), "Debug".to_string()],
1328            ..Default::default()
1329        };
1330        let effective = cfg.default_tool_categories_effective();
1331        assert_eq!(effective, vec!["arch", "debug"]);
1332    }
1333}
1334
1335#[cfg(test)]
1336mod no_degrade_tests {
1337    use super::*;
1338
1339    // --- Defaults ---
1340
1341    #[test]
1342    fn default_is_false() {
1343        let cfg = Config::default();
1344        assert!(!cfg.no_degrade);
1345    }
1346
1347    #[test]
1348    fn effective_false_when_unset() {
1349        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1350            return;
1351        }
1352        let cfg = Config::default();
1353        assert!(!cfg.no_degrade_effective());
1354    }
1355
1356    // --- Config field ---
1357
1358    #[test]
1359    fn config_field_true_respected_when_no_env() {
1360        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1361            return;
1362        }
1363        let cfg = Config {
1364            no_degrade: true,
1365            ..Default::default()
1366        };
1367        assert!(cfg.no_degrade_effective());
1368    }
1369
1370    #[test]
1371    fn config_field_false_respected_when_no_env() {
1372        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1373            return;
1374        }
1375        let cfg = Config {
1376            no_degrade: false,
1377            ..Default::default()
1378        };
1379        assert!(!cfg.no_degrade_effective());
1380    }
1381
1382    // --- TOML deserialization ---
1383
1384    #[test]
1385    fn deserialization_true() {
1386        let cfg: Config = toml::from_str("no_degrade = true").unwrap();
1387        assert!(cfg.no_degrade);
1388    }
1389
1390    #[test]
1391    fn deserialization_false() {
1392        let cfg: Config = toml::from_str("no_degrade = false").unwrap();
1393        assert!(!cfg.no_degrade);
1394    }
1395
1396    #[test]
1397    fn deserialization_absent_defaults_false() {
1398        let cfg: Config = toml::from_str("").unwrap();
1399        assert!(!cfg.no_degrade);
1400    }
1401
1402    // --- Coexistence with other config fields ---
1403
1404    #[test]
1405    fn no_degrade_independent_of_disabled_tools() {
1406        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1407            return;
1408        }
1409        let cfg = Config {
1410            no_degrade: true,
1411            disabled_tools: vec!["ctx_graph".to_string()],
1412            ..Default::default()
1413        };
1414        assert!(cfg.no_degrade_effective());
1415        assert!(!cfg.disabled_tools.is_empty());
1416    }
1417
1418    #[test]
1419    fn no_degrade_independent_of_tool_categories() {
1420        if std::env::var("LCTX_NO_DEGRADE").is_ok()
1421            || std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok()
1422        {
1423            return;
1424        }
1425        let cfg = Config {
1426            no_degrade: true,
1427            default_tool_categories: vec!["core".to_string(), "arch".to_string()],
1428            ..Default::default()
1429        };
1430        assert!(cfg.no_degrade_effective());
1431        assert_eq!(
1432            cfg.default_tool_categories_effective(),
1433            vec!["core", "arch"]
1434        );
1435    }
1436}
1437
1438#[cfg(test)]
1439mod rules_scope_tests {
1440    use super::*;
1441
1442    #[test]
1443    fn default_is_both() {
1444        let cfg = Config::default();
1445        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1446    }
1447
1448    #[test]
1449    fn config_global() {
1450        let cfg = Config {
1451            rules_scope: Some("global".to_string()),
1452            ..Default::default()
1453        };
1454        assert_eq!(cfg.rules_scope_effective(), RulesScope::Global);
1455    }
1456
1457    #[test]
1458    fn config_project() {
1459        let cfg = Config {
1460            rules_scope: Some("project".to_string()),
1461            ..Default::default()
1462        };
1463        assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1464    }
1465
1466    #[test]
1467    fn unknown_value_falls_back_to_both() {
1468        let cfg = Config {
1469            rules_scope: Some("nonsense".to_string()),
1470            ..Default::default()
1471        };
1472        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1473    }
1474
1475    #[test]
1476    fn deserialization_none_by_default() {
1477        let cfg: Config = toml::from_str("").unwrap();
1478        assert!(cfg.rules_scope.is_none());
1479        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1480    }
1481
1482    #[test]
1483    fn deserialization_from_toml() {
1484        let cfg: Config = toml::from_str(r#"rules_scope = "project""#).unwrap();
1485        assert_eq!(cfg.rules_scope.as_deref(), Some("project"));
1486        assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1487    }
1488}
1489
1490#[cfg(test)]
1491mod loop_detection_config_tests {
1492    use super::*;
1493
1494    #[test]
1495    fn defaults_are_reasonable() {
1496        let cfg = LoopDetectionConfig::default();
1497        assert_eq!(cfg.normal_threshold, 2);
1498        assert_eq!(cfg.reduced_threshold, 4);
1499        // 0 = blocking disabled by default (LeanCTX philosophy: always help, never block)
1500        assert_eq!(cfg.blocked_threshold, 0);
1501        assert_eq!(cfg.window_secs, 300);
1502        assert_eq!(cfg.search_group_limit, 10);
1503    }
1504
1505    #[test]
1506    fn deserialization_defaults_when_missing() {
1507        let cfg: Config = toml::from_str("").unwrap();
1508        // 0 = blocking disabled by default
1509        assert_eq!(cfg.loop_detection.blocked_threshold, 0);
1510        assert_eq!(cfg.loop_detection.search_group_limit, 10);
1511    }
1512
1513    #[test]
1514    fn deserialization_from_toml() {
1515        let cfg: Config = toml::from_str(
1516            r"
1517            [loop_detection]
1518            normal_threshold = 1
1519            reduced_threshold = 3
1520            blocked_threshold = 5
1521            window_secs = 120
1522            search_group_limit = 8
1523            ",
1524        )
1525        .unwrap();
1526        assert_eq!(cfg.loop_detection.normal_threshold, 1);
1527        assert_eq!(cfg.loop_detection.reduced_threshold, 3);
1528        assert_eq!(cfg.loop_detection.blocked_threshold, 5);
1529        assert_eq!(cfg.loop_detection.window_secs, 120);
1530        assert_eq!(cfg.loop_detection.search_group_limit, 8);
1531    }
1532
1533    #[test]
1534    fn partial_override_keeps_defaults() {
1535        let cfg: Config = toml::from_str(
1536            r"
1537            [loop_detection]
1538            blocked_threshold = 10
1539            ",
1540        )
1541        .unwrap();
1542        assert_eq!(cfg.loop_detection.blocked_threshold, 10);
1543        assert_eq!(cfg.loop_detection.normal_threshold, 2);
1544        assert_eq!(cfg.loop_detection.search_group_limit, 10);
1545    }
1546}
1547
1548impl Config {
1549    /// Returns the path to the global config file (`~/.lean-ctx/config.toml`).
1550    pub fn path() -> Option<PathBuf> {
1551        crate::core::data_dir::lean_ctx_data_dir()
1552            .ok()
1553            .map(|d| d.join("config.toml"))
1554    }
1555
1556    /// Returns the path to the project-local config override file.
1557    pub fn local_path(project_root: &str) -> PathBuf {
1558        PathBuf::from(project_root).join(".lean-ctx.toml")
1559    }
1560
1561    fn find_project_root() -> Option<String> {
1562        static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
1563        ROOT_CACHE
1564            .get_or_init(Self::find_project_root_inner)
1565            .clone()
1566    }
1567
1568    fn find_project_root_inner() -> Option<String> {
1569        if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
1570            if !env_root.is_empty() {
1571                return Some(env_root);
1572            }
1573        }
1574
1575        let cwd = std::env::current_dir().ok();
1576
1577        if let Some(root) =
1578            crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
1579        {
1580            let root_path = std::path::Path::new(&root);
1581            let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
1582            let has_marker = root_path.join(".git").exists()
1583                || root_path.join("Cargo.toml").exists()
1584                || root_path.join("package.json").exists()
1585                || root_path.join("go.mod").exists()
1586                || root_path.join("pyproject.toml").exists()
1587                || root_path.join(".lean-ctx.toml").exists();
1588
1589            if cwd_is_under_root || has_marker {
1590                return Some(root);
1591            }
1592        }
1593
1594        if let Some(ref cwd) = cwd {
1595            let git_root = std::process::Command::new("git")
1596                .args(["rev-parse", "--show-toplevel"])
1597                .current_dir(cwd)
1598                .stdout(std::process::Stdio::piped())
1599                .stderr(std::process::Stdio::null())
1600                .output()
1601                .ok()
1602                .and_then(|o| {
1603                    if o.status.success() {
1604                        String::from_utf8(o.stdout)
1605                            .ok()
1606                            .map(|s| s.trim().to_string())
1607                    } else {
1608                        None
1609                    }
1610                });
1611            if let Some(root) = git_root {
1612                return Some(root);
1613            }
1614            if !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
1615                return Some(cwd.to_string_lossy().to_string());
1616            }
1617        }
1618        None
1619    }
1620
1621    /// Loads config from disk with caching, merging global + project-local overrides.
1622    pub fn load() -> Self {
1623        static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
1624
1625        let Some(path) = Self::path() else {
1626            return Self::default();
1627        };
1628
1629        let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
1630
1631        let mtime = std::fs::metadata(&path)
1632            .and_then(|m| m.modified())
1633            .unwrap_or(SystemTime::UNIX_EPOCH);
1634
1635        let local_mtime = local_path
1636            .as_ref()
1637            .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
1638
1639        if let Ok(guard) = CACHE.lock() {
1640            if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
1641                if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
1642                    return cfg.clone();
1643                }
1644            }
1645        }
1646
1647        let mut cfg: Config = match std::fs::read_to_string(&path) {
1648            Ok(content) => match toml::from_str(&content) {
1649                Ok(c) => c,
1650                Err(e) => {
1651                    tracing::warn!("config parse error in {}: {e}", path.display());
1652                    eprintln!(
1653                        "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n  \
1654                         Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
1655                        path.display()
1656                    );
1657                    Self::default()
1658                }
1659            },
1660            Err(_) => Self::default(),
1661        };
1662
1663        if let Some(ref lp) = local_path {
1664            if let Ok(local_content) = std::fs::read_to_string(lp) {
1665                cfg.merge_local(&local_content);
1666            }
1667        }
1668
1669        if let Ok(mut guard) = CACHE.lock() {
1670            *guard = Some((cfg.clone(), mtime, local_mtime));
1671        }
1672
1673        cfg
1674    }
1675
1676    fn merge_local(&mut self, local_toml: &str) {
1677        let local: Config = match toml::from_str(local_toml) {
1678            Ok(c) => c,
1679            Err(e) => {
1680                tracing::warn!("local config parse error: {e}");
1681                eprintln!(
1682                    "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n  \
1683                     Local overrides skipped.\x1b[0m"
1684                );
1685                return;
1686            }
1687        };
1688        if local.ultra_compact {
1689            self.ultra_compact = true;
1690        }
1691        if local.tee_mode != TeeMode::default() {
1692            self.tee_mode = local.tee_mode;
1693        }
1694        if local.output_density != OutputDensity::default() {
1695            self.output_density = local.output_density;
1696        }
1697        if local.checkpoint_interval != 15 {
1698            self.checkpoint_interval = local.checkpoint_interval;
1699        }
1700        if !local.excluded_commands.is_empty() {
1701            self.excluded_commands.extend(local.excluded_commands);
1702        }
1703        if !local.passthrough_urls.is_empty() {
1704            self.passthrough_urls.extend(local.passthrough_urls);
1705        }
1706        if !local.custom_aliases.is_empty() {
1707            self.custom_aliases.extend(local.custom_aliases);
1708        }
1709        if local.slow_command_threshold_ms != 5000 {
1710            self.slow_command_threshold_ms = local.slow_command_threshold_ms;
1711        }
1712        if local.theme != "default" {
1713            self.theme = local.theme;
1714        }
1715        if !local.buddy_enabled {
1716            self.buddy_enabled = false;
1717        }
1718        if !local.enable_wakeup_ctx {
1719            self.enable_wakeup_ctx = false;
1720        }
1721        if !local.redirect_exclude.is_empty() {
1722            self.redirect_exclude.extend(local.redirect_exclude);
1723        }
1724        if !local.disabled_tools.is_empty() {
1725            self.disabled_tools.extend(local.disabled_tools);
1726        }
1727        if !local.extra_ignore_patterns.is_empty() {
1728            self.extra_ignore_patterns
1729                .extend(local.extra_ignore_patterns);
1730        }
1731        if local.rules_scope.is_some() {
1732            self.rules_scope = local.rules_scope;
1733        }
1734        if local.proxy.anthropic_upstream.is_some() {
1735            self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
1736        }
1737        if local.proxy.openai_upstream.is_some() {
1738            self.proxy.openai_upstream = local.proxy.openai_upstream;
1739        }
1740        if local.proxy.gemini_upstream.is_some() {
1741            self.proxy.gemini_upstream = local.proxy.gemini_upstream;
1742        }
1743        if !local.autonomy.enabled {
1744            self.autonomy.enabled = false;
1745        }
1746        if !local.autonomy.auto_preload {
1747            self.autonomy.auto_preload = false;
1748        }
1749        if !local.autonomy.auto_dedup {
1750            self.autonomy.auto_dedup = false;
1751        }
1752        if !local.autonomy.auto_related {
1753            self.autonomy.auto_related = false;
1754        }
1755        if !local.autonomy.auto_consolidate {
1756            self.autonomy.auto_consolidate = false;
1757        }
1758        if local.autonomy.silent_preload {
1759            self.autonomy.silent_preload = true;
1760        }
1761        if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1762            self.autonomy.silent_preload = false;
1763        }
1764        if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1765            self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1766        }
1767        if local.autonomy.consolidate_every_calls
1768            != AutonomyConfig::default().consolidate_every_calls
1769        {
1770            self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1771        }
1772        if local.autonomy.consolidate_cooldown_secs
1773            != AutonomyConfig::default().consolidate_cooldown_secs
1774        {
1775            self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1776        }
1777        if !local.autonomy.cognition_loop_enabled {
1778            self.autonomy.cognition_loop_enabled = false;
1779        }
1780        if local.autonomy.cognition_loop_interval_secs
1781            != AutonomyConfig::default().cognition_loop_interval_secs
1782        {
1783            self.autonomy.cognition_loop_interval_secs =
1784                local.autonomy.cognition_loop_interval_secs;
1785        }
1786        if local.autonomy.cognition_loop_max_steps
1787            != AutonomyConfig::default().cognition_loop_max_steps
1788        {
1789            self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
1790        }
1791        if local_toml.contains("compression_level") {
1792            self.compression_level = local.compression_level;
1793        }
1794        if local_toml.contains("terse_agent") {
1795            self.terse_agent = local.terse_agent;
1796        }
1797        if !local.archive.enabled {
1798            self.archive.enabled = false;
1799        }
1800        if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1801            self.archive.threshold_chars = local.archive.threshold_chars;
1802        }
1803        if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1804            self.archive.max_age_hours = local.archive.max_age_hours;
1805        }
1806        if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1807            self.archive.max_disk_mb = local.archive.max_disk_mb;
1808        }
1809        let mem_def = MemoryPolicy::default();
1810        if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1811            self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1812        }
1813        if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1814            self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1815        }
1816        if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1817            self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1818        }
1819        if local.memory.knowledge.contradiction_threshold
1820            != mem_def.knowledge.contradiction_threshold
1821        {
1822            self.memory.knowledge.contradiction_threshold =
1823                local.memory.knowledge.contradiction_threshold;
1824        }
1825
1826        if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1827            self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1828        }
1829        if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1830        {
1831            self.memory.episodic.max_actions_per_episode =
1832                local.memory.episodic.max_actions_per_episode;
1833        }
1834        if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1835            self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1836        }
1837
1838        if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1839            self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1840        }
1841        if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1842            self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1843        }
1844        if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1845            self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1846        }
1847        if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1848            self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1849        }
1850
1851        if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1852            self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1853        }
1854        if local.memory.lifecycle.low_confidence_threshold
1855            != mem_def.lifecycle.low_confidence_threshold
1856        {
1857            self.memory.lifecycle.low_confidence_threshold =
1858                local.memory.lifecycle.low_confidence_threshold;
1859        }
1860        if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1861            self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1862        }
1863        if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1864            self.memory.lifecycle.similarity_threshold =
1865                local.memory.lifecycle.similarity_threshold;
1866        }
1867
1868        if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1869            self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1870        }
1871        if !local.allow_paths.is_empty() {
1872            self.allow_paths.extend(local.allow_paths);
1873        }
1874        if local.minimal_overhead {
1875            self.minimal_overhead = true;
1876        }
1877        if local.shell_hook_disabled {
1878            self.shell_hook_disabled = true;
1879        }
1880        if local.shell_activation != ShellActivation::default() {
1881            self.shell_activation = local.shell_activation.clone();
1882        }
1883        if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1884            self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1885        }
1886        if local.memory_profile != MemoryProfile::default() {
1887            self.memory_profile = local.memory_profile;
1888        }
1889        if local.memory_cleanup != MemoryCleanup::default() {
1890            self.memory_cleanup = local.memory_cleanup;
1891        }
1892        if !local.shell_allowlist.is_empty() {
1893            self.shell_allowlist = local.shell_allowlist;
1894        }
1895        if !local.default_tool_categories.is_empty() {
1896            self.default_tool_categories = local.default_tool_categories;
1897        }
1898        if local.no_degrade {
1899            self.no_degrade = true;
1900        }
1901        if local.proxy_timeout_ms.is_some() {
1902            self.proxy_timeout_ms = local.proxy_timeout_ms;
1903        }
1904    }
1905
1906    /// Persists the current config to the global config file.
1907    pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1908        let path = Self::path().ok_or_else(|| {
1909            super::error::LeanCtxError::Config("cannot determine home directory".into())
1910        })?;
1911        if let Some(parent) = path.parent() {
1912            std::fs::create_dir_all(parent)?;
1913        }
1914        let content = toml::to_string_pretty(self)
1915            .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1916        std::fs::write(&path, content)?;
1917        Ok(())
1918    }
1919
1920    /// Formats the current config as a human-readable string with file paths.
1921    pub fn show(&self) -> String {
1922        let global_path = Self::path().map_or_else(
1923            || "~/.lean-ctx/config.toml".to_string(),
1924            |p| p.to_string_lossy().to_string(),
1925        );
1926        let content = toml::to_string_pretty(self).unwrap_or_default();
1927        let mut out = format!("Global config: {global_path}\n\n{content}");
1928
1929        if let Some(root) = Self::find_project_root() {
1930            let local = Self::local_path(&root);
1931            if local.exists() {
1932                out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1933            } else {
1934                out.push_str(&format!(
1935                    "\n\nLocal config: not found (create {} to override per-project)\n",
1936                    local.display()
1937                ));
1938            }
1939        }
1940        out
1941    }
1942}
1943
1944#[cfg(test)]
1945mod compression_level_tests {
1946    use super::*;
1947
1948    #[test]
1949    fn default_is_standard() {
1950        assert_eq!(CompressionLevel::default(), CompressionLevel::Standard);
1951    }
1952
1953    #[test]
1954    fn to_components_off() {
1955        let (ta, od, crp, tm) = CompressionLevel::Off.to_components();
1956        assert_eq!(ta, TerseAgent::Off);
1957        assert_eq!(od, OutputDensity::Normal);
1958        assert_eq!(crp, "off");
1959        assert!(!tm);
1960    }
1961
1962    #[test]
1963    fn to_components_lite() {
1964        let (ta, od, crp, tm) = CompressionLevel::Lite.to_components();
1965        assert_eq!(ta, TerseAgent::Lite);
1966        assert_eq!(od, OutputDensity::Terse);
1967        assert_eq!(crp, "off");
1968        assert!(tm);
1969    }
1970
1971    #[test]
1972    fn to_components_standard() {
1973        let (ta, od, crp, tm) = CompressionLevel::Standard.to_components();
1974        assert_eq!(ta, TerseAgent::Full);
1975        assert_eq!(od, OutputDensity::Terse);
1976        assert_eq!(crp, "compact");
1977        assert!(tm);
1978    }
1979
1980    #[test]
1981    fn to_components_max() {
1982        let (ta, od, crp, tm) = CompressionLevel::Max.to_components();
1983        assert_eq!(ta, TerseAgent::Ultra);
1984        assert_eq!(od, OutputDensity::Ultra);
1985        assert_eq!(crp, "tdd");
1986        assert!(tm);
1987    }
1988
1989    #[test]
1990    fn from_legacy_ultra_agent_maps_to_max() {
1991        assert_eq!(
1992            CompressionLevel::from_legacy(&TerseAgent::Ultra, &OutputDensity::Normal),
1993            CompressionLevel::Max
1994        );
1995    }
1996
1997    #[test]
1998    fn from_legacy_ultra_density_maps_to_max() {
1999        assert_eq!(
2000            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Ultra),
2001            CompressionLevel::Max
2002        );
2003    }
2004
2005    #[test]
2006    fn from_legacy_full_agent_maps_to_standard() {
2007        assert_eq!(
2008            CompressionLevel::from_legacy(&TerseAgent::Full, &OutputDensity::Normal),
2009            CompressionLevel::Standard
2010        );
2011    }
2012
2013    #[test]
2014    fn from_legacy_lite_agent_maps_to_lite() {
2015        assert_eq!(
2016            CompressionLevel::from_legacy(&TerseAgent::Lite, &OutputDensity::Normal),
2017            CompressionLevel::Lite
2018        );
2019    }
2020
2021    #[test]
2022    fn from_legacy_terse_density_maps_to_lite() {
2023        assert_eq!(
2024            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Terse),
2025            CompressionLevel::Lite
2026        );
2027    }
2028
2029    #[test]
2030    fn from_legacy_both_off_maps_to_off() {
2031        assert_eq!(
2032            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Normal),
2033            CompressionLevel::Off
2034        );
2035    }
2036
2037    #[test]
2038    fn labels_match() {
2039        assert_eq!(CompressionLevel::Off.label(), "off");
2040        assert_eq!(CompressionLevel::Lite.label(), "lite");
2041        assert_eq!(CompressionLevel::Standard.label(), "standard");
2042        assert_eq!(CompressionLevel::Max.label(), "max");
2043    }
2044
2045    #[test]
2046    fn is_active_false_for_off() {
2047        assert!(!CompressionLevel::Off.is_active());
2048    }
2049
2050    #[test]
2051    fn is_active_true_for_all_others() {
2052        assert!(CompressionLevel::Lite.is_active());
2053        assert!(CompressionLevel::Standard.is_active());
2054        assert!(CompressionLevel::Max.is_active());
2055    }
2056
2057    #[test]
2058    fn deserialization_defaults_to_standard() {
2059        let cfg: Config = toml::from_str("").unwrap();
2060        assert_eq!(cfg.compression_level, CompressionLevel::Standard);
2061    }
2062
2063    #[test]
2064    fn deserialization_from_toml() {
2065        let cfg: Config = toml::from_str(r#"compression_level = "standard""#).unwrap();
2066        assert_eq!(cfg.compression_level, CompressionLevel::Standard);
2067    }
2068
2069    #[test]
2070    fn roundtrip_all_levels() {
2071        for level in [
2072            CompressionLevel::Off,
2073            CompressionLevel::Lite,
2074            CompressionLevel::Standard,
2075            CompressionLevel::Max,
2076        ] {
2077            let (ta, od, crp, tm) = level.to_components();
2078            assert!(!crp.is_empty());
2079            if level == CompressionLevel::Off {
2080                assert!(!tm);
2081                assert_eq!(ta, TerseAgent::Off);
2082                assert_eq!(od, OutputDensity::Normal);
2083            } else {
2084                assert!(tm);
2085            }
2086        }
2087    }
2088}
2089
2090#[cfg(test)]
2091mod memory_cleanup_tests {
2092    use super::*;
2093
2094    #[test]
2095    fn default_is_aggressive() {
2096        assert_eq!(MemoryCleanup::default(), MemoryCleanup::Aggressive);
2097    }
2098
2099    #[test]
2100    fn aggressive_ttl_is_300() {
2101        assert_eq!(MemoryCleanup::Aggressive.idle_ttl_secs(), 300);
2102    }
2103
2104    #[test]
2105    fn shared_ttl_is_1800() {
2106        assert_eq!(MemoryCleanup::Shared.idle_ttl_secs(), 1800);
2107    }
2108
2109    #[test]
2110    fn index_retention_multiplier_values() {
2111        assert!(
2112            (MemoryCleanup::Aggressive.index_retention_multiplier() - 1.0).abs() < f64::EPSILON
2113        );
2114        assert!((MemoryCleanup::Shared.index_retention_multiplier() - 3.0).abs() < f64::EPSILON);
2115    }
2116
2117    #[test]
2118    fn deserialization_defaults_to_aggressive() {
2119        let cfg: Config = toml::from_str("").unwrap();
2120        assert_eq!(cfg.memory_cleanup, MemoryCleanup::Aggressive);
2121    }
2122
2123    #[test]
2124    fn deserialization_from_toml() {
2125        let cfg: Config = toml::from_str(r#"memory_cleanup = "shared""#).unwrap();
2126        assert_eq!(cfg.memory_cleanup, MemoryCleanup::Shared);
2127    }
2128
2129    #[test]
2130    fn effective_uses_config_when_no_env() {
2131        let cfg = Config {
2132            memory_cleanup: MemoryCleanup::Shared,
2133            ..Default::default()
2134        };
2135        let eff = MemoryCleanup::effective(&cfg);
2136        assert_eq!(eff, MemoryCleanup::Shared);
2137    }
2138}