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    /// Persistent profile name. Checked after LEAN_CTX_PROFILE env var.
327    /// Set via `lean-ctx config set profile passthrough` or editing config.toml.
328    #[serde(default)]
329    pub profile: Option<String>,
330    #[serde(default)]
331    pub loop_detection: LoopDetectionConfig,
332    /// Controls where lean-ctx installs agent rule files.
333    /// Values: "both" (default), "global" (home-dir only), "project" (repo-local only).
334    /// Override via LEAN_CTX_RULES_SCOPE env var.
335    #[serde(default)]
336    pub rules_scope: Option<String>,
337    /// Extra glob patterns to ignore in graph/overview/preload (repo-local).
338    /// Example: `["externals/**", "target/**", "temp/**"]`
339    #[serde(default)]
340    pub extra_ignore_patterns: Vec<String>,
341    /// Controls agent output verbosity via instructions injection.
342    /// Values: "off" (default), "lite", "full", "ultra".
343    /// Override via LEAN_CTX_TERSE_AGENT env var.
344    #[serde(default)]
345    pub terse_agent: TerseAgent,
346    /// Unified compression level (replaces separate terse_agent + output_density).
347    /// Values: "off" (default), "lite", "standard", "max".
348    /// Override via LEAN_CTX_COMPRESSION env var.
349    #[serde(default)]
350    pub compression_level: CompressionLevel,
351    /// Archive configuration for zero-loss compression.
352    #[serde(default)]
353    pub archive: ArchiveConfig,
354    /// Memory policy (knowledge/episodic/procedural/lifecycle budgets & thresholds).
355    #[serde(default)]
356    pub memory: MemoryPolicy,
357    /// Additional paths allowed by PathJail (absolute).
358    /// Useful for multi-project workspaces where the jail root is a parent directory.
359    /// Override via LEAN_CTX_ALLOW_PATH env var (path-list separator).
360    #[serde(default)]
361    pub allow_paths: Vec<String>,
362    /// Extra project roots for multi-root workspaces.
363    /// Tools like ctx_tree and ctx_search can scan across all roots in a single call.
364    /// These paths are automatically added to PathJail's allow-list.
365    /// Override via LEAN_CTX_EXTRA_ROOTS env var (path-list separator).
366    #[serde(default)]
367    pub extra_roots: Vec<String>,
368    /// Enable content-defined chunking (Rabin-Karp) for cache-optimal output ordering.
369    /// Stable chunks are emitted first to maximize prompt cache hits.
370    #[serde(default)]
371    pub content_defined_chunking: bool,
372    /// Skip session/knowledge/gotcha blocks in MCP instructions to minimize token overhead.
373    /// Override via LEAN_CTX_MINIMAL env var.
374    #[serde(default)]
375    pub minimal_overhead: bool,
376    /// Disable shell hook injection (the _lc() function that wraps CLI commands).
377    /// Override via LEAN_CTX_NO_HOOK env var.
378    #[serde(default)]
379    pub shell_hook_disabled: bool,
380    /// Controls when the shell hook auto-activates aliases.
381    /// - `always`: (Default) Aliases active in every interactive shell.
382    /// - `agents-only`: Aliases only active when an AI agent env var is detected.
383    /// - `off`: Aliases never auto-activate (user must call `lean-ctx-on` manually).
384    ///
385    /// Override via `LEAN_CTX_SHELL_ACTIVATION` env var.
386    #[serde(default)]
387    pub shell_activation: ShellActivation,
388    /// Disable the daily version check against leanctx.com/version.txt.
389    /// Override via LEAN_CTX_NO_UPDATE_CHECK env var.
390    #[serde(default)]
391    pub update_check_disabled: bool,
392    #[serde(default)]
393    pub updates: UpdatesConfig,
394    /// Maximum BM25 cache file size in MB. Indexes exceeding this are quarantined on load
395    /// and refused on save. Override via LEAN_CTX_BM25_MAX_CACHE_MB env var.
396    #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
397    pub bm25_max_cache_mb: u64,
398    /// Maximum number of files scanned by the lightweight JSON graph index.
399    /// Increase for large monorepos. Default: 5000.
400    #[serde(default = "serde_defaults::default_graph_index_max_files")]
401    pub graph_index_max_files: u64,
402    /// Controls RAM vs feature trade-off. Values: "low", "balanced" (default), "performance".
403    /// Override via LEAN_CTX_MEMORY_PROFILE env var.
404    #[serde(default)]
405    pub memory_profile: MemoryProfile,
406    /// Controls how aggressively memory is freed when idle.
407    /// Values: "aggressive" (default, 5 min TTL), "shared" (30 min TTL for multi-IDE use).
408    /// Override via LEAN_CTX_MEMORY_CLEANUP env var.
409    #[serde(default)]
410    pub memory_cleanup: MemoryCleanup,
411    /// Maximum percentage of system RAM that lean-ctx may use (default: 5).
412    /// Override via LEAN_CTX_MAX_RAM_PERCENT env var.
413    #[serde(default = "serde_defaults::default_max_ram_percent")]
414    pub max_ram_percent: u8,
415    /// Simplified disk budget (MB). When set and detail values are at defaults,
416    /// distributes proportionally: archive=25%, bm25=10%, remainder for stores.
417    /// 0 = disabled (use individual settings). Override via LEAN_CTX_MAX_DISK_MB.
418    #[serde(default)]
419    pub max_disk_mb: u64,
420    /// Auto-purge data older than this many days. 0 = disabled.
421    /// Flows into archive.max_age_hours and lifecycle idle TTL.
422    #[serde(default)]
423    pub max_staleness_days: u32,
424    /// Controls visibility of token savings footers in tool output.
425    /// Values: "never" (default, suppress everywhere), "always", "auto" (legacy compatibility).
426    /// Override via LEAN_CTX_SAVINGS_FOOTER env var.
427    #[serde(default)]
428    pub savings_footer: SavingsFooter,
429    /// Explicit project root override. When set, lean-ctx uses this instead of auto-detection.
430    /// This prevents accidental home-directory scans when running from $HOME.
431    /// Override via LEAN_CTX_PROJECT_ROOT env var.
432    #[serde(default)]
433    pub project_root: Option<String>,
434    /// LSP server overrides. Map language name to custom binary path.
435    /// Example: `[lsp]\nrust = "/opt/rust-analyzer"\npython = "~/.venvs/main/bin/pylsp"`
436    #[serde(default)]
437    pub lsp: std::collections::HashMap<String, String>,
438    /// Per-IDE allowed paths. Restricts which directories lean-ctx will scan/index for each IDE.
439    /// Example: `[ide_paths]\ncursor = ["/home/user/projects/app1"]\ncodex = ["/home/user/codex"]`
440    /// When set, only these paths are indexed for the matching agent. Global `allow_paths` still applies.
441    #[serde(default)]
442    pub ide_paths: HashMap<String, Vec<String>>,
443    /// Custom model context window overrides.
444    /// Example: `[model_context_windows]\n"my-custom-model" = 500000`
445    #[serde(default)]
446    pub model_context_windows: HashMap<String, usize>,
447    /// Controls how much detail tool responses include.
448    ///
449    /// - `full` (default): complete compressed output
450    /// - `headers_only`: metadata line only (path, mode, token count)
451    ///
452    /// Override via `LEAN_CTX_RESPONSE_VERBOSITY` env var.
453    #[serde(default)]
454    pub response_verbosity: ResponseVerbosity,
455    /// Bypass hint mode. When agents use native Read/Grep instead of lean-ctx tools,
456    /// a hint is appended to the next tool response.
457    /// Values: "on" (default), "off", "aggressive" (hint on every call, no cooldown).
458    /// Override via LEAN_CTX_BYPASS_HINTS env var.
459    #[serde(default)]
460    pub bypass_hints: Option<String>,
461    /// Cache policy for ctx_read. Controls behavior on cache hits.
462    /// Values: "aggressive" (default, 13-tok stubs + compaction-aware reset),
463    /// "safe" (delivers map instead of stub), "off" (no caching, always disk read).
464    /// Override via LEAN_CTX_CACHE_POLICY env var.
465    #[serde(default)]
466    pub cache_policy: Option<String>,
467    /// Cross-project boundary policy.
468    /// Controls whether cross-project search/import is allowed and whether access is audited.
469    #[serde(default)]
470    pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
471    #[serde(default)]
472    pub secret_detection: SecretDetectionConfig,
473    /// Allow automatic project-root re-rooting when absolute paths outside the jail are seen.
474    /// When false (default), absolute paths outside the jail are rejected without re-rooting.
475    /// Override via LEAN_CTX_ALLOW_REROOT env var.
476    #[serde(default)]
477    pub allow_auto_reroot: bool,
478    /// Disable PathJail entirely. Set to false to allow all paths.
479    /// Useful in container/Docker environments. Override via LEAN_CTX_NO_JAIL=1.
480    #[serde(default)]
481    pub path_jail: Option<bool>,
482    /// Sandbox level for code execution (ctx_exec).
483    /// 0 = subprocess only (current), 1 = OS-level restriction (Seatbelt/Landlock).
484    /// Override via LEAN_CTX_SANDBOX_LEVEL env var.
485    #[serde(default)]
486    pub sandbox_level: u8,
487    /// When true, large tool outputs (>4000 chars) are stored as references
488    /// and a short URI is returned instead of the full content.
489    /// Override via LEAN_CTX_REFERENCE_RESULTS env var.
490    #[serde(default)]
491    pub reference_results: bool,
492    /// Default per-agent token budget. 0 means unlimited.
493    /// Override per-agent via ctx_session or programmatically.
494    #[serde(default)]
495    pub agent_token_budget: usize,
496    /// Optional shell command allowlist. When non-empty, only commands whose base binary
497    /// is in this list are permitted by ctx_shell. Empty = disable allowlist (allow all).
498    /// Default includes common dev tools. Set to `[]` to disable.
499    /// Override via LEAN_CTX_SHELL_ALLOWLIST env var (comma-separated).
500    #[serde(default = "default_shell_allowlist")]
501    pub shell_allowlist: Vec<String>,
502}
503
504#[derive(Debug, Clone, Serialize, Deserialize)]
505#[serde(default)]
506pub struct SecretDetectionConfig {
507    pub enabled: bool,
508    pub redact: bool,
509    pub custom_patterns: Vec<String>,
510}
511
512impl Default for SecretDetectionConfig {
513    fn default() -> Self {
514        Self {
515            enabled: true,
516            redact: false,
517            custom_patterns: Vec::new(),
518        }
519    }
520}
521
522/// Settings for the zero-loss compression archive (large tool outputs saved to disk).
523#[derive(Debug, Clone, Serialize, Deserialize)]
524#[serde(default)]
525pub struct ArchiveConfig {
526    pub enabled: bool,
527    pub threshold_chars: usize,
528    pub max_age_hours: u64,
529    pub max_disk_mb: u64,
530}
531
532impl Default for ArchiveConfig {
533    fn default() -> Self {
534        Self {
535            enabled: true,
536            threshold_chars: 4096,
537            max_age_hours: 48,
538            max_disk_mb: 500,
539        }
540    }
541}
542
543/// Configuration for external context providers (GitHub, GitLab, Jira, etc.).
544/// Each provider can be enabled/disabled and configured with auth tokens.
545/// Override individual tokens via env vars (GITHUB_TOKEN, GITLAB_TOKEN, etc.).
546#[derive(Debug, Clone, Serialize, Deserialize)]
547#[serde(default)]
548pub struct ProvidersConfig {
549    /// Master switch for the provider subsystem.
550    pub enabled: bool,
551    /// GitHub provider configuration.
552    pub github: ProviderEntryConfig,
553    /// GitLab provider configuration.
554    pub gitlab: ProviderEntryConfig,
555    /// Auto-ingest provider results into BM25/embedding indexes.
556    pub auto_index: bool,
557    /// Default cache TTL for provider results (seconds).
558    pub cache_ttl_secs: u64,
559    /// MCP Bridge providers: `{ "name" = { url = "...", description = "..." } }`.
560    #[serde(default)]
561    pub mcp_bridges: std::collections::HashMap<String, McpBridgeEntry>,
562}
563
564impl Default for ProvidersConfig {
565    fn default() -> Self {
566        Self {
567            enabled: true,
568            github: ProviderEntryConfig::default(),
569            gitlab: ProviderEntryConfig::default(),
570            auto_index: true,
571            cache_ttl_secs: 120,
572            mcp_bridges: std::collections::HashMap::new(),
573        }
574    }
575}
576
577#[derive(Debug, Clone, Serialize, Deserialize)]
578pub struct McpBridgeEntry {
579    /// HTTP/SSE URL for remote MCP servers.
580    #[serde(default)]
581    pub url: Option<String>,
582    /// Command to spawn a local MCP server (stdio transport).
583    #[serde(default)]
584    pub command: Option<String>,
585    /// Arguments for the command.
586    #[serde(default)]
587    pub args: Vec<String>,
588    /// Human-readable description.
589    #[serde(default)]
590    pub description: Option<String>,
591    /// Environment variable name containing an auth token.
592    #[serde(default)]
593    pub auth_env: Option<String>,
594}
595
596/// Per-provider configuration entry.
597#[derive(Debug, Clone, Serialize, Deserialize)]
598#[serde(default)]
599pub struct ProviderEntryConfig {
600    /// Whether this specific provider is enabled.
601    pub enabled: bool,
602    /// Auth token (prefer env var; only use this for project-local overrides).
603    pub token: Option<String>,
604    /// API base URL override (for GitHub Enterprise, self-hosted GitLab, etc.).
605    pub api_url: Option<String>,
606    /// Default project/repo for this provider (auto-detected from git remote if empty).
607    pub project: Option<String>,
608}
609
610impl Default for ProviderEntryConfig {
611    fn default() -> Self {
612        Self {
613            enabled: true,
614            token: None,
615            api_url: None,
616            project: None,
617        }
618    }
619}
620
621/// Controls autonomous background behaviors (preload, dedup, consolidation).
622#[derive(Debug, Clone, Serialize, Deserialize)]
623#[serde(default)]
624pub struct AutonomyConfig {
625    pub enabled: bool,
626    pub auto_preload: bool,
627    pub auto_dedup: bool,
628    pub auto_related: bool,
629    pub auto_consolidate: bool,
630    pub silent_preload: bool,
631    pub dedup_threshold: usize,
632    pub consolidate_every_calls: u32,
633    pub consolidate_cooldown_secs: u64,
634    #[serde(default = "serde_defaults::default_true")]
635    pub cognition_loop_enabled: bool,
636    #[serde(default = "serde_defaults::default_cognition_loop_interval")]
637    pub cognition_loop_interval_secs: u64,
638    #[serde(default = "serde_defaults::default_cognition_loop_max_steps")]
639    pub cognition_loop_max_steps: u8,
640}
641
642impl Default for AutonomyConfig {
643    fn default() -> Self {
644        Self {
645            enabled: true,
646            auto_preload: true,
647            auto_dedup: true,
648            auto_related: true,
649            auto_consolidate: true,
650            silent_preload: true,
651            dedup_threshold: 8,
652            consolidate_every_calls: 25,
653            consolidate_cooldown_secs: 120,
654            cognition_loop_enabled: true,
655            cognition_loop_interval_secs: 3600,
656            cognition_loop_max_steps: 8,
657        }
658    }
659}
660
661/// Controls automatic update behavior. All defaults are OFF — auto-updates
662/// require explicit opt-in via `lean-ctx setup` or `lean-ctx update --schedule`.
663#[derive(Debug, Clone, Serialize, Deserialize)]
664#[serde(default)]
665pub struct UpdatesConfig {
666    pub auto_update: bool,
667    pub check_interval_hours: u64,
668    pub notify_only: bool,
669}
670
671impl Default for UpdatesConfig {
672    fn default() -> Self {
673        Self {
674            auto_update: false,
675            check_interval_hours: 6,
676            notify_only: false,
677        }
678    }
679}
680
681impl UpdatesConfig {
682    pub fn from_env() -> Self {
683        let mut cfg = Self::default();
684        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_UPDATE") {
685            cfg.auto_update = v == "1" || v.eq_ignore_ascii_case("true");
686        }
687        if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_INTERVAL_HOURS") {
688            if let Ok(h) = v.parse::<u64>() {
689                cfg.check_interval_hours = h.clamp(1, 168);
690            }
691        }
692        if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_NOTIFY_ONLY") {
693            cfg.notify_only = v == "1" || v.eq_ignore_ascii_case("true");
694        }
695        cfg
696    }
697}
698
699impl AutonomyConfig {
700    /// Creates an autonomy config from env vars, falling back to defaults.
701    pub fn from_env() -> Self {
702        let mut cfg = Self::default();
703        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
704            if v == "false" || v == "0" {
705                cfg.enabled = false;
706            }
707        }
708        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
709            cfg.auto_preload = v != "false" && v != "0";
710        }
711        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
712            cfg.auto_dedup = v != "false" && v != "0";
713        }
714        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
715            cfg.auto_related = v != "false" && v != "0";
716        }
717        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
718            cfg.auto_consolidate = v != "false" && v != "0";
719        }
720        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
721            cfg.silent_preload = v != "false" && v != "0";
722        }
723        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
724            if let Ok(n) = v.parse() {
725                cfg.dedup_threshold = n;
726            }
727        }
728        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
729            if let Ok(n) = v.parse() {
730                cfg.consolidate_every_calls = n;
731            }
732        }
733        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
734            if let Ok(n) = v.parse() {
735                cfg.consolidate_cooldown_secs = n;
736            }
737        }
738        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
739            cfg.cognition_loop_enabled = v != "false" && v != "0";
740        }
741        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
742            if let Ok(n) = v.parse() {
743                cfg.cognition_loop_interval_secs = n;
744            }
745        }
746        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
747            if let Ok(n) = v.parse() {
748                cfg.cognition_loop_max_steps = n;
749            }
750        }
751        cfg
752    }
753
754    /// Loads autonomy config from disk, with env var overrides applied.
755    pub fn load() -> Self {
756        let file_cfg = Config::load().autonomy;
757        let mut cfg = file_cfg;
758        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
759            if v == "false" || v == "0" {
760                cfg.enabled = false;
761            }
762        }
763        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
764            cfg.auto_preload = v != "false" && v != "0";
765        }
766        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
767            cfg.auto_dedup = v != "false" && v != "0";
768        }
769        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
770            cfg.auto_related = v != "false" && v != "0";
771        }
772        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
773            cfg.silent_preload = v != "false" && v != "0";
774        }
775        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
776            if let Ok(n) = v.parse() {
777                cfg.dedup_threshold = n;
778            }
779        }
780        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
781            cfg.cognition_loop_enabled = v != "false" && v != "0";
782        }
783        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
784            if let Ok(n) = v.parse() {
785                cfg.cognition_loop_interval_secs = n;
786            }
787        }
788        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
789            if let Ok(n) = v.parse() {
790                cfg.cognition_loop_max_steps = n;
791            }
792        }
793        cfg
794    }
795}
796
797/// Cloud sync and contribution settings (pattern sharing, model pulls).
798#[derive(Debug, Clone, Serialize, Deserialize, Default)]
799#[serde(default)]
800pub struct CloudConfig {
801    pub contribute_enabled: bool,
802    pub last_contribute: Option<String>,
803    pub last_sync: Option<String>,
804    pub last_gain_sync: Option<String>,
805    pub last_model_pull: Option<String>,
806}
807
808/// A user-defined command alias mapping for shell compression patterns.
809#[derive(Debug, Clone, Serialize, Deserialize)]
810pub struct AliasEntry {
811    pub command: String,
812    pub alias: String,
813}
814
815/// Thresholds for detecting and throttling repetitive agent tool call loops.
816#[derive(Debug, Clone, Serialize, Deserialize)]
817#[serde(default)]
818pub struct LoopDetectionConfig {
819    pub normal_threshold: u32,
820    pub reduced_threshold: u32,
821    pub blocked_threshold: u32,
822    pub window_secs: u64,
823    pub search_group_limit: u32,
824    pub tool_total_limits: HashMap<String, u32>,
825}
826
827impl Default for LoopDetectionConfig {
828    fn default() -> Self {
829        let mut tool_total_limits = HashMap::new();
830        tool_total_limits.insert("ctx_read".to_string(), 100);
831        tool_total_limits.insert("ctx_search".to_string(), 80);
832        tool_total_limits.insert("ctx_shell".to_string(), 50);
833        tool_total_limits.insert("ctx_semantic_search".to_string(), 60);
834        Self {
835            normal_threshold: 2,
836            reduced_threshold: 4,
837            blocked_threshold: 0,
838            window_secs: 300,
839            search_group_limit: 10,
840            tool_total_limits,
841        }
842    }
843}
844
845impl Default for Config {
846    fn default() -> Self {
847        Self {
848            ultra_compact: false,
849            tee_mode: TeeMode::default(),
850            output_density: OutputDensity::default(),
851            checkpoint_interval: 15,
852            excluded_commands: Vec::new(),
853            passthrough_urls: Vec::new(),
854            custom_aliases: Vec::new(),
855            slow_command_threshold_ms: 5000,
856            theme: serde_defaults::default_theme(),
857            cloud: CloudConfig::default(),
858            autonomy: AutonomyConfig::default(),
859            providers: ProvidersConfig::default(),
860            proxy: ProxyConfig::default(),
861            proxy_enabled: None,
862            proxy_port: None,
863            proxy_timeout_ms: None,
864            buddy_enabled: serde_defaults::default_buddy_enabled(),
865            enable_wakeup_ctx: true,
866            redirect_exclude: Vec::new(),
867            disabled_tools: Vec::new(),
868            default_tool_categories: Vec::new(),
869            no_degrade: false,
870            profile: None,
871            loop_detection: LoopDetectionConfig::default(),
872            rules_scope: None,
873            extra_ignore_patterns: Vec::new(),
874            terse_agent: TerseAgent::default(),
875            compression_level: CompressionLevel::default(),
876            archive: ArchiveConfig::default(),
877            memory: MemoryPolicy::default(),
878            allow_paths: Vec::new(),
879            extra_roots: Vec::new(),
880            content_defined_chunking: false,
881            minimal_overhead: false,
882            shell_hook_disabled: false,
883            shell_activation: ShellActivation::default(),
884            update_check_disabled: false,
885            updates: UpdatesConfig::default(),
886            graph_index_max_files: serde_defaults::default_graph_index_max_files(),
887            bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
888            memory_profile: MemoryProfile::default(),
889            memory_cleanup: MemoryCleanup::default(),
890            max_ram_percent: serde_defaults::default_max_ram_percent(),
891            max_disk_mb: 0,
892            max_staleness_days: 0,
893            savings_footer: SavingsFooter::default(),
894            project_root: None,
895            lsp: std::collections::HashMap::new(),
896            ide_paths: HashMap::new(),
897            model_context_windows: HashMap::new(),
898            response_verbosity: ResponseVerbosity::default(),
899            bypass_hints: None,
900            cache_policy: None,
901            boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
902            secret_detection: SecretDetectionConfig::default(),
903            allow_auto_reroot: false,
904            path_jail: None,
905            sandbox_level: 0,
906            reference_results: false,
907            agent_token_budget: 0,
908            shell_allowlist: default_shell_allowlist(),
909        }
910    }
911}
912
913pub(crate) fn default_shell_allowlist() -> Vec<String> {
914    [
915        // VCS
916        "git",
917        "gh",
918        "svn",
919        "hg",
920        // Build tools
921        "cargo",
922        "npm",
923        "npx",
924        "yarn",
925        "pnpm",
926        "bun",
927        "make",
928        "cmake",
929        "pip",
930        "pip3",
931        "poetry",
932        "uv",
933        "go",
934        "mvn",
935        "gradle",
936        "mix",
937        "dotnet",
938        "swift",
939        "zig",
940        "rustup",
941        "rustc",
942        "deno",
943        "bazel",
944        // Package managers
945        "pipenv",
946        "conda",
947        "mamba",
948        "brew",
949        "apt",
950        "apt-get",
951        "apk",
952        "nix",
953        // Common CLI
954        "ls",
955        "cat",
956        "head",
957        "tail",
958        "wc",
959        "sort",
960        "uniq",
961        "tr",
962        "cut",
963        "grep",
964        "rg",
965        "find",
966        "fd",
967        "ag",
968        "ack",
969        "sed",
970        "awk",
971        "echo",
972        "printf",
973        "true",
974        "false",
975        "test",
976        "expr",
977        "cd",
978        "pwd",
979        "basename",
980        "dirname",
981        "realpath",
982        "readlink",
983        "cp",
984        "mv",
985        "mkdir",
986        "rm",
987        "rmdir",
988        "touch",
989        "ln",
990        "chmod",
991        "chown",
992        "diff",
993        "patch",
994        "tar",
995        "zip",
996        "unzip",
997        "gzip",
998        "gunzip",
999        "zstd",
1000        "curl",
1001        "wget",
1002        "tree",
1003        "du",
1004        "df",
1005        "ps",
1006        "lsof",
1007        "watch",
1008        "tee",
1009        "less",
1010        "more",
1011        "id",
1012        "whoami",
1013        "uname",
1014        "hostname",
1015        // Dev tools
1016        "docker",
1017        "docker-compose",
1018        "podman",
1019        "node",
1020        "python",
1021        "python3",
1022        "ruby",
1023        "perl",
1024        "java",
1025        "javac",
1026        "tsc",
1027        "eslint",
1028        "prettier",
1029        "black",
1030        "ruff",
1031        "clippy",
1032        "jq",
1033        "yq",
1034        "xargs",
1035        "env",
1036        "which",
1037        "type",
1038        "file",
1039        "stat",
1040        "date",
1041        "sleep",
1042        "timeout",
1043        "nice",
1044        "ionice",
1045        // Testing frameworks
1046        "pytest",
1047        "py.test",
1048        "jest",
1049        "vitest",
1050        "mocha",
1051        "cypress",
1052        "playwright",
1053        "puppeteer",
1054        // Pre-commit & git hooks
1055        "pre-commit",
1056        "husky",
1057        "lint-staged",
1058        "lefthook",
1059        "overcommit",
1060        "commitlint",
1061        // Linters & formatters
1062        "mypy",
1063        "pyright",
1064        "pylint",
1065        "flake8",
1066        "bandit",
1067        "isort",
1068        "autopep8",
1069        "yapf",
1070        "golangci-lint",
1071        "shellcheck",
1072        "markdownlint",
1073        "stylelint",
1074        // Bundlers & dev servers
1075        "webpack",
1076        "vite",
1077        "esbuild",
1078        "rollup",
1079        "turbo",
1080        "nx",
1081        "lerna",
1082        "next",
1083        "nuxt",
1084        // Ruby ecosystem
1085        "bundle",
1086        "bundler",
1087        "rake",
1088        "rails",
1089        "rspec",
1090        "rubocop",
1091        // PHP ecosystem
1092        "php",
1093        "composer",
1094        "phpunit",
1095        "artisan",
1096        // Mobile
1097        "flutter",
1098        "dart",
1099        "xcodebuild",
1100        "xcrun",
1101        "pod",
1102        "fastlane",
1103        // Cloud & infra
1104        "terraform",
1105        "ansible",
1106        "kubectl",
1107        "helm",
1108        "az",
1109        "aws",
1110        "gcloud",
1111        "firebase",
1112        "heroku",
1113        "vercel",
1114        "netlify",
1115        "fly",
1116        "wrangler",
1117        "pulumi",
1118        // Database
1119        "psql",
1120        "mysql",
1121        "sqlite3",
1122        "mongosh",
1123        "redis-cli",
1124        "pg_dump",
1125        "pg_restore",
1126        "mysqldump",
1127        // JVM ecosystem
1128        "scala",
1129        "sbt",
1130        "kotlin",
1131        "kotlinc",
1132        // Elixir
1133        "elixir",
1134        "iex",
1135        // lean-ctx itself
1136        "lean-ctx",
1137    ]
1138    .iter()
1139    .map(|s| (*s).to_string())
1140    .collect()
1141}
1142
1143/// Where agent rule files are installed: global home dir, project-local, or both.
1144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1145pub enum RulesScope {
1146    Both,
1147    Global,
1148    Project,
1149}
1150
1151impl Config {
1152    /// Returns the effective rules scope, preferring env var over config file.
1153    pub fn rules_scope_effective(&self) -> RulesScope {
1154        let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
1155            .ok()
1156            .or_else(|| self.rules_scope.clone())
1157            .unwrap_or_default();
1158        match raw.trim().to_lowercase().as_str() {
1159            "global" => RulesScope::Global,
1160            "project" => RulesScope::Project,
1161            _ => RulesScope::Both,
1162        }
1163    }
1164
1165    fn parse_disabled_tools_env(val: &str) -> Vec<String> {
1166        val.split(',')
1167            .map(|s| s.trim().to_string())
1168            .filter(|s| !s.is_empty())
1169            .collect()
1170    }
1171
1172    /// Returns the effective disabled tools list, preferring env var over config file.
1173    pub fn disabled_tools_effective(&self) -> Vec<String> {
1174        if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
1175            Self::parse_disabled_tools_env(&val)
1176        } else {
1177            self.disabled_tools.clone()
1178        }
1179    }
1180
1181    /// Returns `true` if minimal overhead is enabled via env var or config.
1182    pub fn minimal_overhead_effective(&self) -> bool {
1183        std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
1184    }
1185
1186    /// Returns `true` if minimal overhead should be enabled for this MCP client.
1187    ///
1188    /// This is a superset of `minimal_overhead_effective()`:
1189    /// - `LEAN_CTX_OVERHEAD_MODE=minimal` forces minimal overhead
1190    /// - `LEAN_CTX_OVERHEAD_MODE=full` disables client/model heuristics (still honors LEAN_CTX_MINIMAL / config)
1191    /// - In auto mode (default), certain low-context clients/models are treated as minimal to prevent
1192    ///   large metadata blocks from destabilizing smaller context windows (e.g. Hermes + MiniMax).
1193    pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
1194        if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
1195            match raw.trim().to_lowercase().as_str() {
1196                "minimal" => return true,
1197                "full" => return self.minimal_overhead_effective(),
1198                _ => {}
1199            }
1200        }
1201
1202        if self.minimal_overhead_effective() {
1203            return true;
1204        }
1205
1206        let client_lower = client_name.trim().to_lowercase();
1207        if !client_lower.is_empty() {
1208            if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
1209                for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
1210                    if !needle.is_empty() && client_lower.contains(&needle) {
1211                        return true;
1212                    }
1213                }
1214            } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
1215                return true;
1216            }
1217        }
1218
1219        let model = std::env::var("LEAN_CTX_MODEL")
1220            .or_else(|_| std::env::var("LCTX_MODEL"))
1221            .unwrap_or_default();
1222        let model = model.trim().to_lowercase();
1223        if !model.is_empty() {
1224            let m = model.replace(['_', ' '], "-");
1225            if m.contains("minimax")
1226                || m.contains("mini-max")
1227                || m.contains("m2.7")
1228                || m.contains("m2-7")
1229            {
1230                return true;
1231            }
1232        }
1233
1234        false
1235    }
1236
1237    /// Returns `true` if shell hook injection is disabled via env var or config.
1238    pub fn shell_hook_disabled_effective(&self) -> bool {
1239        std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
1240    }
1241
1242    /// Returns the effective shell activation mode (env var > config > default).
1243    pub fn shell_activation_effective(&self) -> ShellActivation {
1244        ShellActivation::effective(self)
1245    }
1246
1247    /// Returns `true` if the daily update check is disabled via env var or config.
1248    pub fn update_check_disabled_effective(&self) -> bool {
1249        std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
1250    }
1251
1252    pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
1253        let mut policy = self.memory.clone();
1254        policy.apply_env_overrides();
1255
1256        // Scale memory limits proportionally when max_disk_mb is set
1257        // and individual limits are still at their defaults.
1258        let budget = self.max_disk_mb_effective();
1259        if budget > 0 {
1260            let scale_factor = (budget as f64 / 500.0).clamp(0.5, 10.0);
1261            let default_policy = MemoryPolicy::default();
1262            if policy.knowledge.max_facts == default_policy.knowledge.max_facts {
1263                policy.knowledge.max_facts = (200.0 * scale_factor) as usize;
1264            }
1265            if policy.knowledge.max_patterns == default_policy.knowledge.max_patterns {
1266                policy.knowledge.max_patterns = (50.0 * scale_factor) as usize;
1267            }
1268            if policy.episodic.max_episodes == default_policy.episodic.max_episodes {
1269                policy.episodic.max_episodes = (500.0 * scale_factor) as usize;
1270            }
1271            if policy.procedural.max_procedures == default_policy.procedural.max_procedures {
1272                policy.procedural.max_procedures = (100.0 * scale_factor) as usize;
1273            }
1274        }
1275
1276        policy.validate()?;
1277        Ok(policy)
1278    }
1279
1280    /// Returns the effective set of default tool categories.
1281    /// Priority: LCTX_DEFAULT_CATEGORIES env var > config.toml > hardcoded default.
1282    pub fn default_tool_categories_effective(&self) -> Vec<String> {
1283        if let Ok(val) = std::env::var("LCTX_DEFAULT_CATEGORIES") {
1284            return val
1285                .split(',')
1286                .map(|s| s.trim().to_lowercase())
1287                .filter(|s| !s.is_empty())
1288                .collect();
1289        }
1290        if !self.default_tool_categories.is_empty() {
1291            return self
1292                .default_tool_categories
1293                .iter()
1294                .map(|s| s.to_lowercase())
1295                .collect();
1296        }
1297        vec!["core".to_string(), "session".to_string()]
1298    }
1299
1300    /// Returns `true` if all automatic read-mode degradation is disabled.
1301    /// Checks LCTX_NO_DEGRADE env var first, then config.toml field.
1302    pub fn no_degrade_effective(&self) -> bool {
1303        if let Ok(val) = std::env::var("LCTX_NO_DEGRADE") {
1304            return val == "1" || val.eq_ignore_ascii_case("true");
1305        }
1306        self.no_degrade
1307    }
1308
1309    /// Effective max_disk_mb from env or config.
1310    pub fn max_disk_mb_effective(&self) -> u64 {
1311        std::env::var("LEAN_CTX_MAX_DISK_MB")
1312            .ok()
1313            .and_then(|v| v.parse().ok())
1314            .unwrap_or(self.max_disk_mb)
1315    }
1316
1317    /// Effective max_staleness_days from env or config.
1318    pub fn max_staleness_days_effective(&self) -> u32 {
1319        std::env::var("LEAN_CTX_MAX_STALENESS_DAYS")
1320            .ok()
1321            .and_then(|v| v.parse().ok())
1322            .unwrap_or(self.max_staleness_days)
1323    }
1324
1325    /// Archive max_disk_mb derived from simplified max_disk_mb if the detail
1326    /// value is still at its default. Explicit overrides take priority.
1327    pub fn archive_max_disk_mb_effective(&self) -> u64 {
1328        let budget = self.max_disk_mb_effective();
1329        if budget > 0 && self.archive.max_disk_mb == ArchiveConfig::default().max_disk_mb {
1330            budget * 25 / 100
1331        } else {
1332            self.archive.max_disk_mb
1333        }
1334    }
1335
1336    /// Archive max_age_hours derived from max_staleness_days if the detail
1337    /// value is still at its default. Explicit overrides take priority.
1338    pub fn archive_max_age_hours_effective(&self) -> u64 {
1339        let staleness = self.max_staleness_days_effective();
1340        if staleness > 0 && self.archive.max_age_hours == ArchiveConfig::default().max_age_hours {
1341            staleness as u64 * 24
1342        } else {
1343            self.archive.max_age_hours
1344        }
1345    }
1346
1347    /// BM25 max cache MB derived from simplified max_disk_mb if the detail
1348    /// value is still at its default. Explicit overrides and MemoryProfile take priority.
1349    pub fn bm25_max_cache_mb_effective(&self) -> u64 {
1350        let budget = self.max_disk_mb_effective();
1351        if budget > 0 && self.bm25_max_cache_mb == serde_defaults::default_bm25_max_cache_mb() {
1352            budget * 10 / 100
1353        } else {
1354            let profile = MemoryProfile::effective(self);
1355            if self.bm25_max_cache_mb == serde_defaults::default_bm25_max_cache_mb() {
1356                profile.bm25_max_cache_mb()
1357            } else {
1358                self.bm25_max_cache_mb
1359            }
1360        }
1361    }
1362}
1363
1364#[cfg(test)]
1365mod disabled_tools_tests {
1366    use super::*;
1367
1368    #[test]
1369    fn config_field_default_is_empty() {
1370        let cfg = Config::default();
1371        assert!(cfg.disabled_tools.is_empty());
1372    }
1373
1374    #[test]
1375    fn effective_returns_config_field_when_no_env_var() {
1376        // Only meaningful when LEAN_CTX_DISABLED_TOOLS is unset; skip otherwise.
1377        if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
1378            return;
1379        }
1380        let cfg = Config {
1381            disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
1382            ..Default::default()
1383        };
1384        assert_eq!(
1385            cfg.disabled_tools_effective(),
1386            vec!["ctx_graph", "ctx_agent"]
1387        );
1388    }
1389
1390    #[test]
1391    fn parse_env_basic() {
1392        let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
1393        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
1394    }
1395
1396    #[test]
1397    fn parse_env_trims_whitespace_and_skips_empty() {
1398        let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
1399        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
1400    }
1401
1402    #[test]
1403    fn parse_env_single_entry() {
1404        let result = Config::parse_disabled_tools_env("ctx_graph");
1405        assert_eq!(result, vec!["ctx_graph"]);
1406    }
1407
1408    #[test]
1409    fn parse_env_empty_string_returns_empty() {
1410        let result = Config::parse_disabled_tools_env("");
1411        assert!(result.is_empty());
1412    }
1413
1414    #[test]
1415    fn disabled_tools_deserialization_defaults_to_empty() {
1416        let cfg: Config = toml::from_str("").unwrap();
1417        assert!(cfg.disabled_tools.is_empty());
1418    }
1419
1420    #[test]
1421    fn disabled_tools_deserialization_from_toml() {
1422        let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
1423        assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
1424    }
1425}
1426
1427#[cfg(test)]
1428mod default_tool_categories_tests {
1429    use super::*;
1430
1431    // --- Defaults ---
1432
1433    #[test]
1434    fn default_returns_core_and_session() {
1435        if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1436            return;
1437        }
1438        let cfg = Config::default();
1439        assert_eq!(
1440            cfg.default_tool_categories_effective(),
1441            vec!["core", "session"]
1442        );
1443    }
1444
1445    #[test]
1446    fn default_struct_field_is_empty_vec() {
1447        let cfg = Config::default();
1448        assert!(cfg.default_tool_categories.is_empty());
1449    }
1450
1451    // --- Config field overrides ---
1452
1453    #[test]
1454    fn config_field_overrides_default() {
1455        if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1456            return;
1457        }
1458        let cfg = Config {
1459            default_tool_categories: vec![
1460                "core".to_string(),
1461                "arch".to_string(),
1462                "memory".to_string(),
1463            ],
1464            ..Default::default()
1465        };
1466        assert_eq!(
1467            cfg.default_tool_categories_effective(),
1468            vec!["core", "arch", "memory"]
1469        );
1470    }
1471
1472    #[test]
1473    fn single_category_in_config() {
1474        if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1475            return;
1476        }
1477        let cfg = Config {
1478            default_tool_categories: vec!["debug".to_string()],
1479            ..Default::default()
1480        };
1481        assert_eq!(cfg.default_tool_categories_effective(), vec!["debug"]);
1482    }
1483
1484    #[test]
1485    fn all_six_categories_in_config() {
1486        if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1487            return;
1488        }
1489        let cfg = Config {
1490            default_tool_categories: vec![
1491                "core".to_string(),
1492                "arch".to_string(),
1493                "debug".to_string(),
1494                "memory".to_string(),
1495                "metrics".to_string(),
1496                "session".to_string(),
1497            ],
1498            ..Default::default()
1499        };
1500        let effective = cfg.default_tool_categories_effective();
1501        assert_eq!(effective.len(), 6);
1502        assert!(effective.contains(&"core".to_string()));
1503        assert!(effective.contains(&"metrics".to_string()));
1504    }
1505
1506    // --- TOML deserialization ---
1507
1508    #[test]
1509    fn deserialization_defaults_to_empty() {
1510        let cfg: Config = toml::from_str("").unwrap();
1511        assert!(cfg.default_tool_categories.is_empty());
1512    }
1513
1514    #[test]
1515    fn deserialization_from_toml() {
1516        let cfg: Config =
1517            toml::from_str(r#"default_tool_categories = ["core", "arch", "debug"]"#).unwrap();
1518        assert_eq!(cfg.default_tool_categories, vec!["core", "arch", "debug"]);
1519    }
1520
1521    #[test]
1522    fn deserialization_empty_array() {
1523        let cfg: Config = toml::from_str(r"default_tool_categories = []").unwrap();
1524        assert!(cfg.default_tool_categories.is_empty());
1525    }
1526
1527    #[test]
1528    fn deserialization_single_entry() {
1529        let cfg: Config = toml::from_str(r#"default_tool_categories = ["memory"]"#).unwrap();
1530        assert_eq!(cfg.default_tool_categories, vec!["memory"]);
1531    }
1532
1533    // --- Edge cases ---
1534
1535    #[test]
1536    fn effective_normalizes_config_to_lowercase() {
1537        if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1538            return;
1539        }
1540        let cfg = Config {
1541            default_tool_categories: vec!["ARCH".to_string(), "Debug".to_string()],
1542            ..Default::default()
1543        };
1544        let effective = cfg.default_tool_categories_effective();
1545        assert_eq!(effective, vec!["arch", "debug"]);
1546    }
1547}
1548
1549#[cfg(test)]
1550mod no_degrade_tests {
1551    use super::*;
1552
1553    // --- Defaults ---
1554
1555    #[test]
1556    fn default_is_false() {
1557        let cfg = Config::default();
1558        assert!(!cfg.no_degrade);
1559    }
1560
1561    #[test]
1562    fn effective_false_when_unset() {
1563        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1564            return;
1565        }
1566        let cfg = Config::default();
1567        assert!(!cfg.no_degrade_effective());
1568    }
1569
1570    // --- Config field ---
1571
1572    #[test]
1573    fn config_field_true_respected_when_no_env() {
1574        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1575            return;
1576        }
1577        let cfg = Config {
1578            no_degrade: true,
1579            ..Default::default()
1580        };
1581        assert!(cfg.no_degrade_effective());
1582    }
1583
1584    #[test]
1585    fn config_field_false_respected_when_no_env() {
1586        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1587            return;
1588        }
1589        let cfg = Config {
1590            no_degrade: false,
1591            ..Default::default()
1592        };
1593        assert!(!cfg.no_degrade_effective());
1594    }
1595
1596    // --- TOML deserialization ---
1597
1598    #[test]
1599    fn deserialization_true() {
1600        let cfg: Config = toml::from_str("no_degrade = true").unwrap();
1601        assert!(cfg.no_degrade);
1602    }
1603
1604    #[test]
1605    fn deserialization_false() {
1606        let cfg: Config = toml::from_str("no_degrade = false").unwrap();
1607        assert!(!cfg.no_degrade);
1608    }
1609
1610    #[test]
1611    fn deserialization_absent_defaults_false() {
1612        let cfg: Config = toml::from_str("").unwrap();
1613        assert!(!cfg.no_degrade);
1614    }
1615
1616    // --- Coexistence with other config fields ---
1617
1618    #[test]
1619    fn no_degrade_independent_of_disabled_tools() {
1620        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1621            return;
1622        }
1623        let cfg = Config {
1624            no_degrade: true,
1625            disabled_tools: vec!["ctx_graph".to_string()],
1626            ..Default::default()
1627        };
1628        assert!(cfg.no_degrade_effective());
1629        assert!(!cfg.disabled_tools.is_empty());
1630    }
1631
1632    #[test]
1633    fn no_degrade_independent_of_tool_categories() {
1634        if std::env::var("LCTX_NO_DEGRADE").is_ok()
1635            || std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok()
1636        {
1637            return;
1638        }
1639        let cfg = Config {
1640            no_degrade: true,
1641            default_tool_categories: vec!["core".to_string(), "arch".to_string()],
1642            ..Default::default()
1643        };
1644        assert!(cfg.no_degrade_effective());
1645        assert_eq!(
1646            cfg.default_tool_categories_effective(),
1647            vec!["core", "arch"]
1648        );
1649    }
1650}
1651
1652#[cfg(test)]
1653mod rules_scope_tests {
1654    use super::*;
1655
1656    #[test]
1657    fn default_is_both() {
1658        let cfg = Config::default();
1659        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1660    }
1661
1662    #[test]
1663    fn config_global() {
1664        let cfg = Config {
1665            rules_scope: Some("global".to_string()),
1666            ..Default::default()
1667        };
1668        assert_eq!(cfg.rules_scope_effective(), RulesScope::Global);
1669    }
1670
1671    #[test]
1672    fn config_project() {
1673        let cfg = Config {
1674            rules_scope: Some("project".to_string()),
1675            ..Default::default()
1676        };
1677        assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1678    }
1679
1680    #[test]
1681    fn unknown_value_falls_back_to_both() {
1682        let cfg = Config {
1683            rules_scope: Some("nonsense".to_string()),
1684            ..Default::default()
1685        };
1686        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1687    }
1688
1689    #[test]
1690    fn deserialization_none_by_default() {
1691        let cfg: Config = toml::from_str("").unwrap();
1692        assert!(cfg.rules_scope.is_none());
1693        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1694    }
1695
1696    #[test]
1697    fn deserialization_from_toml() {
1698        let cfg: Config = toml::from_str(r#"rules_scope = "project""#).unwrap();
1699        assert_eq!(cfg.rules_scope.as_deref(), Some("project"));
1700        assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1701    }
1702}
1703
1704#[cfg(test)]
1705mod loop_detection_config_tests {
1706    use super::*;
1707
1708    #[test]
1709    fn defaults_are_reasonable() {
1710        let cfg = LoopDetectionConfig::default();
1711        assert_eq!(cfg.normal_threshold, 2);
1712        assert_eq!(cfg.reduced_threshold, 4);
1713        // 0 = blocking disabled by default (LeanCTX philosophy: always help, never block)
1714        assert_eq!(cfg.blocked_threshold, 0);
1715        assert_eq!(cfg.window_secs, 300);
1716        assert_eq!(cfg.search_group_limit, 10);
1717    }
1718
1719    #[test]
1720    fn deserialization_defaults_when_missing() {
1721        let cfg: Config = toml::from_str("").unwrap();
1722        // 0 = blocking disabled by default
1723        assert_eq!(cfg.loop_detection.blocked_threshold, 0);
1724        assert_eq!(cfg.loop_detection.search_group_limit, 10);
1725    }
1726
1727    #[test]
1728    fn deserialization_from_toml() {
1729        let cfg: Config = toml::from_str(
1730            r"
1731            [loop_detection]
1732            normal_threshold = 1
1733            reduced_threshold = 3
1734            blocked_threshold = 5
1735            window_secs = 120
1736            search_group_limit = 8
1737            ",
1738        )
1739        .unwrap();
1740        assert_eq!(cfg.loop_detection.normal_threshold, 1);
1741        assert_eq!(cfg.loop_detection.reduced_threshold, 3);
1742        assert_eq!(cfg.loop_detection.blocked_threshold, 5);
1743        assert_eq!(cfg.loop_detection.window_secs, 120);
1744        assert_eq!(cfg.loop_detection.search_group_limit, 8);
1745    }
1746
1747    #[test]
1748    fn partial_override_keeps_defaults() {
1749        let cfg: Config = toml::from_str(
1750            r"
1751            [loop_detection]
1752            blocked_threshold = 10
1753            ",
1754        )
1755        .unwrap();
1756        assert_eq!(cfg.loop_detection.blocked_threshold, 10);
1757        assert_eq!(cfg.loop_detection.normal_threshold, 2);
1758        assert_eq!(cfg.loop_detection.search_group_limit, 10);
1759    }
1760}
1761
1762impl Config {
1763    /// Returns the path to the global config file (`~/.lean-ctx/config.toml`).
1764    pub fn path() -> Option<PathBuf> {
1765        crate::core::data_dir::lean_ctx_data_dir()
1766            .ok()
1767            .map(|d| d.join("config.toml"))
1768    }
1769
1770    /// Returns the path to the project-local config override file.
1771    pub fn local_path(project_root: &str) -> PathBuf {
1772        PathBuf::from(project_root).join(".lean-ctx.toml")
1773    }
1774
1775    fn find_project_root() -> Option<String> {
1776        static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
1777        ROOT_CACHE
1778            .get_or_init(Self::find_project_root_inner)
1779            .clone()
1780    }
1781
1782    fn find_project_root_inner() -> Option<String> {
1783        if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
1784            if !env_root.is_empty() {
1785                return Some(env_root);
1786            }
1787        }
1788
1789        let cwd = std::env::current_dir().ok();
1790
1791        if let Some(root) =
1792            crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
1793        {
1794            let root_path = std::path::Path::new(&root);
1795            let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
1796            let has_marker = root_path.join(".git").exists()
1797                || root_path.join("Cargo.toml").exists()
1798                || root_path.join("package.json").exists()
1799                || root_path.join("go.mod").exists()
1800                || root_path.join("pyproject.toml").exists()
1801                || root_path.join(".lean-ctx.toml").exists();
1802
1803            if cwd_is_under_root || has_marker {
1804                return Some(root);
1805            }
1806        }
1807
1808        if let Some(ref cwd) = cwd {
1809            let git_root = std::process::Command::new("git")
1810                .args(["rev-parse", "--show-toplevel"])
1811                .current_dir(cwd)
1812                .stdout(std::process::Stdio::piped())
1813                .stderr(std::process::Stdio::null())
1814                .output()
1815                .ok()
1816                .and_then(|o| {
1817                    if o.status.success() {
1818                        String::from_utf8(o.stdout)
1819                            .ok()
1820                            .map(|s| s.trim().to_string())
1821                    } else {
1822                        None
1823                    }
1824                });
1825            if let Some(root) = git_root {
1826                return Some(root);
1827            }
1828            if !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
1829                return Some(cwd.to_string_lossy().to_string());
1830            }
1831        }
1832        None
1833    }
1834
1835    /// Loads config from disk with caching, merging global + project-local overrides.
1836    pub fn load() -> Self {
1837        static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
1838
1839        let Some(path) = Self::path() else {
1840            return Self::default();
1841        };
1842
1843        let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
1844
1845        let mtime = std::fs::metadata(&path)
1846            .and_then(|m| m.modified())
1847            .unwrap_or(SystemTime::UNIX_EPOCH);
1848
1849        let local_mtime = local_path
1850            .as_ref()
1851            .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
1852
1853        if let Ok(guard) = CACHE.lock() {
1854            if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
1855                if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
1856                    return cfg.clone();
1857                }
1858            }
1859        }
1860
1861        let mut cfg: Config = match std::fs::read_to_string(&path) {
1862            Ok(content) => match toml::from_str(&content) {
1863                Ok(c) => c,
1864                Err(e) => {
1865                    tracing::warn!("config parse error in {}: {e}", path.display());
1866                    eprintln!(
1867                        "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n  \
1868                         Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
1869                        path.display()
1870                    );
1871                    Self::default()
1872                }
1873            },
1874            Err(_) => Self::default(),
1875        };
1876
1877        if let Some(ref lp) = local_path {
1878            if let Ok(local_content) = std::fs::read_to_string(lp) {
1879                cfg.merge_local(&local_content);
1880            }
1881        }
1882
1883        if let Ok(mut guard) = CACHE.lock() {
1884            *guard = Some((cfg.clone(), mtime, local_mtime));
1885        }
1886
1887        cfg
1888    }
1889
1890    fn merge_local(&mut self, local_toml: &str) {
1891        let local: Config = match toml::from_str(local_toml) {
1892            Ok(c) => c,
1893            Err(e) => {
1894                tracing::warn!("local config parse error: {e}");
1895                eprintln!(
1896                    "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n  \
1897                     Local overrides skipped.\x1b[0m"
1898                );
1899                return;
1900            }
1901        };
1902        if local.ultra_compact {
1903            self.ultra_compact = true;
1904        }
1905        if local.tee_mode != TeeMode::default() {
1906            self.tee_mode = local.tee_mode;
1907        }
1908        if local.output_density != OutputDensity::default() {
1909            self.output_density = local.output_density;
1910        }
1911        if local.checkpoint_interval != 15 {
1912            self.checkpoint_interval = local.checkpoint_interval;
1913        }
1914        if !local.excluded_commands.is_empty() {
1915            self.excluded_commands.extend(local.excluded_commands);
1916        }
1917        if !local.passthrough_urls.is_empty() {
1918            self.passthrough_urls.extend(local.passthrough_urls);
1919        }
1920        if !local.custom_aliases.is_empty() {
1921            self.custom_aliases.extend(local.custom_aliases);
1922        }
1923        if local.slow_command_threshold_ms != 5000 {
1924            self.slow_command_threshold_ms = local.slow_command_threshold_ms;
1925        }
1926        if local.theme != "default" {
1927            self.theme = local.theme;
1928        }
1929        if !local.buddy_enabled {
1930            self.buddy_enabled = false;
1931        }
1932        if !local.enable_wakeup_ctx {
1933            self.enable_wakeup_ctx = false;
1934        }
1935        if !local.redirect_exclude.is_empty() {
1936            self.redirect_exclude.extend(local.redirect_exclude);
1937        }
1938        if !local.disabled_tools.is_empty() {
1939            self.disabled_tools.extend(local.disabled_tools);
1940        }
1941        if !local.extra_ignore_patterns.is_empty() {
1942            self.extra_ignore_patterns
1943                .extend(local.extra_ignore_patterns);
1944        }
1945        if local.rules_scope.is_some() {
1946            self.rules_scope = local.rules_scope;
1947        }
1948        if local.proxy.anthropic_upstream.is_some() {
1949            self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
1950        }
1951        if local.proxy.openai_upstream.is_some() {
1952            self.proxy.openai_upstream = local.proxy.openai_upstream;
1953        }
1954        if local.proxy.gemini_upstream.is_some() {
1955            self.proxy.gemini_upstream = local.proxy.gemini_upstream;
1956        }
1957        if !local.autonomy.enabled {
1958            self.autonomy.enabled = false;
1959        }
1960        if !local.autonomy.auto_preload {
1961            self.autonomy.auto_preload = false;
1962        }
1963        if !local.autonomy.auto_dedup {
1964            self.autonomy.auto_dedup = false;
1965        }
1966        if !local.autonomy.auto_related {
1967            self.autonomy.auto_related = false;
1968        }
1969        if !local.autonomy.auto_consolidate {
1970            self.autonomy.auto_consolidate = false;
1971        }
1972        if local.autonomy.silent_preload {
1973            self.autonomy.silent_preload = true;
1974        }
1975        if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1976            self.autonomy.silent_preload = false;
1977        }
1978        if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1979            self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1980        }
1981        if local.autonomy.consolidate_every_calls
1982            != AutonomyConfig::default().consolidate_every_calls
1983        {
1984            self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1985        }
1986        if local.autonomy.consolidate_cooldown_secs
1987            != AutonomyConfig::default().consolidate_cooldown_secs
1988        {
1989            self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1990        }
1991        if !local.autonomy.cognition_loop_enabled {
1992            self.autonomy.cognition_loop_enabled = false;
1993        }
1994        if local.autonomy.cognition_loop_interval_secs
1995            != AutonomyConfig::default().cognition_loop_interval_secs
1996        {
1997            self.autonomy.cognition_loop_interval_secs =
1998                local.autonomy.cognition_loop_interval_secs;
1999        }
2000        if local.autonomy.cognition_loop_max_steps
2001            != AutonomyConfig::default().cognition_loop_max_steps
2002        {
2003            self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
2004        }
2005        if local_toml.contains("compression_level") {
2006            self.compression_level = local.compression_level;
2007        }
2008        if local_toml.contains("terse_agent") {
2009            self.terse_agent = local.terse_agent;
2010        }
2011        if !local.archive.enabled {
2012            self.archive.enabled = false;
2013        }
2014        if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
2015            self.archive.threshold_chars = local.archive.threshold_chars;
2016        }
2017        if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
2018            self.archive.max_age_hours = local.archive.max_age_hours;
2019        }
2020        if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
2021            self.archive.max_disk_mb = local.archive.max_disk_mb;
2022        }
2023        let mem_def = MemoryPolicy::default();
2024        if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
2025            self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
2026        }
2027        if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
2028            self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
2029        }
2030        if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
2031            self.memory.knowledge.max_history = local.memory.knowledge.max_history;
2032        }
2033        if local.memory.knowledge.contradiction_threshold
2034            != mem_def.knowledge.contradiction_threshold
2035        {
2036            self.memory.knowledge.contradiction_threshold =
2037                local.memory.knowledge.contradiction_threshold;
2038        }
2039
2040        if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
2041            self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
2042        }
2043        if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
2044        {
2045            self.memory.episodic.max_actions_per_episode =
2046                local.memory.episodic.max_actions_per_episode;
2047        }
2048        if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
2049            self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
2050        }
2051
2052        if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
2053            self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
2054        }
2055        if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
2056            self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
2057        }
2058        if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
2059            self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
2060        }
2061        if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
2062            self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
2063        }
2064
2065        if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
2066            self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
2067        }
2068        if local.memory.lifecycle.low_confidence_threshold
2069            != mem_def.lifecycle.low_confidence_threshold
2070        {
2071            self.memory.lifecycle.low_confidence_threshold =
2072                local.memory.lifecycle.low_confidence_threshold;
2073        }
2074        if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
2075            self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
2076        }
2077        if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
2078            self.memory.lifecycle.similarity_threshold =
2079                local.memory.lifecycle.similarity_threshold;
2080        }
2081
2082        if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
2083            self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
2084        }
2085        if !local.allow_paths.is_empty() {
2086            self.allow_paths.extend(local.allow_paths);
2087        }
2088        if !local.extra_roots.is_empty() {
2089            self.extra_roots.extend(local.extra_roots);
2090        }
2091        if local.minimal_overhead {
2092            self.minimal_overhead = true;
2093        }
2094        if local.shell_hook_disabled {
2095            self.shell_hook_disabled = true;
2096        }
2097        if local.shell_activation != ShellActivation::default() {
2098            self.shell_activation = local.shell_activation.clone();
2099        }
2100        if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
2101            self.bm25_max_cache_mb = local.bm25_max_cache_mb;
2102        }
2103        if local.memory_profile != MemoryProfile::default() {
2104            self.memory_profile = local.memory_profile;
2105        }
2106        if local.memory_cleanup != MemoryCleanup::default() {
2107            self.memory_cleanup = local.memory_cleanup;
2108        }
2109        if !local.shell_allowlist.is_empty() {
2110            self.shell_allowlist = local.shell_allowlist;
2111        }
2112        if !local.default_tool_categories.is_empty() {
2113            self.default_tool_categories = local.default_tool_categories;
2114        }
2115        if local.no_degrade {
2116            self.no_degrade = true;
2117        }
2118        if local.profile.is_some() {
2119            self.profile = local.profile;
2120        }
2121        if local.proxy_timeout_ms.is_some() {
2122            self.proxy_timeout_ms = local.proxy_timeout_ms;
2123        }
2124    }
2125
2126    /// Persists the current config to the global config file.
2127    pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
2128        let path = Self::path().ok_or_else(|| {
2129            super::error::LeanCtxError::Config("cannot determine home directory".into())
2130        })?;
2131        if let Some(parent) = path.parent() {
2132            std::fs::create_dir_all(parent)?;
2133        }
2134        let content = toml::to_string_pretty(self)
2135            .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
2136        std::fs::write(&path, content)?;
2137        Ok(())
2138    }
2139
2140    /// Formats the current config as a human-readable string with file paths.
2141    pub fn show(&self) -> String {
2142        let global_path = Self::path().map_or_else(
2143            || "~/.lean-ctx/config.toml".to_string(),
2144            |p| p.to_string_lossy().to_string(),
2145        );
2146        let content = toml::to_string_pretty(self).unwrap_or_default();
2147        let mut out = format!("Global config: {global_path}\n\n{content}");
2148
2149        if let Some(root) = Self::find_project_root() {
2150            let local = Self::local_path(&root);
2151            if local.exists() {
2152                out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
2153            } else {
2154                out.push_str(&format!(
2155                    "\n\nLocal config: not found (create {} to override per-project)\n",
2156                    local.display()
2157                ));
2158            }
2159        }
2160        out
2161    }
2162}
2163
2164#[cfg(test)]
2165mod extra_roots_tests {
2166    use super::*;
2167
2168    #[test]
2169    fn default_is_empty() {
2170        let cfg = Config::default();
2171        assert!(cfg.extra_roots.is_empty());
2172    }
2173
2174    #[test]
2175    fn deserialization_from_toml() {
2176        let cfg: Config = toml::from_str(r#"extra_roots = ["/data/store", "/test/env"]"#).unwrap();
2177        assert_eq!(cfg.extra_roots, vec!["/data/store", "/test/env"]);
2178    }
2179
2180    #[test]
2181    fn merge_extends() {
2182        let mut base = Config {
2183            extra_roots: vec!["/base".to_string()],
2184            ..Config::default()
2185        };
2186        base.merge_local(r#"extra_roots = ["/local"]"#);
2187        assert_eq!(base.extra_roots, vec!["/base", "/local"]);
2188    }
2189}
2190
2191#[cfg(test)]
2192mod compression_level_tests {
2193    use super::*;
2194
2195    #[test]
2196    fn default_is_standard() {
2197        assert_eq!(CompressionLevel::default(), CompressionLevel::Standard);
2198    }
2199
2200    #[test]
2201    fn to_components_off() {
2202        let (ta, od, crp, tm) = CompressionLevel::Off.to_components();
2203        assert_eq!(ta, TerseAgent::Off);
2204        assert_eq!(od, OutputDensity::Normal);
2205        assert_eq!(crp, "off");
2206        assert!(!tm);
2207    }
2208
2209    #[test]
2210    fn to_components_lite() {
2211        let (ta, od, crp, tm) = CompressionLevel::Lite.to_components();
2212        assert_eq!(ta, TerseAgent::Lite);
2213        assert_eq!(od, OutputDensity::Terse);
2214        assert_eq!(crp, "off");
2215        assert!(tm);
2216    }
2217
2218    #[test]
2219    fn to_components_standard() {
2220        let (ta, od, crp, tm) = CompressionLevel::Standard.to_components();
2221        assert_eq!(ta, TerseAgent::Full);
2222        assert_eq!(od, OutputDensity::Terse);
2223        assert_eq!(crp, "compact");
2224        assert!(tm);
2225    }
2226
2227    #[test]
2228    fn to_components_max() {
2229        let (ta, od, crp, tm) = CompressionLevel::Max.to_components();
2230        assert_eq!(ta, TerseAgent::Ultra);
2231        assert_eq!(od, OutputDensity::Ultra);
2232        assert_eq!(crp, "tdd");
2233        assert!(tm);
2234    }
2235
2236    #[test]
2237    fn from_legacy_ultra_agent_maps_to_max() {
2238        assert_eq!(
2239            CompressionLevel::from_legacy(&TerseAgent::Ultra, &OutputDensity::Normal),
2240            CompressionLevel::Max
2241        );
2242    }
2243
2244    #[test]
2245    fn from_legacy_ultra_density_maps_to_max() {
2246        assert_eq!(
2247            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Ultra),
2248            CompressionLevel::Max
2249        );
2250    }
2251
2252    #[test]
2253    fn from_legacy_full_agent_maps_to_standard() {
2254        assert_eq!(
2255            CompressionLevel::from_legacy(&TerseAgent::Full, &OutputDensity::Normal),
2256            CompressionLevel::Standard
2257        );
2258    }
2259
2260    #[test]
2261    fn from_legacy_lite_agent_maps_to_lite() {
2262        assert_eq!(
2263            CompressionLevel::from_legacy(&TerseAgent::Lite, &OutputDensity::Normal),
2264            CompressionLevel::Lite
2265        );
2266    }
2267
2268    #[test]
2269    fn from_legacy_terse_density_maps_to_lite() {
2270        assert_eq!(
2271            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Terse),
2272            CompressionLevel::Lite
2273        );
2274    }
2275
2276    #[test]
2277    fn from_legacy_both_off_maps_to_off() {
2278        assert_eq!(
2279            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Normal),
2280            CompressionLevel::Off
2281        );
2282    }
2283
2284    #[test]
2285    fn labels_match() {
2286        assert_eq!(CompressionLevel::Off.label(), "off");
2287        assert_eq!(CompressionLevel::Lite.label(), "lite");
2288        assert_eq!(CompressionLevel::Standard.label(), "standard");
2289        assert_eq!(CompressionLevel::Max.label(), "max");
2290    }
2291
2292    #[test]
2293    fn is_active_false_for_off() {
2294        assert!(!CompressionLevel::Off.is_active());
2295    }
2296
2297    #[test]
2298    fn is_active_true_for_all_others() {
2299        assert!(CompressionLevel::Lite.is_active());
2300        assert!(CompressionLevel::Standard.is_active());
2301        assert!(CompressionLevel::Max.is_active());
2302    }
2303
2304    #[test]
2305    fn deserialization_defaults_to_standard() {
2306        let cfg: Config = toml::from_str("").unwrap();
2307        assert_eq!(cfg.compression_level, CompressionLevel::Standard);
2308    }
2309
2310    #[test]
2311    fn deserialization_from_toml() {
2312        let cfg: Config = toml::from_str(r#"compression_level = "standard""#).unwrap();
2313        assert_eq!(cfg.compression_level, CompressionLevel::Standard);
2314    }
2315
2316    #[test]
2317    fn roundtrip_all_levels() {
2318        for level in [
2319            CompressionLevel::Off,
2320            CompressionLevel::Lite,
2321            CompressionLevel::Standard,
2322            CompressionLevel::Max,
2323        ] {
2324            let (ta, od, crp, tm) = level.to_components();
2325            assert!(!crp.is_empty());
2326            if level == CompressionLevel::Off {
2327                assert!(!tm);
2328                assert_eq!(ta, TerseAgent::Off);
2329                assert_eq!(od, OutputDensity::Normal);
2330            } else {
2331                assert!(tm);
2332            }
2333        }
2334    }
2335}
2336
2337#[cfg(test)]
2338mod memory_cleanup_tests {
2339    use super::*;
2340
2341    #[test]
2342    fn default_is_aggressive() {
2343        assert_eq!(MemoryCleanup::default(), MemoryCleanup::Aggressive);
2344    }
2345
2346    #[test]
2347    fn aggressive_ttl_is_300() {
2348        assert_eq!(MemoryCleanup::Aggressive.idle_ttl_secs(), 300);
2349    }
2350
2351    #[test]
2352    fn shared_ttl_is_1800() {
2353        assert_eq!(MemoryCleanup::Shared.idle_ttl_secs(), 1800);
2354    }
2355
2356    #[test]
2357    fn index_retention_multiplier_values() {
2358        assert!(
2359            (MemoryCleanup::Aggressive.index_retention_multiplier() - 1.0).abs() < f64::EPSILON
2360        );
2361        assert!((MemoryCleanup::Shared.index_retention_multiplier() - 3.0).abs() < f64::EPSILON);
2362    }
2363
2364    #[test]
2365    fn deserialization_defaults_to_aggressive() {
2366        let cfg: Config = toml::from_str("").unwrap();
2367        assert_eq!(cfg.memory_cleanup, MemoryCleanup::Aggressive);
2368    }
2369
2370    #[test]
2371    fn deserialization_from_toml() {
2372        let cfg: Config = toml::from_str(r#"memory_cleanup = "shared""#).unwrap();
2373        assert_eq!(cfg.memory_cleanup, MemoryCleanup::Shared);
2374    }
2375
2376    #[test]
2377    fn effective_uses_config_when_no_env() {
2378        let cfg = Config {
2379            memory_cleanup: MemoryCleanup::Shared,
2380            ..Default::default()
2381        };
2382        let eff = MemoryCleanup::effective(&cfg);
2383        assert_eq!(eff, MemoryCleanup::Shared);
2384    }
2385}
2386
2387#[cfg(test)]
2388mod simplified_config_tests {
2389    use super::*;
2390
2391    #[test]
2392    fn max_disk_mb_zero_means_disabled() {
2393        let cfg = Config::default();
2394        assert_eq!(cfg.max_disk_mb, 0);
2395        assert_eq!(cfg.max_disk_mb_effective(), 0);
2396    }
2397
2398    #[test]
2399    fn archive_derives_from_disk_budget() {
2400        let cfg = Config {
2401            max_disk_mb: 4000,
2402            ..Default::default()
2403        };
2404        assert_eq!(cfg.archive_max_disk_mb_effective(), 1000);
2405    }
2406
2407    #[test]
2408    fn archive_explicit_overrides_derived() {
2409        let cfg = Config {
2410            max_disk_mb: 4000,
2411            archive: ArchiveConfig {
2412                max_disk_mb: 800,
2413                ..Default::default()
2414            },
2415            ..Default::default()
2416        };
2417        assert_eq!(cfg.archive_max_disk_mb_effective(), 800);
2418    }
2419
2420    #[test]
2421    fn bm25_derives_from_disk_budget() {
2422        let cfg = Config {
2423            max_disk_mb: 4000,
2424            ..Default::default()
2425        };
2426        assert_eq!(cfg.bm25_max_cache_mb_effective(), 400);
2427    }
2428
2429    #[test]
2430    fn bm25_explicit_overrides_derived() {
2431        let cfg = Config {
2432            max_disk_mb: 4000,
2433            bm25_max_cache_mb: 256,
2434            ..Default::default()
2435        };
2436        assert_eq!(cfg.bm25_max_cache_mb_effective(), 256);
2437    }
2438
2439    #[test]
2440    fn staleness_days_derives_archive_age() {
2441        let cfg = Config {
2442            max_staleness_days: 30,
2443            ..Default::default()
2444        };
2445        assert_eq!(cfg.archive_max_age_hours_effective(), 720);
2446    }
2447
2448    #[test]
2449    fn staleness_explicit_archive_age_overrides() {
2450        let cfg = Config {
2451            max_staleness_days: 30,
2452            archive: ArchiveConfig {
2453                max_age_hours: 96,
2454                ..Default::default()
2455            },
2456            ..Default::default()
2457        };
2458        assert_eq!(cfg.archive_max_age_hours_effective(), 96);
2459    }
2460
2461    #[test]
2462    fn no_budget_returns_defaults() {
2463        let cfg = Config::default();
2464        assert_eq!(
2465            cfg.archive_max_disk_mb_effective(),
2466            ArchiveConfig::default().max_disk_mb
2467        );
2468        assert_eq!(
2469            cfg.archive_max_age_hours_effective(),
2470            ArchiveConfig::default().max_age_hours
2471        );
2472    }
2473
2474    #[test]
2475    fn memory_limits_scale_with_disk_budget() {
2476        let cfg = Config {
2477            max_disk_mb: 2000,
2478            ..Default::default()
2479        };
2480        let policy = cfg.memory_policy_effective().unwrap();
2481        // factor = 2000/500 = 4.0
2482        assert_eq!(policy.knowledge.max_facts, 800);
2483        assert_eq!(policy.knowledge.max_patterns, 200);
2484        assert_eq!(policy.episodic.max_episodes, 2000);
2485        assert_eq!(policy.procedural.max_procedures, 400);
2486    }
2487
2488    #[test]
2489    fn memory_limits_clamped_at_max_factor() {
2490        let cfg = Config {
2491            max_disk_mb: 50_000,
2492            ..Default::default()
2493        };
2494        let policy = cfg.memory_policy_effective().unwrap();
2495        // factor clamped at 10.0
2496        assert_eq!(policy.knowledge.max_facts, 2000);
2497        assert_eq!(policy.episodic.max_episodes, 5000);
2498    }
2499
2500    #[test]
2501    fn memory_limits_unchanged_when_no_budget() {
2502        let cfg = Config::default();
2503        let policy = cfg.memory_policy_effective().unwrap();
2504        assert_eq!(policy.knowledge.max_facts, 200);
2505        assert_eq!(policy.episodic.max_episodes, 500);
2506    }
2507
2508    #[test]
2509    fn simplified_template_is_valid_toml() {
2510        let parsed: Result<toml::Table, _> = toml::from_str(crate::cli::SIMPLIFIED_TEMPLATE);
2511        assert!(parsed.is_ok(), "Template must be valid TOML");
2512    }
2513}