Skip to main content

lean_ctx/core/config/
logic.rs

1use std::path::Path;
2
3#[allow(clippy::wildcard_imports)]
4use super::*;
5impl Config {
6    /// Whether opt-in lossless JSON crushing of verbatim data commands (#936) is
7    /// active. `LEAN_CTX_CRUSH_VERBATIM_JSON` (any value) wins, then the
8    /// `crush_verbatim_json` config flag, else `false`.
9    pub fn crush_verbatim_json_enabled(&self) -> bool {
10        std::env::var("LEAN_CTX_CRUSH_VERBATIM_JSON").is_ok() || self.crush_verbatim_json
11    }
12
13    /// Effective proxy bind address (gateway mode, enterprise#8). Precedence:
14    /// `LEAN_CTX_PROXY_BIND_HOST` env > `proxy_bind_host` config > loopback.
15    /// The value must parse as an IP address; anything else (including a blank)
16    /// resolves to `127.0.0.1` — a typo can only ever *narrow* exposure, never
17    /// silently open the listener.
18    #[must_use]
19    pub fn resolved_proxy_bind_host(&self) -> std::net::IpAddr {
20        let raw = std::env::var("LEAN_CTX_PROXY_BIND_HOST")
21            .ok()
22            .filter(|v| !v.trim().is_empty())
23            .or_else(|| self.proxy_bind_host.clone());
24        match raw.as_deref().map(str::trim) {
25            Some(v) if !v.is_empty() => v.parse().unwrap_or_else(|_| {
26                tracing::warn!(
27                    "proxy_bind_host '{v}' is not a valid IP address — binding 127.0.0.1"
28                );
29                std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
30            }),
31            _ => std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
32        }
33    }
34
35    /// Returns the effective rules scope, preferring env var over config file.
36    pub fn rules_scope_effective(&self) -> RulesScope {
37        let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
38            .ok()
39            .or_else(|| self.rules_scope.clone())
40            .unwrap_or_default();
41        match raw.trim().to_lowercase().as_str() {
42            "global" => RulesScope::Global,
43            "project" => RulesScope::Project,
44            _ => RulesScope::Both,
45        }
46    }
47
48    /// Returns the effective rules injection mode, preferring env var over config.
49    /// Default is `Shared` (zero-config discovery via a CLAUDE.md/CODEBUDDY.md/AGENTS.md block).
50    pub fn rules_injection_effective(&self) -> RulesInjection {
51        let raw = std::env::var("LEAN_CTX_RULES_INJECTION")
52            .ok()
53            .or_else(|| self.rules_injection.clone())
54            .unwrap_or_default();
55        match raw.trim().to_lowercase().as_str() {
56            "dedicated" => RulesInjection::Dedicated,
57            "off" | "none" | "disabled" => RulesInjection::Off,
58            _ => RulesInjection::Shared,
59        }
60    }
61
62    /// Provider prompt-cache hit rate for net-of-injection (#1104).
63    /// Returns the configured value or None (caller picks the default).
64    #[must_use]
65    pub fn dashboard_cache_hit_rate(&self) -> Option<f64> {
66        std::env::var("LEAN_CTX_CACHE_HIT_RATE")
67            .ok()
68            .and_then(|v| v.parse().ok())
69            .or(self.dashboard_cache_hit_rate)
70    }
71    /// Returns the user-configured hook mode override, or `None` for auto-detect.
72    /// Env var `LEAN_CTX_HOOK_MODE` takes priority over config.
73    #[must_use]
74    pub fn hook_mode_override(&self) -> Option<crate::hooks::HookMode> {
75        let raw = std::env::var("LEAN_CTX_HOOK_MODE")
76            .ok()
77            .or_else(|| self.hook_mode.clone())?;
78        crate::hooks::HookMode::from_str_loose(raw.trim())
79    }
80
81    /// Returns the effective permission-inheritance mode, preferring the
82    /// `LEAN_CTX_PERMISSION_INHERITANCE` env var over config. Default is `Off`.
83    /// Accepts `on`/`true`/`1` as enabled.
84    #[must_use]
85    pub fn permission_inheritance_effective(&self) -> PermissionInheritance {
86        let raw = std::env::var("LEAN_CTX_PERMISSION_INHERITANCE")
87            .ok()
88            .or_else(|| self.permission_inheritance.clone())
89            .unwrap_or_default();
90        match raw.trim().to_lowercase().as_str() {
91            "on" | "true" | "1" | "inherit" => PermissionInheritance::On,
92            _ => PermissionInheritance::Off,
93        }
94    }
95
96    /// True when lean-ctx should inject its rules via each agent's dedicated,
97    /// non-polluting auto-load path *and* global rules are in scope.
98    ///
99    /// Gates the Claude/Codex `SessionStart` `additionalContext` summary: it
100    /// stands in for the (now-skipped) shared CLAUDE.md/CODEBUDDY.md/AGENTS.md block, so it
101    /// only fires when injection is `Dedicated` and the scope isn't project-only.
102    #[must_use]
103    pub fn dedicated_session_context_active(&self) -> bool {
104        self.rules_injection_effective() == RulesInjection::Dedicated
105            && self.rules_scope_effective() != RulesScope::Project
106    }
107
108    pub(super) fn parse_disabled_tools_env(val: &str) -> Vec<String> {
109        val.split(',')
110            .map(|s| s.trim().to_string())
111            .filter(|s| !s.is_empty())
112            .collect()
113    }
114
115    /// Returns the effective disabled tools list, preferring env var over config
116    /// file. When `prefer_native_editor` is active, the lean-ctx edit tools are
117    /// folded in so they are hidden from `list_tools` (#454).
118    pub fn disabled_tools_effective(&self) -> Vec<String> {
119        let mut list = if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
120            Self::parse_disabled_tools_env(&val)
121        } else {
122            self.disabled_tools.clone()
123        };
124        if self.prefer_native_editor_effective() {
125            for name in EDIT_TOOL_NAMES {
126                if !list.iter().any(|t| t == name) {
127                    list.push((*name).to_string());
128                }
129            }
130        }
131        list
132    }
133
134    /// Whether lean-ctx edit operations are disabled in favour of the host's
135    /// native editor (#454). `LEAN_CTX_PREFER_NATIVE_EDITOR` wins over config.
136    pub fn prefer_native_editor_effective(&self) -> bool {
137        match std::env::var("LEAN_CTX_PREFER_NATIVE_EDITOR") {
138            Ok(raw) => matches!(
139                raw.trim().to_lowercase().as_str(),
140                "1" | "true" | "yes" | "on"
141            ),
142            Err(_) => self.prefer_native_editor,
143        }
144    }
145
146    /// Cap on the rayon index-build worker threads. `LEANCTX_INDEX_THREADS` wins
147    /// over config; `0` means "no cap" — rayon's all-cores default is kept.
148    pub fn max_index_threads_effective(&self) -> usize {
149        std::env::var("LEANCTX_INDEX_THREADS")
150            .ok()
151            .and_then(|raw| raw.trim().parse::<usize>().ok())
152            .unwrap_or(self.max_index_threads)
153    }
154
155    /// Whether `name` is a lean-ctx edit operation that must be blocked from
156    /// dispatch (direct and via `ctx_call`) when [`Self::prefer_native_editor_effective`]
157    /// is set (#454). Read/search/shell/memory tools are never blocked.
158    pub fn edit_tool_blocked(&self, name: &str) -> bool {
159        self.prefer_native_editor_effective() && EDIT_TOOL_NAMES.contains(&name)
160    }
161
162    /// Returns `true` if minimal overhead is enabled via env var or config.
163    pub fn minimal_overhead_effective(&self) -> bool {
164        std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
165    }
166
167    /// Returns `true` if structure-first auto reads are enabled.
168    ///
169    /// The `LEAN_CTX_STRUCTURE_FIRST` env var wins over the config field, and
170    /// accepts the usual truthy/falsy spellings so a harness can flip it per run
171    /// (`LEAN_CTX_STRUCTURE_FIRST=0` forces it off even if config enables it).
172    pub fn structure_first_effective(&self) -> bool {
173        match std::env::var("LEAN_CTX_STRUCTURE_FIRST") {
174            Ok(raw) => matches!(
175                raw.trim().to_lowercase().as_str(),
176                "1" | "true" | "yes" | "on"
177            ),
178            Err(_) => self.structure_first,
179        }
180    }
181
182    /// Effective session token limit. 0 = unlimited.
183    pub fn session_token_limit_effective(&self) -> usize {
184        std::env::var("LEAN_CTX_SESSION_TOKEN_LIMIT")
185            .ok()
186            .and_then(|v| v.trim().parse().ok())
187            .unwrap_or(self.session_token_limit)
188    }
189
190    /// Effective turn budget (fresh token limit per response). 0 = unlimited.
191    pub fn turn_fresh_limit_effective(&self) -> usize {
192        std::env::var("LEAN_CTX_TURN_FRESH_LIMIT")
193            .ok()
194            .and_then(|v| v.trim().parse().ok())
195            .unwrap_or(self.turn_fresh_limit)
196    }
197
198    /// Whether progressive disclosure is active. Default true. The env var
199    /// `LEAN_CTX_PROGRESSIVE_DISCLOSURE` overrides the config field.
200    pub fn progressive_disclosure_effective(&self) -> bool {
201        match std::env::var("LEAN_CTX_PROGRESSIVE_DISCLOSURE") {
202            Ok(raw) => matches!(
203                raw.trim().to_lowercase().as_str(),
204                "1" | "true" | "yes" | "on"
205            ),
206            Err(_) => self.progressive_disclosure,
207        }
208    }
209
210    /// Returns `true` when the adaptive learning signals may participate in
211    /// `auto` mode resolution (#683). Off by default for a deterministic,
212    /// I/O-light cascade; the `LEAN_CTX_AUTO_MODE_LEARNING` env var wins over the
213    /// config field and accepts the usual truthy/falsy spellings.
214    pub fn auto_mode_learning_effective(&self) -> bool {
215        match std::env::var("LEAN_CTX_AUTO_MODE_LEARNING") {
216            Ok(raw) => matches!(
217                raw.trim().to_lowercase().as_str(),
218                "1" | "true" | "yes" | "on"
219            ),
220            Err(_) => self.auto_mode_learning,
221        }
222    }
223
224    /// Returns `true` when probabilistic exploration (Thompson sampling,
225    /// Boltzmann-temperature eviction, simulated annealing) may influence
226    /// decisions. Off by default so tool output stays a deterministic, byte-
227    /// stable function of (content, mode, task) — the determinism contract
228    /// (#498) that lets provider prompt caching apply. The `LEAN_CTX_STOCHASTIC`
229    /// env var wins (the usual truthy/falsy spellings); otherwise it follows
230    /// [`Self::auto_mode_learning_effective`], which is itself off by default.
231    pub fn is_stochastic_enabled(&self) -> bool {
232        match std::env::var("LEAN_CTX_STOCHASTIC") {
233            Ok(raw) => matches!(
234                raw.trim().to_lowercase().as_str(),
235                "1" | "true" | "yes" | "on"
236            ),
237            Err(_) => self.auto_mode_learning_effective(),
238        }
239    }
240
241    /// Returns `true` if minimal overhead should be enabled for this MCP client.
242    ///
243    /// This is a superset of `minimal_overhead_effective()`:
244    /// - `LEAN_CTX_OVERHEAD_MODE=minimal` forces minimal overhead
245    /// - `LEAN_CTX_OVERHEAD_MODE=full` disables client/model heuristics (still honors LEAN_CTX_MINIMAL / config)
246    /// - In auto mode (default), certain low-context clients/models are treated as minimal to prevent
247    ///   large metadata blocks from destabilizing smaller context windows (e.g. Hermes + MiniMax).
248    pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
249        if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
250            match raw.trim().to_lowercase().as_str() {
251                "minimal" => return true,
252                "full" => return self.minimal_overhead_effective(),
253                _ => {}
254            }
255        }
256
257        if self.minimal_overhead_effective() {
258            return true;
259        }
260
261        let client_lower = client_name.trim().to_lowercase();
262        if !client_lower.is_empty() {
263            if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
264                for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
265                    if !needle.is_empty() && client_lower.contains(&needle) {
266                        return true;
267                    }
268                }
269            } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
270                return true;
271            }
272        }
273
274        let model = std::env::var("LEAN_CTX_MODEL")
275            .or_else(|_| std::env::var("LCTX_MODEL"))
276            .unwrap_or_default();
277        let model = model.trim().to_lowercase();
278        if !model.is_empty() {
279            let m = model.replace(['_', ' '], "-");
280            if m.contains("minimax")
281                || m.contains("mini-max")
282                || m.contains("m2.7")
283                || m.contains("m2-7")
284            {
285                return true;
286            }
287        }
288
289        false
290    }
291
292    /// Returns `true` if shell hook injection is disabled via env var or config.
293    pub fn shell_hook_disabled_effective(&self) -> bool {
294        std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
295    }
296
297    /// Returns the effective shell activation mode (env var > config > default).
298    pub fn shell_activation_effective(&self) -> ShellActivation {
299        ShellActivation::effective(self)
300    }
301
302    /// Returns `true` if `ctx_shell` may accept shell file-write redirects.
303    /// `LEAN_CTX_SHELL_ALLOW_WRITES` (`1`/`true`/`yes`/`on`) overrides
304    /// `config.toml`. The real command gating still applies either way.
305    pub fn shell_allow_writes_effective(&self) -> bool {
306        match std::env::var("LEAN_CTX_SHELL_ALLOW_WRITES") {
307            Ok(raw) => matches!(
308                raw.trim().to_ascii_lowercase().as_str(),
309                "1" | "true" | "yes" | "on"
310            ),
311            Err(_) => self.shell_allow_writes,
312        }
313    }
314
315    /// Returns the effective paths where shell output capture may write.
316    /// Empty configuration intentionally falls back to OS temp directories.
317    pub fn shell_write_allow_paths_effective(&self) -> Vec<String> {
318        if self.write_allow_paths.is_empty() {
319            default_shell_write_allow_paths()
320        } else {
321            self.write_allow_paths.clone()
322        }
323    }
324
325    /// #814: returns `true` if `ctx_shell` may accept inline interpreter scripts
326    /// (`python3 -c "..."`, `node -e "..."`, etc.).
327    /// `LEAN_CTX_SHELL_ALLOW_INLINE_SCRIPTS` (`1`/`true`/`yes`/`on`) overrides
328    /// `config.toml`. The real command gating (allowlist) still applies.
329    pub fn shell_allow_inline_scripts_effective(&self) -> bool {
330        match std::env::var("LEAN_CTX_SHELL_ALLOW_INLINE_SCRIPTS") {
331            Ok(raw) => matches!(
332                raw.trim().to_ascii_lowercase().as_str(),
333                "1" | "true" | "yes" | "on"
334            ),
335            Err(_) => self.shell_allow_inline_scripts,
336        }
337    }
338
339    /// Returns `true` if the daily update check is disabled via env var or config.
340    pub fn update_check_disabled_effective(&self) -> bool {
341        std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
342    }
343
344    pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
345        let mut policy = self.memory.clone();
346        policy.apply_env_overrides();
347
348        let budget = self.max_disk_mb_effective();
349        if budget > 0 {
350            let scale_factor = (budget as f64 / 500.0).clamp(0.5, 10.0);
351            let default_policy = MemoryPolicy::default();
352            if policy.knowledge.max_facts == default_policy.knowledge.max_facts {
353                policy.knowledge.max_facts = (200.0 * scale_factor) as usize;
354            }
355            if policy.knowledge.max_patterns == default_policy.knowledge.max_patterns {
356                policy.knowledge.max_patterns = (50.0 * scale_factor) as usize;
357            }
358            if policy.episodic.max_episodes == default_policy.episodic.max_episodes {
359                policy.episodic.max_episodes = (500.0 * scale_factor) as usize;
360            }
361            if policy.procedural.max_procedures == default_policy.procedural.max_procedures {
362                policy.procedural.max_procedures = (100.0 * scale_factor) as usize;
363            }
364        }
365
366        policy.validate()?;
367        Ok(policy)
368    }
369
370    /// Returns the effective set of default tool categories.
371    /// Priority: LCTX_DEFAULT_CATEGORIES env var > config.toml > hardcoded default.
372    pub fn default_tool_categories_effective(&self) -> Vec<String> {
373        if let Ok(val) = std::env::var("LCTX_DEFAULT_CATEGORIES") {
374            return val
375                .split(',')
376                .map(|s| s.trim().to_lowercase())
377                .filter(|s| !s.is_empty())
378                .collect();
379        }
380        if !self.default_tool_categories.is_empty() {
381            return self
382                .default_tool_categories
383                .iter()
384                .map(|s| s.to_lowercase())
385                .collect();
386        }
387        vec!["core".to_string(), "session".to_string()]
388    }
389
390    /// Returns the effective tool profile.
391    /// Priority: LEAN_CTX_TOOL_PROFILE env > config tool_profile > config
392    /// tools_enabled > active persona's tool surface > power.
393    ///
394    /// Explicit settings win (backward compatible); when none are set, the
395    /// active persona supplies the tool surface (the `coding` default resolves
396    /// to `power`, so existing installs are unaffected).
397    pub fn tool_profile_effective(&self) -> super::tool_profiles::ToolProfile {
398        super::persona::Persona::resolve(self).effective_tool_profile(self)
399    }
400
401    /// The `[sensitivity]` config with the active persona's floor folded in
402    /// (persona-spec-v1). Enforcement chokepoints use this instead of the raw
403    /// field so a persona like `lead-gen` (`sensitivity_floor = "confidential"`)
404    /// protects PII out of the box. The `coding` default (`public`) passes the
405    /// config through unchanged.
406    #[must_use]
407    pub fn sensitivity_effective(&self) -> crate::core::sensitivity::SensitivityConfig {
408        self.sensitivity
409            .clone()
410            .with_persona_floor(super::persona::Persona::resolve(self).sensitivity_floor)
411    }
412
413    /// Returns `true` if all automatic read-mode degradation is disabled.
414    /// Checks LCTX_NO_DEGRADE env var first, then config.toml field.
415    pub fn no_degrade_effective(&self) -> bool {
416        if let Ok(val) = std::env::var("LCTX_NO_DEGRADE") {
417            return val == "1" || val.eq_ignore_ascii_case("true");
418        }
419        self.no_degrade
420    }
421
422    /// Returns `true` if explicit `full`/`lines:N-M` re-reads of
423    /// cached-but-changed files should be served as deltas (`mode=diff`)
424    /// instead of re-emitting full content.
425    ///
426    /// Checks the `LCTX_DELTA_EXPLICIT` env var first, then the config.toml
427    /// field. Unlike a presence-only knob, an explicit `0`/`false` in the env
428    /// forces the feature OFF even when the config field is `true`, so the env
429    /// can fully override config in both directions.
430    pub fn delta_explicit_effective(&self) -> bool {
431        if let Ok(val) = std::env::var("LCTX_DELTA_EXPLICIT") {
432            return val == "1" || val.eq_ignore_ascii_case("true");
433        }
434        self.delta_explicit
435    }
436
437    /// Effective max_disk_mb from env or config.
438    pub fn max_disk_mb_effective(&self) -> u64 {
439        std::env::var("LEAN_CTX_MAX_DISK_MB")
440            .ok()
441            .and_then(|v| v.parse().ok())
442            .unwrap_or(self.max_disk_mb)
443    }
444
445    /// Effective max_staleness_days from env or config.
446    pub fn max_staleness_days_effective(&self) -> u32 {
447        std::env::var("LEAN_CTX_MAX_STALENESS_DAYS")
448            .ok()
449            .and_then(|v| v.parse().ok())
450            .unwrap_or(self.max_staleness_days)
451    }
452
453    /// Effective fixed-context budget (tokens) from env or config (#964). `0`
454    /// (env or config) disables the warning; otherwise the per-session footprint
455    /// is checked against this in `doctor overhead` and `gain`.
456    pub fn context_budget_tokens_effective(&self) -> usize {
457        std::env::var("LEAN_CTX_CONTEXT_BUDGET_TOKENS")
458            .ok()
459            .and_then(|v| v.parse().ok())
460            .unwrap_or(self.context.budget_tokens)
461    }
462
463    /// Whether later tool calls may receive matching CCR context.
464    pub fn proactive_expansion_effective(&self) -> bool {
465        self.context.proactive_expansion
466    }
467
468    /// Per-response token budget for proactive CCR expansion.
469    pub fn proactive_expansion_budget_tokens_effective(&self) -> usize {
470        self.context.proactive_expansion_budget_tokens
471    }
472
473    /// Normalized BM25 score threshold for proactive CCR expansion.
474    pub fn proactive_expansion_threshold_effective(&self) -> f64 {
475        self.context.proactive_expansion_threshold
476    }
477
478    /// Maximum archive age considered for proactive expansion.
479    pub fn proactive_expansion_max_age_secs_effective(&self) -> u64 {
480        self.context.proactive_expansion_max_age_secs
481    }
482
483    /// Archive max_disk_mb derived from simplified max_disk_mb if the detail
484    /// value is still at its default. Explicit overrides take priority.
485    pub fn archive_max_disk_mb_effective(&self) -> u64 {
486        let budget = self.max_disk_mb_effective();
487        if budget > 0 && self.archive.max_disk_mb == ArchiveConfig::default().max_disk_mb {
488            budget * 25 / 100
489        } else {
490            self.archive.max_disk_mb
491        }
492    }
493
494    /// Archive max_age_hours derived from max_staleness_days if the detail
495    /// value is still at its default. Explicit overrides take priority.
496    pub fn archive_max_age_hours_effective(&self) -> u64 {
497        let staleness = self.max_staleness_days_effective();
498        if staleness > 0 && self.archive.max_age_hours == ArchiveConfig::default().max_age_hours {
499            staleness as u64 * 24
500        } else {
501            self.archive.max_age_hours
502        }
503    }
504
505    /// Effective on-disk ceiling (MB) for the persisted BM25 index. Single source
506    /// of truth for `save`/`load`, `cache prune`, and the doctor health check.
507    ///
508    /// Priority: explicit `bm25_max_cache_mb` › `max_disk_mb` budget (10%) ›
509    /// generous default ([`DEFAULT_BM25_PERSIST_MB`]). The default is decoupled
510    /// from the RAM profile so large repos persist instead of rebuilding forever
511    /// (issue #249).
512    pub fn bm25_max_cache_mb_effective(&self) -> u64 {
513        if self.bm25_max_cache_mb != serde_defaults::default_bm25_max_cache_mb() {
514            return self.bm25_max_cache_mb;
515        }
516        let budget = self.max_disk_mb_effective();
517        if budget > 0 {
518            return budget * 10 / 100;
519        }
520        DEFAULT_BM25_PERSIST_MB
521    }
522}
523
524impl Config {
525    /// Safely mutate and persist the GLOBAL config. Reads the global file only
526    /// (no project-local merge), applies `f`, then writes minimally. Refuses
527    /// (returns `Err`) when the file exists but is unparseable, so a typo can
528    /// never clobber a customized config (#443). Returns the saved `Config`.
529    ///
530    /// This is the canonical persistence entry point: prefer it over
531    /// `Config::load()` followed by `save()`, which leaks project-local
532    /// overrides into the global file.
533    pub fn update_global<F>(f: F) -> std::result::Result<Self, super::error::LeanCtxError>
534    where
535        F: FnOnce(&mut Self),
536    {
537        let path = Self::path().ok_or_else(|| {
538            super::error::LeanCtxError::Config("cannot determine home directory".into())
539        })?;
540        Self::update_global_at(&path, f)
541    }
542
543    /// Path-parameterized core of [`Config::update_global`] (unit-testable).
544    pub(super) fn update_global_at<F>(
545        path: &Path,
546        f: F,
547    ) -> std::result::Result<Self, super::error::LeanCtxError>
548    where
549        F: FnOnce(&mut Self),
550    {
551        let mut cfg = match std::fs::read_to_string(path) {
552            Ok(raw) if !raw.trim().is_empty() => toml::from_str::<Self>(&raw).map_err(|e| {
553                super::error::LeanCtxError::Config(
554                    format!(
555                        "refusing to modify an unparseable config.toml ({e}); fix it \
556                     manually or run `lean-ctx doctor --fix`, then retry"
557                    )
558                    .into(),
559                )
560            })?,
561            _ => Self::default(),
562        };
563        f(&mut cfg);
564        cfg.save_to(path)?;
565        Ok(cfg)
566    }
567
568    /// Persists the current config to the global config file.
569    ///
570    /// Preserves user comments, formatting, and unknown keys, keeps the file
571    /// minimal (defaults that were never set on disk stay implicit), and writes
572    /// atomically with a `.bak` backup so customizations are always recoverable.
573    pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
574        let path = Self::path().ok_or_else(|| {
575            super::error::LeanCtxError::Config("cannot determine home directory".into())
576        })?;
577        self.save_to(&path)
578    }
579
580    /// Path-parameterized core of [`Config::save`] (unit-testable).
581    pub(super) fn save_to(
582        &self,
583        path: &Path,
584    ) -> std::result::Result<(), super::error::LeanCtxError> {
585        if let Some(parent) = path.parent() {
586            std::fs::create_dir_all(parent)?;
587        }
588        let content = toml::to_string_pretty(self)
589            .map_err(|e| super::error::LeanCtxError::Config(e.to_string().into()))?;
590        // Baseline = what loading an empty config yields. This honors serde's
591        // field-level `#[serde(default)]` (which can diverge from the struct's
592        // `Default` impl), so minimal mode skips exactly the keys that a fresh
593        // load would produce — no spurious lines on save.
594        let baseline = toml::from_str::<Self>("").unwrap_or_else(|_| Self::default());
595        let defaults = toml::to_string_pretty(&baseline)
596            .map_err(|e| super::error::LeanCtxError::Config(e.to_string().into()))?;
597        crate::config_io::write_toml_preserving_minimal(path, &content, &defaults)
598            .map_err(|e| super::error::LeanCtxError::Config(e.into()))?;
599        Ok(())
600    }
601
602    /// Formats the current config as a human-readable string with file paths.
603    pub fn show(&self) -> String {
604        let global_path = Self::path().map_or_else(
605            || "~/.lean-ctx/config.toml".to_string(),
606            |p| p.to_string_lossy().to_string(),
607        );
608        let content = toml::to_string_pretty(self).unwrap_or_default();
609        let mut out = format!("Global config: {global_path}\n\n{content}");
610
611        if let Some(root) = Self::find_project_root() {
612            let local = Self::local_path(&root);
613            if local.exists() {
614                out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
615            } else {
616                out.push_str(&format!(
617                    "\n\nLocal config: not found (create {} to override per-project)\n",
618                    local.display()
619                ));
620            }
621        }
622        out
623    }
624}