Skip to main content

lean_ctx/core/config/
mod.rs

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