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