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::Mutex;
5use std::time::SystemTime;
6
7use super::memory_policy::MemoryPolicy;
8
9mod defaults_allowlist;
10mod enums;
11mod memory;
12mod proxy;
13pub mod schema;
14mod sections;
15mod serde_defaults;
16pub mod setter;
17mod shell_activation;
18pub use sections::*;
19#[cfg(test)]
20mod tests;
21
22pub(crate) use defaults_allowlist::default_shell_allowlist;
23pub use enums::{
24    CompressionLevel, OutputDensity, PermissionInheritance, ResponseVerbosity, RulesInjection,
25    RulesScope, TeeMode, TerseAgent,
26};
27pub use memory::{MemoryCleanup, MemoryGuardConfig, MemoryProfile, SavingsFooter};
28pub use proxy::{is_local_proxy_url, normalize_url, normalize_url_opt, ProxyConfig, ProxyProvider};
29pub use shell_activation::ShellActivation;
30
31/// Default BM25 cache cap from config (also used by `bm25_index` heuristics).
32pub fn default_bm25_max_cache_mb() -> u64 {
33    serde_defaults::default_bm25_max_cache_mb()
34}
35
36/// Effective on-disk ceiling (MB) for the persisted BM25 index when nothing is
37/// explicitly configured (no `bm25_max_cache_mb`, no `max_disk_mb` budget).
38///
39/// Deliberately decoupled from the RAM `MemoryProfile` (64/128/512 MB): this is
40/// a *disk* file, and tying it to the profile silently refused persistence on
41/// large repos under Low/Balanced, forcing a cold rebuild on every call (the
42/// perpetual "index warming" of issue #249). 512 MB compressed covers
43/// essentially every real repo; RAM pressure is governed separately by the
44/// eviction orchestrator (which measures real heap).
45pub const DEFAULT_BM25_PERSIST_MB: u64 = 512;
46
47// Compile-time regression guard (#249): the default disk ceiling must stay well
48// above the old RAM-profile caps (64/128 MB) that starved large repos.
49const _: () = assert!(DEFAULT_BM25_PERSIST_MB >= 512);
50
51/// Global lean-ctx configuration loaded from `config.toml`, merged with project-local overrides.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53#[serde(default)]
54pub struct Config {
55    pub ultra_compact: bool,
56    #[serde(default, deserialize_with = "serde_defaults::deserialize_tee_mode")]
57    pub tee_mode: TeeMode,
58    #[serde(default)]
59    pub output_density: OutputDensity,
60    pub checkpoint_interval: u32,
61    pub excluded_commands: Vec<String>,
62    pub passthrough_urls: Vec<String>,
63    pub custom_aliases: Vec<AliasEntry>,
64    /// Output formats that are already compact/token-oriented and must be
65    /// preserved verbatim instead of being recompressed (#342). Matched against
66    /// the *output shape* (not the command name), so any tool emitting the
67    /// format is covered without enumerating commands in `excluded_commands`.
68    /// Default: `["toon"]`. Set to `[]` to disable and always recompress.
69    #[serde(default = "serde_defaults::default_preserve_compact_formats")]
70    pub preserve_compact_formats: Vec<String>,
71    /// Commands taking longer than this threshold (ms) are recorded in the slow log.
72    /// Set to 0 to disable slow logging.
73    pub slow_command_threshold_ms: u64,
74    #[serde(default = "serde_defaults::default_theme")]
75    pub theme: String,
76    #[serde(default)]
77    pub cloud: CloudConfig,
78    #[serde(default)]
79    pub gain: GainConfig,
80    #[serde(default)]
81    pub autonomy: AutonomyConfig,
82    #[serde(default)]
83    pub providers: ProvidersConfig,
84    #[serde(default)]
85    pub proxy: ProxyConfig,
86    /// Whether the API proxy is enabled. Tri-state:
87    /// - None: undecided (fresh install, will prompt on interactive setup)
88    /// - Some(true): user opted in, proxy managed by lean-ctx
89    /// - Some(false): user opted out, never touch proxy or endpoints
90    #[serde(default)]
91    pub proxy_enabled: Option<bool>,
92    #[serde(default)]
93    pub proxy_port: Option<u16>,
94    /// Proxy reachability timeout in milliseconds. Default: 200.
95    /// Override via LEAN_CTX_PROXY_TIMEOUT_MS env var.
96    #[serde(default)]
97    pub proxy_timeout_ms: Option<u64>,
98    #[serde(default = "serde_defaults::default_buddy_enabled")]
99    pub buddy_enabled: bool,
100    #[serde(default = "serde_defaults::default_true")]
101    pub enable_wakeup_ctx: bool,
102    #[serde(default)]
103    pub redirect_exclude: Vec<String>,
104    /// Tools to exclude from the MCP tool list returned by list_tools.
105    /// Accepts exact tool names (e.g. `["ctx_graph", "ctx_agent"]`).
106    /// Empty by default — all tools listed, no behaviour change.
107    #[serde(default)]
108    pub disabled_tools: Vec<String>,
109    /// Tool categories to activate by default for dynamic-tool-capable clients.
110    /// Values: "core" (always on), "arch", "debug", "memory", "metrics", "session".
111    /// Example: `default_tool_categories = ["core", "arch", "memory"]`
112    /// Override via LCTX_DEFAULT_CATEGORIES env var (comma-separated).
113    /// Empty = lean-ctx default (core + session).
114    #[serde(default)]
115    pub default_tool_categories: Vec<String>,
116    /// Disable all automatic read-mode degradation (auto_degrade + context_gate pressure).
117    /// When true, lean-ctx never downgrades requested read modes regardless of pressure.
118    /// Override via LCTX_NO_DEGRADE=1 env var.
119    #[serde(default)]
120    pub no_degrade: bool,
121    /// Persistent profile name. Checked after LEAN_CTX_PROFILE env var.
122    /// Set via `lean-ctx config set profile passthrough` or editing config.toml.
123    #[serde(default)]
124    pub profile: Option<String>,
125    /// Tool visibility profile: "minimal" (6), "standard" (21), or "power" (all).
126    /// Override via LEAN_CTX_TOOL_PROFILE env var.
127    /// Existing installs default to "power" (backward compat).
128    #[serde(default)]
129    pub tool_profile: Option<String>,
130    /// Explicit list of enabled tool names (overrides tool_profile when non-empty).
131    /// Example: `tools_enabled = ["ctx_read", "ctx_shell", "ctx_search"]`
132    #[serde(default)]
133    pub tools_enabled: Vec<String>,
134    #[serde(default)]
135    pub loop_detection: LoopDetectionConfig,
136    /// Controls where lean-ctx installs agent rule files.
137    /// Values: "both" (default), "global" (home-dir only), "project" (repo-local only).
138    /// Override via LEAN_CTX_RULES_SCOPE env var.
139    #[serde(default)]
140    pub rules_scope: Option<String>,
141    /// Controls how rules are injected for shared-instruction-file agents.
142    /// Values: "shared" (default, marker block in CLAUDE.md/AGENTS.md/GEMINI.md)
143    /// or "dedicated" (never touch those files; use each agent's config-driven
144    /// auto-load: SessionStart hook / instructions[] / context.fileName). See #343.
145    /// Override via LEAN_CTX_RULES_INJECTION env var.
146    #[serde(default)]
147    pub rules_injection: Option<String>,
148    /// Mirror the host IDE's tool-permission rules onto lean-ctx's own MCP tools.
149    /// Values: "off" (default) or "on". When "on", lean-ctx reads the active
150    /// IDE's permission config (v1: OpenCode) and applies the equivalent
151    /// deny/ask/allow decision to the matching lean-ctx tool — so `ctx_shell`
152    /// honors your `bash`/`rm *` rules instead of bypassing them.
153    /// Override via LEAN_CTX_PERMISSION_INHERITANCE env var.
154    #[serde(default)]
155    pub permission_inheritance: Option<String>,
156    /// Extra glob patterns to ignore in graph/overview/preload (repo-local).
157    /// Example: `["externals/**", "target/**", "temp/**"]`
158    #[serde(default)]
159    pub extra_ignore_patterns: Vec<String>,
160    /// Controls agent output verbosity via instructions injection.
161    /// Values: "off" (default), "lite", "full", "ultra".
162    /// Override via LEAN_CTX_TERSE_AGENT env var.
163    #[serde(default)]
164    pub terse_agent: TerseAgent,
165    /// Unified compression level (replaces separate terse_agent + output_density).
166    /// Values: "off" (default), "lite", "standard", "max".
167    /// Override via LEAN_CTX_COMPRESSION env var.
168    #[serde(default)]
169    pub compression_level: CompressionLevel,
170    /// Archive configuration for zero-loss compression.
171    #[serde(default)]
172    pub archive: ArchiveConfig,
173    /// Memory policy (knowledge/episodic/procedural/lifecycle budgets & thresholds).
174    #[serde(default)]
175    pub memory: MemoryPolicy,
176    /// Additional paths allowed by PathJail (absolute).
177    /// Useful for multi-project workspaces where the jail root is a parent directory.
178    /// Override via LEAN_CTX_ALLOW_PATH env var (path-list separator).
179    #[serde(default)]
180    pub allow_paths: Vec<String>,
181    /// Extra project roots for multi-root workspaces.
182    /// Tools like ctx_tree and ctx_search can scan across all roots in a single call.
183    /// These paths are automatically added to PathJail's allow-list.
184    /// Override via LEAN_CTX_EXTRA_ROOTS env var (path-list separator).
185    #[serde(default)]
186    pub extra_roots: Vec<String>,
187    /// Enable content-defined chunking (Rabin-Karp) for cache-optimal output ordering.
188    /// Stable chunks are emitted first to maximize prompt cache hits.
189    #[serde(default)]
190    pub content_defined_chunking: bool,
191    /// Skip session/knowledge/gotcha blocks in MCP instructions to minimize token overhead.
192    /// Override via LEAN_CTX_MINIMAL env var.
193    #[serde(default)]
194    pub minimal_overhead: bool,
195    /// Opt-in: substitute long identifiers with short α-codes (+ a `§MAP` table)
196    /// in `aggressive` reads for projects with >50 source files. Off by default —
197    /// the abbreviated form is confusing for editing/refactoring, where the agent
198    /// needs the real package and symbol names. Enable for max exploration savings.
199    #[serde(default)]
200    pub symbol_map_auto: bool,
201    /// Team server URL for opt-in savings roll-up.
202    /// Set via `lean-ctx config set team_url https://...` or `[team] url` in config.toml.
203    /// Override via LEAN_CTX_TEAM_URL env var.
204    #[serde(default)]
205    pub team_url: Option<String>,
206    /// Enable human-readable activity journal (~/.lean-ctx/journal.md).
207    #[serde(default)]
208    pub journal_enabled: bool,
209    /// Opt-in: auto-persist interesting findings as knowledge facts.
210    #[serde(default)]
211    pub auto_capture: bool,
212    /// Hybrid search weights (BM25/dense/candidates).
213    #[serde(default)]
214    pub search: crate::core::hybrid_search::HybridConfig,
215    /// Optional LLM enhancement (query expansion, contradiction explanation).
216    #[serde(default)]
217    pub llm: crate::core::llm_enhance::LlmConfig,
218    /// Semantic-embedding engine settings (which local ONNX model to use).
219    #[serde(default)]
220    pub embedding: EmbeddingConfig,
221    /// Disable shell hook injection (the _lc() function that wraps CLI commands).
222    /// Override via LEAN_CTX_NO_HOOK env var.
223    #[serde(default)]
224    pub shell_hook_disabled: bool,
225    /// Shadow mode: transparently intercepts native tool calls (Read/Grep/Shell)
226    /// via hooks, strengthens MCP instructions to MUST-level, and activates
227    /// immediate bypass hints on first native tool use. Enables "transparent
228    /// replacement" so agents use ctx_* without explicit opt-in.
229    #[serde(default)]
230    pub shadow_mode: bool,
231    /// Controls when the shell hook auto-activates aliases.
232    /// - `always`: (Default) Aliases active in every interactive shell.
233    /// - `agents-only`: Aliases only active when an AI agent env var is detected.
234    /// - `off`: Aliases never auto-activate (user must call `lean-ctx-on` manually).
235    ///
236    /// Override via `LEAN_CTX_SHELL_ACTIVATION` env var.
237    #[serde(default)]
238    pub shell_activation: ShellActivation,
239    /// Disable the daily version check against leanctx.com/version.txt.
240    /// Override via LEAN_CTX_NO_UPDATE_CHECK env var.
241    #[serde(default)]
242    pub update_check_disabled: bool,
243    #[serde(default)]
244    pub updates: UpdatesConfig,
245    /// Maximum BM25 cache file size in MB. Indexes exceeding this are quarantined on load
246    /// and refused on save. Override via LEAN_CTX_BM25_MAX_CACHE_MB env var.
247    #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
248    pub bm25_max_cache_mb: u64,
249    /// Maximum number of files scanned by the lightweight JSON graph index.
250    /// 0 = unlimited (default). Set >0 to cap for constrained systems.
251    #[serde(default = "serde_defaults::default_graph_index_max_files")]
252    pub graph_index_max_files: u64,
253    /// Controls RAM vs feature trade-off. Values: "low", "balanced" (default), "performance".
254    /// Override via LEAN_CTX_MEMORY_PROFILE env var.
255    #[serde(default)]
256    pub memory_profile: MemoryProfile,
257    /// Controls how aggressively memory is freed when idle.
258    /// Values: "aggressive" (default, 5 min TTL), "shared" (30 min TTL for multi-IDE use).
259    /// Override via LEAN_CTX_MEMORY_CLEANUP env var.
260    #[serde(default)]
261    pub memory_cleanup: MemoryCleanup,
262    /// Maximum percentage of system RAM that lean-ctx may use (default: 5).
263    /// Override via LEAN_CTX_MAX_RAM_PERCENT env var.
264    #[serde(default = "serde_defaults::default_max_ram_percent")]
265    pub max_ram_percent: u8,
266    /// Simplified disk budget (MB). When set and detail values are at defaults,
267    /// distributes proportionally: archive=25%, bm25=10%, remainder for stores.
268    /// 0 = disabled (use individual settings). Override via LEAN_CTX_MAX_DISK_MB.
269    #[serde(default)]
270    pub max_disk_mb: u64,
271    /// Auto-purge data older than this many days. 0 = disabled.
272    /// Flows into archive.max_age_hours and lifecycle idle TTL.
273    #[serde(default)]
274    pub max_staleness_days: u32,
275    /// Controls visibility of token savings footers in tool output.
276    /// Values: "always" (default, show on every response), "never", "auto" (legacy compatibility).
277    /// Override via LEAN_CTX_SAVINGS_FOOTER or LEAN_CTX_SHOW_SAVINGS=1|0 env var.
278    #[serde(default)]
279    pub savings_footer: SavingsFooter,
280    /// Explicit project root override. When set, lean-ctx uses this instead of auto-detection.
281    /// This prevents accidental home-directory scans when running from $HOME.
282    /// Override via LEAN_CTX_PROJECT_ROOT env var.
283    #[serde(default)]
284    pub project_root: Option<String>,
285    /// LSP server overrides. Map language name to custom binary path.
286    /// Example: `[lsp]\nrust = "/opt/rust-analyzer"\npython = "~/.venvs/main/bin/pylsp"`
287    #[serde(default)]
288    pub lsp: std::collections::HashMap<String, String>,
289    /// Per-IDE allowed paths. Restricts which directories lean-ctx will scan/index for each IDE.
290    /// Example: `[ide_paths]\ncursor = ["/home/user/projects/app1"]\ncodex = ["/home/user/codex"]`
291    /// When set, only these paths are indexed for the matching agent. Global `allow_paths` still applies.
292    #[serde(default)]
293    pub ide_paths: HashMap<String, Vec<String>>,
294    /// Custom model context window overrides.
295    /// Example: `[model_context_windows]\n"my-custom-model" = 500000`
296    #[serde(default)]
297    pub model_context_windows: HashMap<String, usize>,
298    /// Controls how much detail tool responses include.
299    ///
300    /// - `full` (default): complete compressed output
301    /// - `headers_only`: metadata line only (path, mode, token count)
302    ///
303    /// Override via `LEAN_CTX_RESPONSE_VERBOSITY` env var.
304    #[serde(default)]
305    pub response_verbosity: ResponseVerbosity,
306    /// Bypass hint mode. When agents use native Read/Grep instead of lean-ctx tools,
307    /// a hint is appended to the next tool response.
308    /// Values: "on" (default), "off", "aggressive" (hint on every call, no cooldown).
309    /// Override via LEAN_CTX_BYPASS_HINTS env var.
310    #[serde(default)]
311    pub bypass_hints: Option<String>,
312    /// Cache policy for ctx_read. Controls behavior on cache hits.
313    /// Values: "aggressive" (default, 13-tok stubs + compaction-aware reset),
314    /// "safe" (delivers map instead of stub), "off" (no caching, always disk read).
315    /// Override via LEAN_CTX_CACHE_POLICY env var.
316    #[serde(default)]
317    pub cache_policy: Option<String>,
318    /// Cross-project boundary policy.
319    /// Controls whether cross-project search/import is allowed and whether access is audited.
320    #[serde(default)]
321    pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
322    #[serde(default)]
323    pub secret_detection: SecretDetectionConfig,
324    /// Allow automatic project-root re-rooting when absolute paths outside the jail are seen.
325    /// When false (default), absolute paths outside the jail are rejected without re-rooting.
326    /// Override via LEAN_CTX_ALLOW_REROOT env var.
327    #[serde(default)]
328    pub allow_auto_reroot: bool,
329    /// Disable PathJail entirely. Set to false to allow all paths.
330    /// Useful in container/Docker environments. Override via LEAN_CTX_NO_JAIL=1.
331    #[serde(default)]
332    pub path_jail: Option<bool>,
333    /// Sandbox level for code execution (ctx_exec).
334    /// 0 = subprocess only (current), 1 = OS-level restriction (Seatbelt/Landlock).
335    /// Override via LEAN_CTX_SANDBOX_LEVEL env var.
336    #[serde(default)]
337    pub sandbox_level: u8,
338    /// When true, large tool outputs (>4000 chars) are stored as references
339    /// and a short URI is returned instead of the full content.
340    /// Override via LEAN_CTX_REFERENCE_RESULTS env var.
341    #[serde(default)]
342    pub reference_results: bool,
343    /// Default per-agent token budget. 0 means unlimited.
344    /// Override per-agent via ctx_session or programmatically.
345    #[serde(default)]
346    pub agent_token_budget: usize,
347    /// Optional shell command allowlist. When non-empty, only commands whose base binary
348    /// is in this list are permitted by ctx_shell. Empty = disable allowlist (allow all).
349    /// Default includes common dev tools. Set to `[]` to disable.
350    /// Override via LEAN_CTX_SHELL_ALLOWLIST env var (comma-separated).
351    #[serde(default = "default_shell_allowlist")]
352    pub shell_allowlist: Vec<String>,
353
354    /// Extra commands MERGED on top of the effective `shell_allowlist` without replacing
355    /// the defaults. Setting `shell_allowlist` replaces the whole built-in list (a common
356    /// footgun); entries here are purely additive, which is what `lean-ctx allow <cmd>`
357    /// writes. Only applied in restricted mode (when the base allowlist is non-empty).
358    #[serde(default)]
359    pub shell_allowlist_extra: Vec<String>,
360
361    /// When true, block command substitution ($(), backticks) and process substitution
362    /// (<(), >()) in shell arguments. When false (default), only warn via tracing.
363    /// Default false preserves backward compatibility — set true for maximum security.
364    #[serde(default)]
365    pub shell_strict_mode: bool,
366    /// Setup behavior: controls what gets injected during setup and updates.
367    #[serde(default)]
368    pub setup: SetupConfig,
369}
370
371impl Default for Config {
372    fn default() -> Self {
373        Self {
374            ultra_compact: false,
375            tee_mode: TeeMode::default(),
376            output_density: OutputDensity::default(),
377            checkpoint_interval: 15,
378            excluded_commands: Vec::new(),
379            passthrough_urls: Vec::new(),
380            custom_aliases: Vec::new(),
381            preserve_compact_formats: serde_defaults::default_preserve_compact_formats(),
382            slow_command_threshold_ms: 5000,
383            theme: serde_defaults::default_theme(),
384            cloud: CloudConfig::default(),
385            gain: GainConfig::default(),
386            autonomy: AutonomyConfig::default(),
387            providers: ProvidersConfig::default(),
388            proxy: ProxyConfig::default(),
389            proxy_enabled: None,
390            proxy_port: None,
391            proxy_timeout_ms: None,
392            buddy_enabled: serde_defaults::default_buddy_enabled(),
393            enable_wakeup_ctx: true,
394            redirect_exclude: Vec::new(),
395            disabled_tools: Vec::new(),
396            default_tool_categories: Vec::new(),
397            no_degrade: false,
398            profile: None,
399            tool_profile: None,
400            tools_enabled: Vec::new(),
401            loop_detection: LoopDetectionConfig::default(),
402            rules_scope: None,
403            rules_injection: None,
404            permission_inheritance: None,
405            extra_ignore_patterns: Vec::new(),
406            terse_agent: TerseAgent::default(),
407            compression_level: CompressionLevel::default(),
408            archive: ArchiveConfig::default(),
409            memory: MemoryPolicy::default(),
410            allow_paths: Vec::new(),
411            extra_roots: Vec::new(),
412            content_defined_chunking: false,
413            minimal_overhead: true,
414            symbol_map_auto: false,
415            team_url: None,
416            journal_enabled: true,
417            auto_capture: true,
418            search: crate::core::hybrid_search::HybridConfig::default(),
419            llm: crate::core::llm_enhance::LlmConfig::default(),
420            embedding: EmbeddingConfig::default(),
421            shell_hook_disabled: false,
422            shadow_mode: false,
423            shell_activation: ShellActivation::default(),
424            update_check_disabled: false,
425            updates: UpdatesConfig::default(),
426            graph_index_max_files: serde_defaults::default_graph_index_max_files(),
427            bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
428            memory_profile: MemoryProfile::default(),
429            memory_cleanup: MemoryCleanup::default(),
430            max_ram_percent: serde_defaults::default_max_ram_percent(),
431            max_disk_mb: 0,
432            max_staleness_days: 0,
433            savings_footer: SavingsFooter::default(),
434            project_root: None,
435            lsp: std::collections::HashMap::new(),
436            ide_paths: HashMap::new(),
437            model_context_windows: HashMap::new(),
438            response_verbosity: ResponseVerbosity::default(),
439            bypass_hints: None,
440            cache_policy: None,
441            boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
442            secret_detection: SecretDetectionConfig::default(),
443            allow_auto_reroot: false,
444            path_jail: None,
445            sandbox_level: 0,
446            reference_results: false,
447            agent_token_budget: 0,
448            shell_allowlist: default_shell_allowlist(),
449            shell_allowlist_extra: Vec::new(),
450            shell_strict_mode: false,
451            setup: SetupConfig::default(),
452        }
453    }
454}
455
456/// Holds the most recent global `config.toml` parse error, if the file currently
457/// fails to parse. When that happens `Config::load()` silently falls back to the
458/// built-in defaults and only logs to stderr — which is invisible over an MCP/stdio
459/// transport. Recording it here lets callers (e.g. the shell-allowlist diagnostic
460/// and `lean-ctx doctor`) surface "you're on defaults because your config is broken".
461static LAST_PARSE_ERROR: Mutex<Option<String>> = Mutex::new(None);
462
463/// Returns the most recent global config parse error, or `None` if the current
464/// `config.toml` parsed successfully (or no config file exists).
465#[must_use]
466pub fn last_config_parse_error() -> Option<String> {
467    LAST_PARSE_ERROR.lock().ok().and_then(|g| g.clone())
468}
469
470fn record_parse_error(err: Option<String>) {
471    if let Ok(mut guard) = LAST_PARSE_ERROR.lock() {
472        *guard = err;
473    }
474}
475
476impl Config {
477    /// Returns the effective rules scope, preferring env var over config file.
478    pub fn rules_scope_effective(&self) -> RulesScope {
479        let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
480            .ok()
481            .or_else(|| self.rules_scope.clone())
482            .unwrap_or_default();
483        match raw.trim().to_lowercase().as_str() {
484            "global" => RulesScope::Global,
485            "project" => RulesScope::Project,
486            _ => RulesScope::Both,
487        }
488    }
489
490    /// Returns the effective rules injection mode, preferring env var over config.
491    /// Default is `Shared` (zero-config discovery via a CLAUDE.md/AGENTS.md block).
492    pub fn rules_injection_effective(&self) -> RulesInjection {
493        let raw = std::env::var("LEAN_CTX_RULES_INJECTION")
494            .ok()
495            .or_else(|| self.rules_injection.clone())
496            .unwrap_or_default();
497        match raw.trim().to_lowercase().as_str() {
498            "dedicated" => RulesInjection::Dedicated,
499            _ => RulesInjection::Shared,
500        }
501    }
502
503    /// Returns the effective permission-inheritance mode, preferring the
504    /// `LEAN_CTX_PERMISSION_INHERITANCE` env var over config. Default is `Off`.
505    /// Accepts `on`/`true`/`1` as enabled.
506    #[must_use]
507    pub fn permission_inheritance_effective(&self) -> PermissionInheritance {
508        let raw = std::env::var("LEAN_CTX_PERMISSION_INHERITANCE")
509            .ok()
510            .or_else(|| self.permission_inheritance.clone())
511            .unwrap_or_default();
512        match raw.trim().to_lowercase().as_str() {
513            "on" | "true" | "1" | "inherit" => PermissionInheritance::On,
514            _ => PermissionInheritance::Off,
515        }
516    }
517
518    /// True when lean-ctx should inject its rules via each agent's dedicated,
519    /// non-polluting auto-load path *and* global rules are in scope.
520    ///
521    /// Gates the Claude/Codex `SessionStart` `additionalContext` summary: it
522    /// stands in for the (now-skipped) shared CLAUDE.md/AGENTS.md block, so it
523    /// only fires when injection is `Dedicated` and the scope isn't project-only.
524    #[must_use]
525    pub fn dedicated_session_context_active(&self) -> bool {
526        self.rules_injection_effective() == RulesInjection::Dedicated
527            && self.rules_scope_effective() != RulesScope::Project
528    }
529
530    fn parse_disabled_tools_env(val: &str) -> Vec<String> {
531        val.split(',')
532            .map(|s| s.trim().to_string())
533            .filter(|s| !s.is_empty())
534            .collect()
535    }
536
537    /// Returns the effective disabled tools list, preferring env var over config file.
538    pub fn disabled_tools_effective(&self) -> Vec<String> {
539        if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
540            Self::parse_disabled_tools_env(&val)
541        } else {
542            self.disabled_tools.clone()
543        }
544    }
545
546    /// Returns `true` if minimal overhead is enabled via env var or config.
547    pub fn minimal_overhead_effective(&self) -> bool {
548        std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
549    }
550
551    /// Returns `true` if minimal overhead should be enabled for this MCP client.
552    ///
553    /// This is a superset of `minimal_overhead_effective()`:
554    /// - `LEAN_CTX_OVERHEAD_MODE=minimal` forces minimal overhead
555    /// - `LEAN_CTX_OVERHEAD_MODE=full` disables client/model heuristics (still honors LEAN_CTX_MINIMAL / config)
556    /// - In auto mode (default), certain low-context clients/models are treated as minimal to prevent
557    ///   large metadata blocks from destabilizing smaller context windows (e.g. Hermes + MiniMax).
558    pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
559        if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
560            match raw.trim().to_lowercase().as_str() {
561                "minimal" => return true,
562                "full" => return self.minimal_overhead_effective(),
563                _ => {}
564            }
565        }
566
567        if self.minimal_overhead_effective() {
568            return true;
569        }
570
571        let client_lower = client_name.trim().to_lowercase();
572        if !client_lower.is_empty() {
573            if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
574                for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
575                    if !needle.is_empty() && client_lower.contains(&needle) {
576                        return true;
577                    }
578                }
579            } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
580                return true;
581            }
582        }
583
584        let model = std::env::var("LEAN_CTX_MODEL")
585            .or_else(|_| std::env::var("LCTX_MODEL"))
586            .unwrap_or_default();
587        let model = model.trim().to_lowercase();
588        if !model.is_empty() {
589            let m = model.replace(['_', ' '], "-");
590            if m.contains("minimax")
591                || m.contains("mini-max")
592                || m.contains("m2.7")
593                || m.contains("m2-7")
594            {
595                return true;
596            }
597        }
598
599        false
600    }
601
602    /// Returns `true` if shell hook injection is disabled via env var or config.
603    pub fn shell_hook_disabled_effective(&self) -> bool {
604        std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
605    }
606
607    /// Returns the effective shell activation mode (env var > config > default).
608    pub fn shell_activation_effective(&self) -> ShellActivation {
609        ShellActivation::effective(self)
610    }
611
612    /// Returns `true` if the daily update check is disabled via env var or config.
613    pub fn update_check_disabled_effective(&self) -> bool {
614        std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
615    }
616
617    pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
618        let mut policy = self.memory.clone();
619        policy.apply_env_overrides();
620
621        // Scale memory limits proportionally when max_disk_mb is set
622        // and individual limits are still at their defaults.
623        let budget = self.max_disk_mb_effective();
624        if budget > 0 {
625            let scale_factor = (budget as f64 / 500.0).clamp(0.5, 10.0);
626            let default_policy = MemoryPolicy::default();
627            if policy.knowledge.max_facts == default_policy.knowledge.max_facts {
628                policy.knowledge.max_facts = (200.0 * scale_factor) as usize;
629            }
630            if policy.knowledge.max_patterns == default_policy.knowledge.max_patterns {
631                policy.knowledge.max_patterns = (50.0 * scale_factor) as usize;
632            }
633            if policy.episodic.max_episodes == default_policy.episodic.max_episodes {
634                policy.episodic.max_episodes = (500.0 * scale_factor) as usize;
635            }
636            if policy.procedural.max_procedures == default_policy.procedural.max_procedures {
637                policy.procedural.max_procedures = (100.0 * scale_factor) as usize;
638            }
639        }
640
641        policy.validate()?;
642        Ok(policy)
643    }
644
645    /// Returns the effective set of default tool categories.
646    /// Priority: LCTX_DEFAULT_CATEGORIES env var > config.toml > hardcoded default.
647    pub fn default_tool_categories_effective(&self) -> Vec<String> {
648        if let Ok(val) = std::env::var("LCTX_DEFAULT_CATEGORIES") {
649            return val
650                .split(',')
651                .map(|s| s.trim().to_lowercase())
652                .filter(|s| !s.is_empty())
653                .collect();
654        }
655        if !self.default_tool_categories.is_empty() {
656            return self
657                .default_tool_categories
658                .iter()
659                .map(|s| s.to_lowercase())
660                .collect();
661        }
662        vec!["core".to_string(), "session".to_string()]
663    }
664
665    /// Returns the effective tool profile.
666    /// Priority: LEAN_CTX_TOOL_PROFILE env > config tool_profile > config tools_enabled > power.
667    pub fn tool_profile_effective(&self) -> super::tool_profiles::ToolProfile {
668        super::tool_profiles::ToolProfile::from_config(self)
669    }
670
671    /// Returns `true` if all automatic read-mode degradation is disabled.
672    /// Checks LCTX_NO_DEGRADE env var first, then config.toml field.
673    pub fn no_degrade_effective(&self) -> bool {
674        if let Ok(val) = std::env::var("LCTX_NO_DEGRADE") {
675            return val == "1" || val.eq_ignore_ascii_case("true");
676        }
677        self.no_degrade
678    }
679
680    /// Effective max_disk_mb from env or config.
681    pub fn max_disk_mb_effective(&self) -> u64 {
682        std::env::var("LEAN_CTX_MAX_DISK_MB")
683            .ok()
684            .and_then(|v| v.parse().ok())
685            .unwrap_or(self.max_disk_mb)
686    }
687
688    /// Effective max_staleness_days from env or config.
689    pub fn max_staleness_days_effective(&self) -> u32 {
690        std::env::var("LEAN_CTX_MAX_STALENESS_DAYS")
691            .ok()
692            .and_then(|v| v.parse().ok())
693            .unwrap_or(self.max_staleness_days)
694    }
695
696    /// Archive max_disk_mb derived from simplified max_disk_mb if the detail
697    /// value is still at its default. Explicit overrides take priority.
698    pub fn archive_max_disk_mb_effective(&self) -> u64 {
699        let budget = self.max_disk_mb_effective();
700        if budget > 0 && self.archive.max_disk_mb == ArchiveConfig::default().max_disk_mb {
701            budget * 25 / 100
702        } else {
703            self.archive.max_disk_mb
704        }
705    }
706
707    /// Archive max_age_hours derived from max_staleness_days if the detail
708    /// value is still at its default. Explicit overrides take priority.
709    pub fn archive_max_age_hours_effective(&self) -> u64 {
710        let staleness = self.max_staleness_days_effective();
711        if staleness > 0 && self.archive.max_age_hours == ArchiveConfig::default().max_age_hours {
712            staleness as u64 * 24
713        } else {
714            self.archive.max_age_hours
715        }
716    }
717
718    /// Effective on-disk ceiling (MB) for the persisted BM25 index. Single source
719    /// of truth for `save`/`load`, `cache prune`, and the doctor health check.
720    ///
721    /// Priority: explicit `bm25_max_cache_mb` › `max_disk_mb` budget (10%) ›
722    /// generous default ([`DEFAULT_BM25_PERSIST_MB`]). The default is decoupled
723    /// from the RAM profile so large repos persist instead of rebuilding forever
724    /// (issue #249).
725    pub fn bm25_max_cache_mb_effective(&self) -> u64 {
726        // Explicit per-key override always wins.
727        if self.bm25_max_cache_mb != serde_defaults::default_bm25_max_cache_mb() {
728            return self.bm25_max_cache_mb;
729        }
730        // Otherwise derive from an explicit overall disk budget when present …
731        let budget = self.max_disk_mb_effective();
732        if budget > 0 {
733            return budget * 10 / 100;
734        }
735        // … else fall back to the generous, profile-independent disk default.
736        DEFAULT_BM25_PERSIST_MB
737    }
738}
739
740impl Config {
741    /// Returns the path to the global config file (`~/.lean-ctx/config.toml`).
742    pub fn path() -> Option<PathBuf> {
743        crate::core::data_dir::lean_ctx_data_dir()
744            .ok()
745            .map(|d| d.join("config.toml"))
746    }
747
748    /// Returns the path to the project-local config override file.
749    pub fn local_path(project_root: &str) -> PathBuf {
750        PathBuf::from(project_root).join(".lean-ctx.toml")
751    }
752
753    fn find_project_root() -> Option<String> {
754        static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
755        ROOT_CACHE
756            .get_or_init(Self::find_project_root_inner)
757            .clone()
758    }
759
760    fn find_project_root_inner() -> Option<String> {
761        if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
762            if !env_root.is_empty() {
763                return Some(env_root);
764            }
765        }
766
767        let cwd = std::env::current_dir().ok();
768
769        if let Some(root) =
770            crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
771        {
772            let root_path = std::path::Path::new(&root);
773            let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
774            let has_marker = root_path.join(".git").exists()
775                || root_path.join("Cargo.toml").exists()
776                || root_path.join("package.json").exists()
777                || root_path.join("go.mod").exists()
778                || root_path.join("pyproject.toml").exists()
779                || root_path.join(".lean-ctx.toml").exists();
780
781            if cwd_is_under_root || has_marker {
782                return Some(root);
783            }
784        }
785
786        if let Some(ref cwd) = cwd {
787            let git_root = std::process::Command::new("git")
788                .args(["rev-parse", "--show-toplevel"])
789                .current_dir(cwd)
790                .stdout(std::process::Stdio::piped())
791                .stderr(std::process::Stdio::null())
792                .output()
793                .ok()
794                .and_then(|o| {
795                    if o.status.success() {
796                        String::from_utf8(o.stdout)
797                            .ok()
798                            .map(|s| s.trim().to_string())
799                    } else {
800                        None
801                    }
802                });
803            if let Some(root) = git_root {
804                return Some(root);
805            }
806            if !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
807                return Some(cwd.to_string_lossy().to_string());
808            }
809        }
810        None
811    }
812
813    /// Loads config from disk with caching, merging global + project-local overrides.
814    pub fn load() -> Self {
815        static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
816
817        let Some(path) = Self::path() else {
818            return Self::default();
819        };
820
821        let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
822
823        let mtime = std::fs::metadata(&path)
824            .and_then(|m| m.modified())
825            .unwrap_or(SystemTime::UNIX_EPOCH);
826
827        let local_mtime = local_path
828            .as_ref()
829            .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
830
831        if let Ok(guard) = CACHE.lock() {
832            if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
833                if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
834                    return cfg.clone();
835                }
836            }
837        }
838
839        let mut cfg: Config = if let Ok(content) = std::fs::read_to_string(&path) {
840            match toml::from_str(&content) {
841                Ok(c) => {
842                    record_parse_error(None);
843                    c
844                }
845                Err(e) => {
846                    record_parse_error(Some(format!("{e}")));
847                    tracing::warn!("config parse error in {}: {e}", path.display());
848                    eprintln!(
849                        "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n  \
850                         Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
851                        path.display()
852                    );
853                    Self::default()
854                }
855            }
856        } else {
857            record_parse_error(None);
858            Self::default()
859        };
860
861        if let Some(ref lp) = local_path {
862            if let Ok(local_content) = std::fs::read_to_string(lp) {
863                cfg.merge_local(&local_content);
864            }
865        }
866
867        if let Ok(mut guard) = CACHE.lock() {
868            *guard = Some((cfg.clone(), mtime, local_mtime));
869        }
870
871        cfg
872    }
873
874    fn merge_local(&mut self, local_toml: &str) {
875        let local: Config = match toml::from_str(local_toml) {
876            Ok(c) => c,
877            Err(e) => {
878                tracing::warn!("local config parse error: {e}");
879                eprintln!(
880                    "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n  \
881                     Local overrides skipped.\x1b[0m"
882                );
883                return;
884            }
885        };
886        if local.ultra_compact {
887            self.ultra_compact = true;
888        }
889        if local.tee_mode != TeeMode::default() {
890            self.tee_mode = local.tee_mode;
891        }
892        if local.output_density != OutputDensity::default() {
893            self.output_density = local.output_density;
894        }
895        if local.checkpoint_interval != 15 {
896            self.checkpoint_interval = local.checkpoint_interval;
897        }
898        if !local.excluded_commands.is_empty() {
899            self.excluded_commands.extend(local.excluded_commands);
900        }
901        if !local.passthrough_urls.is_empty() {
902            self.passthrough_urls.extend(local.passthrough_urls);
903        }
904        if !local.custom_aliases.is_empty() {
905            self.custom_aliases.extend(local.custom_aliases);
906        }
907        // Additive merge with dedup: project-local config can add formats on top
908        // of the global default (`["toon"]`) without re-listing it.
909        for fmt in local.preserve_compact_formats {
910            if !self
911                .preserve_compact_formats
912                .iter()
913                .any(|f| f.eq_ignore_ascii_case(&fmt))
914            {
915                self.preserve_compact_formats.push(fmt);
916            }
917        }
918        if local.slow_command_threshold_ms != 5000 {
919            self.slow_command_threshold_ms = local.slow_command_threshold_ms;
920        }
921        if local.theme != "default" {
922            self.theme = local.theme;
923        }
924        if !local.buddy_enabled {
925            self.buddy_enabled = false;
926        }
927        if !local.enable_wakeup_ctx {
928            self.enable_wakeup_ctx = false;
929        }
930        if !local.redirect_exclude.is_empty() {
931            self.redirect_exclude.extend(local.redirect_exclude);
932        }
933        if !local.disabled_tools.is_empty() {
934            self.disabled_tools.extend(local.disabled_tools);
935        }
936        if !local.extra_ignore_patterns.is_empty() {
937            self.extra_ignore_patterns
938                .extend(local.extra_ignore_patterns);
939        }
940        if local.rules_scope.is_some() {
941            self.rules_scope = local.rules_scope;
942        }
943        if local.rules_injection.is_some() {
944            self.rules_injection = local.rules_injection;
945        }
946        if local.permission_inheritance.is_some() {
947            self.permission_inheritance = local.permission_inheritance;
948        }
949        if local.proxy.anthropic_upstream.is_some() {
950            self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
951        }
952        if local.proxy.openai_upstream.is_some() {
953            self.proxy.openai_upstream = local.proxy.openai_upstream;
954        }
955        if local.proxy.gemini_upstream.is_some() {
956            self.proxy.gemini_upstream = local.proxy.gemini_upstream;
957        }
958        if !local.autonomy.enabled {
959            self.autonomy.enabled = false;
960        }
961        if !local.autonomy.auto_preload {
962            self.autonomy.auto_preload = false;
963        }
964        if !local.autonomy.auto_dedup {
965            self.autonomy.auto_dedup = false;
966        }
967        if !local.autonomy.auto_related {
968            self.autonomy.auto_related = false;
969        }
970        if !local.autonomy.auto_consolidate {
971            self.autonomy.auto_consolidate = false;
972        }
973        if local.autonomy.silent_preload {
974            self.autonomy.silent_preload = true;
975        }
976        if !local.autonomy.silent_preload && self.autonomy.silent_preload {
977            self.autonomy.silent_preload = false;
978        }
979        if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
980            self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
981        }
982        if local.autonomy.consolidate_every_calls
983            != AutonomyConfig::default().consolidate_every_calls
984        {
985            self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
986        }
987        if local.autonomy.consolidate_cooldown_secs
988            != AutonomyConfig::default().consolidate_cooldown_secs
989        {
990            self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
991        }
992        if !local.autonomy.cognition_loop_enabled {
993            self.autonomy.cognition_loop_enabled = false;
994        }
995        if local.autonomy.cognition_loop_interval_secs
996            != AutonomyConfig::default().cognition_loop_interval_secs
997        {
998            self.autonomy.cognition_loop_interval_secs =
999                local.autonomy.cognition_loop_interval_secs;
1000        }
1001        if local.autonomy.cognition_loop_max_steps
1002            != AutonomyConfig::default().cognition_loop_max_steps
1003        {
1004            self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
1005        }
1006        if local_toml.contains("compression_level") {
1007            self.compression_level = local.compression_level;
1008        }
1009        if local_toml.contains("terse_agent") {
1010            self.terse_agent = local.terse_agent;
1011        }
1012        if !local.archive.enabled {
1013            self.archive.enabled = false;
1014        }
1015        if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1016            self.archive.threshold_chars = local.archive.threshold_chars;
1017        }
1018        if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1019            self.archive.max_age_hours = local.archive.max_age_hours;
1020        }
1021        if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1022            self.archive.max_disk_mb = local.archive.max_disk_mb;
1023        }
1024        if !local.archive.ephemeral {
1025            self.archive.ephemeral = false;
1026        }
1027        let mem_def = MemoryPolicy::default();
1028        if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1029            self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1030        }
1031        if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1032            self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1033        }
1034        if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1035            self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1036        }
1037        if local.memory.knowledge.contradiction_threshold
1038            != mem_def.knowledge.contradiction_threshold
1039        {
1040            self.memory.knowledge.contradiction_threshold =
1041                local.memory.knowledge.contradiction_threshold;
1042        }
1043
1044        if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1045            self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1046        }
1047        if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1048        {
1049            self.memory.episodic.max_actions_per_episode =
1050                local.memory.episodic.max_actions_per_episode;
1051        }
1052        if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1053            self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1054        }
1055
1056        if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1057            self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1058        }
1059        if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1060            self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1061        }
1062        if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1063            self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1064        }
1065        if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1066            self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1067        }
1068
1069        if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1070            self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1071        }
1072        if local.memory.lifecycle.low_confidence_threshold
1073            != mem_def.lifecycle.low_confidence_threshold
1074        {
1075            self.memory.lifecycle.low_confidence_threshold =
1076                local.memory.lifecycle.low_confidence_threshold;
1077        }
1078        if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1079            self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1080        }
1081        if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1082            self.memory.lifecycle.similarity_threshold =
1083                local.memory.lifecycle.similarity_threshold;
1084        }
1085
1086        if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1087            self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1088        }
1089        if !local.allow_paths.is_empty() {
1090            self.allow_paths.extend(local.allow_paths);
1091        }
1092        if !local.extra_roots.is_empty() {
1093            self.extra_roots.extend(local.extra_roots);
1094        }
1095        if local.minimal_overhead {
1096            self.minimal_overhead = true;
1097        }
1098        if local.shell_hook_disabled {
1099            self.shell_hook_disabled = true;
1100        }
1101        if local.shell_activation != ShellActivation::default() {
1102            self.shell_activation = local.shell_activation.clone();
1103        }
1104        if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1105            self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1106        }
1107        if local.memory_profile != MemoryProfile::default() {
1108            self.memory_profile = local.memory_profile;
1109        }
1110        if local.memory_cleanup != MemoryCleanup::default() {
1111            self.memory_cleanup = local.memory_cleanup;
1112        }
1113        if !local.shell_allowlist.is_empty() {
1114            self.shell_allowlist = local.shell_allowlist;
1115        }
1116        if !local.shell_allowlist_extra.is_empty() {
1117            self.shell_allowlist_extra
1118                .extend(local.shell_allowlist_extra);
1119        }
1120        if !local.default_tool_categories.is_empty() {
1121            self.default_tool_categories = local.default_tool_categories;
1122        }
1123        if local.tool_profile.is_some() {
1124            self.tool_profile = local.tool_profile;
1125        }
1126        if !local.tools_enabled.is_empty() {
1127            self.tools_enabled = local.tools_enabled;
1128        }
1129        if local.no_degrade {
1130            self.no_degrade = true;
1131        }
1132        if local.profile.is_some() {
1133            self.profile = local.profile;
1134        }
1135        if local.proxy_timeout_ms.is_some() {
1136            self.proxy_timeout_ms = local.proxy_timeout_ms;
1137        }
1138    }
1139
1140    /// Persists the current config to the global config file.
1141    ///
1142    /// Preserves user comments, formatting, and unknown keys, keeps the file
1143    /// minimal (defaults that were never set on disk stay implicit), and writes
1144    /// atomically with a `.bak` backup so customizations are always recoverable.
1145    pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1146        let path = Self::path().ok_or_else(|| {
1147            super::error::LeanCtxError::Config("cannot determine home directory".into())
1148        })?;
1149        if let Some(parent) = path.parent() {
1150            std::fs::create_dir_all(parent)?;
1151        }
1152        let content = toml::to_string_pretty(self)
1153            .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1154        // Baseline = what loading an empty config yields. This honors serde's
1155        // field-level `#[serde(default)]` (which can diverge from the struct's
1156        // `Default` impl), so minimal mode skips exactly the keys that a fresh
1157        // load would produce — no spurious lines on save.
1158        let baseline = toml::from_str::<Self>("").unwrap_or_else(|_| Self::default());
1159        let defaults = toml::to_string_pretty(&baseline)
1160            .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1161        crate::config_io::write_toml_preserving_minimal(&path, &content, &defaults)
1162            .map_err(super::error::LeanCtxError::Config)?;
1163        Ok(())
1164    }
1165
1166    /// Formats the current config as a human-readable string with file paths.
1167    pub fn show(&self) -> String {
1168        let global_path = Self::path().map_or_else(
1169            || "~/.lean-ctx/config.toml".to_string(),
1170            |p| p.to_string_lossy().to_string(),
1171        );
1172        let content = toml::to_string_pretty(self).unwrap_or_default();
1173        let mut out = format!("Global config: {global_path}\n\n{content}");
1174
1175        if let Some(root) = Self::find_project_root() {
1176            let local = Self::local_path(&root);
1177            if local.exists() {
1178                out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1179            } else {
1180                out.push_str(&format!(
1181                    "\n\nLocal config: not found (create {} to override per-project)\n",
1182                    local.display()
1183                ));
1184            }
1185        }
1186        out
1187    }
1188}