Skip to main content

lean_ctx/core/config/
mod.rs

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