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