Skip to main content

lean_ctx/core/config/
mod.rs

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