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    /// #1754: has the user declined lean-ctx steering their agent?
63    ///
64    /// `rules_injection = off` is the #1599 rule — off means off, on every
65    /// channel, including text lean-ctx appends inside a tool result. Under
66    /// `Off` such a reminder is also unclearable: `inject_all_rules`
67    /// early-returns, and the `lean-ctx setup` it suggests removes the rules
68    /// block rather than writing it, so the reminder returns next session.
69    ///
70    /// An explicit `setup.auto_inject_rules = false` says the same thing in the
71    /// setup section. `None` (auto — rules simply not present yet) is not a
72    /// refusal and still gets the reminder.
73    #[must_use]
74    pub fn declines_rule_steering(&self) -> bool {
75        self.rules_injection_effective() == RulesInjection::Off
76            || self.setup.auto_inject_rules == Some(false)
77    }
78
79    /// Provider prompt-cache hit rate for net-of-injection (#1104).
80    /// Returns the configured value or None (caller picks the default).
81    #[must_use]
82    pub fn dashboard_cache_hit_rate(&self) -> Option<f64> {
83        std::env::var("LEAN_CTX_CACHE_HIT_RATE")
84            .ok()
85            .and_then(|v| v.parse().ok())
86            .or(self.dashboard_cache_hit_rate)
87    }
88    /// Returns the user-configured hook mode override, or `None` for auto-detect.
89    /// Env var `LEAN_CTX_HOOK_MODE` takes priority over config.
90    #[must_use]
91    pub fn hook_mode_override(&self) -> Option<crate::core::hook_mode::HookMode> {
92        let raw = std::env::var("LEAN_CTX_HOOK_MODE")
93            .ok()
94            .or_else(|| self.hook_mode.clone())?;
95        crate::core::hook_mode::HookMode::from_str_loose(raw.trim())
96    }
97
98    /// Returns the effective permission-inheritance mode, preferring the
99    /// `LEAN_CTX_PERMISSION_INHERITANCE` env var over config. Default is `Off`.
100    /// Accepts `on`/`true`/`1` as enabled.
101    #[must_use]
102    pub fn permission_inheritance_effective(&self) -> PermissionInheritance {
103        let raw = std::env::var("LEAN_CTX_PERMISSION_INHERITANCE")
104            .ok()
105            .or_else(|| self.permission_inheritance.clone())
106            .unwrap_or_default();
107        match raw.trim().to_lowercase().as_str() {
108            "off" | "false" | "0" | "none" => PermissionInheritance::Off,
109            // GH #1428: default to On so IDE permission rules (e.g. "rm *": "ask")
110            // are honored by ctx_* tools out of the box. The check is read-only and
111            // only activates when an IDE with a permission system is detected
112            // (v1: OpenCode); undetected IDEs are always allowed through.
113            _ => PermissionInheritance::On,
114        }
115    }
116
117    /// True when lean-ctx should inject its rules via each agent's dedicated,
118    /// non-polluting auto-load path *and* global rules are in scope.
119    ///
120    /// Gates the Claude/Codex `SessionStart` `additionalContext` summary: it
121    /// stands in for the (now-skipped) shared CLAUDE.md/CODEBUDDY.md/AGENTS.md block, so it
122    /// only fires when injection is `Dedicated` and the scope isn't project-only.
123    #[must_use]
124    pub fn dedicated_session_context_active(&self) -> bool {
125        self.rules_injection_effective() == RulesInjection::Dedicated
126            && self.rules_scope_effective() != RulesScope::Project
127    }
128
129    pub(super) fn parse_disabled_tools_env(val: &str) -> Vec<String> {
130        val.split(',')
131            .map(|s| s.trim().to_string())
132            .filter(|s| !s.is_empty())
133            .collect()
134    }
135
136    /// Returns the effective disabled tools list, preferring env var over config
137    /// file. When `prefer_native_editor` is active, the lean-ctx edit tools are
138    /// folded in so they are hidden from `list_tools` (#454).
139    pub fn disabled_tools_effective(&self) -> Vec<String> {
140        let mut list = if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
141            Self::parse_disabled_tools_env(&val)
142        } else {
143            self.disabled_tools.clone()
144        };
145        if self.prefer_native_editor_effective() {
146            for name in EDIT_TOOL_NAMES {
147                if !list.iter().any(|t| t == name) {
148                    list.push((*name).to_string());
149                }
150            }
151        }
152        list
153    }
154
155    /// Whether lean-ctx edit operations are disabled in favour of the host's
156    /// native editor (#454). `LEAN_CTX_PREFER_NATIVE_EDITOR` wins over config.
157    pub fn prefer_native_editor_effective(&self) -> bool {
158        match std::env::var("LEAN_CTX_PREFER_NATIVE_EDITOR") {
159            Ok(raw) => matches!(
160                raw.trim().to_lowercase().as_str(),
161                "1" | "true" | "yes" | "on"
162            ),
163            Err(_) => self.prefer_native_editor,
164        }
165    }
166
167    /// Cap on the rayon index-build worker threads. `LEANCTX_INDEX_THREADS` wins
168    /// over config; `0` means "no cap" — rayon's all-cores default is kept.
169    pub fn max_index_threads_effective(&self) -> usize {
170        std::env::var("LEANCTX_INDEX_THREADS")
171            .ok()
172            .and_then(|raw| raw.trim().parse::<usize>().ok())
173            .unwrap_or(self.max_index_threads)
174    }
175
176    /// Whether `name` is a lean-ctx edit operation that must be blocked from
177    /// dispatch (direct and via `ctx_call`) when [`Self::prefer_native_editor_effective`]
178    /// is set (#454). Read/search/shell/memory tools are never blocked.
179    pub fn edit_tool_blocked(&self, name: &str) -> bool {
180        self.prefer_native_editor_effective() && EDIT_TOOL_NAMES.contains(&name)
181    }
182
183    /// Returns `true` if minimal overhead is enabled via env var or config.
184    pub fn minimal_overhead_effective(&self) -> bool {
185        std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
186    }
187
188    /// The `LEAN_CTX_MINIMAL` env var is the documented do-not-touch-anything
189    /// escape hatch (3.9.19 triage incident): only IT may skip the pipeline's
190    /// archive + ephemeral-firewall safety net. The `minimal_overhead` config
191    /// key (default `true`) trims MCP instruction overhead only and must never
192    /// disable archiving (#1540).
193    pub fn minimal_escape_hatch() -> bool {
194        std::env::var("LEAN_CTX_MINIMAL").is_ok()
195    }
196
197    /// Returns `true` if structure-first auto reads are enabled.
198    ///
199    /// The `LEAN_CTX_STRUCTURE_FIRST` env var wins over the config field, and
200    /// accepts the usual truthy/falsy spellings so a harness can flip it per run
201    /// (`LEAN_CTX_STRUCTURE_FIRST=0` forces it off even if config enables it).
202    pub fn structure_first_effective(&self) -> bool {
203        match std::env::var("LEAN_CTX_STRUCTURE_FIRST") {
204            Ok(raw) => matches!(
205                raw.trim().to_lowercase().as_str(),
206                "1" | "true" | "yes" | "on"
207            ),
208            Err(_) => self.structure_first,
209        }
210    }
211
212    /// Effective turn budget (fresh token limit per response). 0 = unlimited.
213    pub fn turn_fresh_limit_effective(&self) -> usize {
214        std::env::var("LEAN_CTX_TURN_FRESH_LIMIT")
215            .ok()
216            .and_then(|v| v.trim().parse().ok())
217            .unwrap_or(self.turn_fresh_limit)
218    }
219
220    /// Effective turn budget for an explicitly verbatim response (#1582).
221    ///
222    /// 0 = unlimited. Never below `turn_fresh_limit_effective()`: raising the
223    /// ordinary budget above the verbatim one must not make `raw=true` deliver
224    /// *less* than a compressed read of the same file. An unlimited ordinary
225    /// budget stays unlimited here.
226    pub fn turn_fresh_limit_verbatim_effective(&self) -> usize {
227        let ordinary = self.turn_fresh_limit_effective();
228        if ordinary == 0 {
229            return 0;
230        }
231        let verbatim = std::env::var("LEAN_CTX_TURN_FRESH_LIMIT_VERBATIM")
232            .ok()
233            .and_then(|v| v.trim().parse().ok())
234            .unwrap_or(self.turn_fresh_limit_verbatim);
235        resolve_verbatim_limit(ordinary, verbatim)
236    }
237
238    /// Whether progressive disclosure is active. Default true. The env var
239    /// `LEAN_CTX_PROGRESSIVE_DISCLOSURE` overrides the config field.
240    pub fn progressive_disclosure_effective(&self) -> bool {
241        match std::env::var("LEAN_CTX_PROGRESSIVE_DISCLOSURE") {
242            Ok(raw) => matches!(
243                raw.trim().to_lowercase().as_str(),
244                "1" | "true" | "yes" | "on"
245            ),
246            Err(_) => self.progressive_disclosure,
247        }
248    }
249
250    /// Returns `true` if minimal overhead should be enabled for this MCP client.
251    ///
252    /// This is a superset of `minimal_overhead_effective()`:
253    /// - `LEAN_CTX_OVERHEAD_MODE=minimal` forces minimal overhead
254    /// - `LEAN_CTX_OVERHEAD_MODE=full` disables client/model heuristics (still honors LEAN_CTX_MINIMAL / config)
255    /// - In auto mode (default), certain low-context clients/models are treated as minimal to prevent
256    ///   large metadata blocks from destabilizing smaller context windows (e.g. Hermes + MiniMax).
257    pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
258        if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
259            match raw.trim().to_lowercase().as_str() {
260                "minimal" => return true,
261                "full" => return self.minimal_overhead_effective(),
262                _ => {}
263            }
264        }
265
266        if self.minimal_overhead_effective() {
267            return true;
268        }
269
270        let client_lower = client_name.trim().to_lowercase();
271        if !client_lower.is_empty() {
272            if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
273                for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
274                    if !needle.is_empty() && client_lower.contains(&needle) {
275                        return true;
276                    }
277                }
278            } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
279                return true;
280            }
281        }
282
283        let model = std::env::var("LEAN_CTX_MODEL")
284            .or_else(|_| std::env::var("LCTX_MODEL"))
285            .unwrap_or_default();
286        let model = model.trim().to_lowercase();
287        if !model.is_empty() {
288            let m = model.replace(['_', ' '], "-");
289            if m.contains("minimax")
290                || m.contains("mini-max")
291                || m.contains("m2.7")
292                || m.contains("m2-7")
293            {
294                return true;
295            }
296        }
297
298        false
299    }
300
301    /// Returns `true` if shell hook injection is disabled via env var or config.
302    pub fn shell_hook_disabled_effective(&self) -> bool {
303        std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
304    }
305
306    /// Returns the effective shell activation mode (env var > config > default).
307    pub fn shell_activation_effective(&self) -> ShellActivation {
308        ShellActivation::effective(self)
309    }
310
311    /// Returns `true` if `ctx_shell` may accept shell file-write redirects.
312    /// `LEAN_CTX_SHELL_ALLOW_WRITES` (`1`/`true`/`yes`/`on`) overrides
313    /// `config.toml`. The real command gating still applies either way.
314    pub fn shell_allow_writes_effective(&self) -> bool {
315        match std::env::var("LEAN_CTX_SHELL_ALLOW_WRITES") {
316            Ok(raw) => matches!(
317                raw.trim().to_ascii_lowercase().as_str(),
318                "1" | "true" | "yes" | "on"
319            ),
320            Err(_) => self.shell_allow_writes,
321        }
322    }
323
324    /// Returns the effective paths where shell output capture may write.
325    /// Empty configuration intentionally falls back to OS temp directories.
326    pub fn shell_write_allow_paths_effective(&self) -> Vec<String> {
327        if self.write_allow_paths.is_empty() {
328            default_shell_write_allow_paths()
329        } else {
330            self.write_allow_paths.clone()
331        }
332    }
333
334    /// #814: returns `true` if `ctx_shell` may accept inline interpreter scripts
335    /// (`python3 -c "..."`, `node -e "..."`, etc.).
336    /// `LEAN_CTX_SHELL_ALLOW_INLINE_SCRIPTS` (`1`/`true`/`yes`/`on`) overrides
337    /// `config.toml`. The real command gating (allowlist) still applies.
338    pub fn shell_allow_inline_scripts_effective(&self) -> bool {
339        match std::env::var("LEAN_CTX_SHELL_ALLOW_INLINE_SCRIPTS") {
340            Ok(raw) => matches!(
341                raw.trim().to_ascii_lowercase().as_str(),
342                "1" | "true" | "yes" | "on"
343            ),
344            Err(_) => self.shell_allow_inline_scripts,
345        }
346    }
347
348    /// Returns `true` if the daily update check is disabled via env var or config.
349    pub fn update_check_disabled_effective(&self) -> bool {
350        std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
351    }
352
353    pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
354        let mut policy = self.memory.clone();
355        policy.apply_env_overrides();
356
357        let budget = self.max_disk_mb_effective();
358        if budget > 0 {
359            let scale_factor = (budget as f64 / 500.0).clamp(0.5, 10.0);
360            let default_policy = MemoryPolicy::default();
361            // Facts are not scaled: knowledge has no fact limit.
362            if policy.knowledge.max_patterns == default_policy.knowledge.max_patterns {
363                policy.knowledge.max_patterns = (50.0 * scale_factor) as usize;
364            }
365        }
366
367        policy.validate()?;
368        Ok(policy)
369    }
370
371    /// Returns the effective set of default tool categories.
372    /// Priority: LCTX_DEFAULT_CATEGORIES env var > config.toml > hardcoded default.
373    pub fn default_tool_categories_effective(&self) -> Vec<String> {
374        if let Ok(val) = std::env::var("LCTX_DEFAULT_CATEGORIES") {
375            return val
376                .split(',')
377                .map(|s| s.trim().to_lowercase())
378                .filter(|s| !s.is_empty())
379                .collect();
380        }
381        if !self.default_tool_categories.is_empty() {
382            return self
383                .default_tool_categories
384                .iter()
385                .map(|s| s.to_lowercase())
386                .collect();
387        }
388        vec!["core".to_string()]
389    }
390
391    /// Returns the effective tool profile.
392    /// Priority: LEAN_CTX_TOOL_PROFILE env > config tool_profile > config
393    /// tools_enabled > active persona's tool surface > power.
394    ///
395    /// Explicit settings win (backward compatible); when none are set, the
396    /// active persona supplies the tool surface (the `coding` default resolves
397    /// to `power`, so existing installs are unaffected).
398    pub fn tool_profile_effective(&self) -> super::tool_profiles::ToolProfile {
399        super::persona::Persona::resolve(self).effective_tool_profile(self)
400    }
401
402    /// The `[sensitivity]` config with the active persona's floor folded in
403    /// (persona-spec-v1). Enforcement chokepoints use this instead of the raw
404    /// field so a persona like `lead-gen` (`sensitivity_floor = "confidential"`)
405    /// protects PII out of the box. The `coding` default (`public`) passes the
406    /// config through unchanged.
407    #[must_use]
408    pub fn sensitivity_effective(&self) -> crate::core::sensitivity::SensitivityConfig {
409        self.sensitivity
410            .clone()
411            .with_persona_floor(super::persona::Persona::resolve(self).sensitivity_floor)
412    }
413
414    /// Returns `true` if all automatic read-mode degradation is disabled.
415    /// Checks LCTX_NO_DEGRADE env var first, then config.toml field.
416    pub fn no_degrade_effective(&self) -> bool {
417        if let Ok(val) = std::env::var("LCTX_NO_DEGRADE") {
418            return val == "1" || val.eq_ignore_ascii_case("true");
419        }
420        self.no_degrade
421    }
422
423    /// Returns `true` if explicit `full`/`lines:N-M` re-reads of
424    /// cached-but-changed files should be served as deltas (`mode=diff`)
425    /// instead of re-emitting full content.
426    ///
427    /// Checks the `LCTX_DELTA_EXPLICIT` env var first, then the config.toml
428    /// field. Unlike a presence-only knob, an explicit `0`/`false` in the env
429    /// forces the feature OFF even when the config field is `true`, so the env
430    /// can fully override config in both directions.
431    pub fn delta_explicit_effective(&self) -> bool {
432        if let Ok(val) = std::env::var("LCTX_DELTA_EXPLICIT") {
433            return val == "1" || val.eq_ignore_ascii_case("true");
434        }
435        self.delta_explicit
436    }
437
438    /// Effective max_disk_mb from env or config.
439    pub fn max_disk_mb_effective(&self) -> u64 {
440        std::env::var("LEAN_CTX_MAX_DISK_MB")
441            .ok()
442            .and_then(|v| v.parse().ok())
443            .unwrap_or(self.max_disk_mb)
444    }
445
446    /// Effective per-process RSS target of a stdio MCP server (MB) from env or config.
447    pub fn mcp_max_rss_mb_effective(&self) -> u64 {
448        std::env::var("LEAN_CTX_MCP_MAX_RSS_MB")
449            .ok()
450            .and_then(|v| v.trim().parse().ok())
451            .unwrap_or(self.mcp_max_rss_mb)
452    }
453
454    /// Effective process RSS target of the background daemon (MB) from env or config.
455    pub fn daemon_max_rss_mb_effective(&self) -> u64 {
456        std::env::var("LEAN_CTX_DAEMON_MAX_RSS_MB")
457            .ok()
458            .and_then(|v| v.trim().parse().ok())
459            .unwrap_or(self.daemon_max_rss_mb)
460    }
461
462    /// Effective idle-exit window of a stdio MCP server (minutes, 0 = never).
463    pub fn mcp_idle_exit_minutes_effective(&self) -> u64 {
464        std::env::var("LEAN_CTX_MCP_IDLE_EXIT_MINUTES")
465            .ok()
466            .and_then(|v| v.trim().parse().ok())
467            .unwrap_or(self.mcp_idle_exit_minutes)
468    }
469
470    /// Effective max_staleness_days from env or config.
471    pub fn max_staleness_days_effective(&self) -> u32 {
472        std::env::var("LEAN_CTX_MAX_STALENESS_DAYS")
473            .ok()
474            .and_then(|v| v.parse().ok())
475            .unwrap_or(self.max_staleness_days)
476    }
477
478    /// Effective retention window for explicit session cleanup, independent of
479    /// the archive staleness policy.
480    pub fn session_retention_days_effective(&self) -> u32 {
481        std::env::var("LEAN_CTX_SESSION_RETENTION_DAYS")
482            .ok()
483            .and_then(|value| value.parse().ok())
484            .unwrap_or(self.session_retention_days)
485    }
486
487    /// Effective fixed-context budget (tokens) from env or config (#964). `0`
488    /// (env or config) disables the warning; otherwise the per-session footprint
489    /// is checked against this in `doctor overhead` and `gain`.
490    pub fn context_budget_tokens_effective(&self) -> usize {
491        std::env::var("LEAN_CTX_CONTEXT_BUDGET_TOKENS")
492            .ok()
493            .and_then(|v| v.parse().ok())
494            .unwrap_or(self.context.budget_tokens)
495    }
496
497    /// Whether later tool calls may receive matching CCR context.
498    pub fn proactive_expansion_effective(&self) -> bool {
499        self.context.proactive_expansion
500    }
501
502    /// Per-response token budget for proactive CCR expansion.
503    pub fn proactive_expansion_budget_tokens_effective(&self) -> usize {
504        self.context.proactive_expansion_budget_tokens
505    }
506
507    /// Normalized BM25 score threshold for proactive CCR expansion.
508    pub fn proactive_expansion_threshold_effective(&self) -> f64 {
509        self.context.proactive_expansion_threshold
510    }
511
512    /// Maximum archive age considered for proactive expansion.
513    pub fn proactive_expansion_max_age_secs_effective(&self) -> u64 {
514        self.context.proactive_expansion_max_age_secs
515    }
516
517    /// Archive max_disk_mb derived from simplified max_disk_mb if the detail
518    /// value is still at its default. Explicit overrides take priority.
519    pub fn archive_max_disk_mb_effective(&self) -> u64 {
520        let budget = self.max_disk_mb_effective();
521        if budget > 0 && self.archive.max_disk_mb == ArchiveConfig::default().max_disk_mb {
522            budget * 25 / 100
523        } else {
524            self.archive.max_disk_mb
525        }
526    }
527
528    /// Archive max_age_hours derived from max_staleness_days if the detail
529    /// value is still at its default. Explicit overrides take priority.
530    pub fn archive_max_age_hours_effective(&self) -> u64 {
531        let staleness = self.max_staleness_days_effective();
532        if staleness > 0 && self.archive.max_age_hours == ArchiveConfig::default().max_age_hours {
533            staleness as u64 * 24
534        } else {
535            self.archive.max_age_hours
536        }
537    }
538
539    /// Effective on-disk ceiling (MB) of the Context Gateway evidence store
540    /// (`<data>/gateway`: receipts, trace sidecars, observations, egress
541    /// records). `0` means no ceiling. Retention keeps the store below it
542    /// (`receipt_store::retention`); a new receipt is refused only while the
543    /// store stays full (`receipt_store::capacity`, G09).
544    ///
545    /// Priority: `LEAN_CTX_GATEWAY_STORE_MAX_MB` › `max_disk_mb` budget (10%) ›
546    /// `receipt_store::capacity::DEFAULT_STORE_MB`.
547    /// Deliberately not a `[context_gateway]` key: that table is hashed into
548    /// every receipt's policy digest, and a storage ceiling decides no
549    /// admission.
550    pub fn gateway_store_max_mb_effective(&self) -> u64 {
551        if let Some(explicit) = std::env::var("LEAN_CTX_GATEWAY_STORE_MAX_MB")
552            .ok()
553            .and_then(|value| value.trim().parse().ok())
554        {
555            return explicit;
556        }
557        self.gateway_store_configured_mb()
558    }
559
560    /// [`Self::gateway_store_max_mb_effective`] without the environment
561    /// override: what this configuration gives. Noted for the store on every
562    /// load, because the store's writers must not load a configuration.
563    pub(crate) fn gateway_store_configured_mb(&self) -> u64 {
564        let budget = self.max_disk_mb_effective();
565        if budget > 0 {
566            return (budget * 10 / 100).max(1);
567        }
568        crate::core::context_admission::receipt_store::capacity::DEFAULT_STORE_MB
569    }
570
571    /// Effective on-disk ceiling (MB) for the persisted BM25 index. Single source
572    /// of truth for `save`/`load`, `cache prune`, and the doctor health check.
573    ///
574    /// Priority: explicit `bm25_max_cache_mb` › `max_disk_mb` budget (10%) ›
575    /// generous default ([`DEFAULT_BM25_PERSIST_MB`]). The default is decoupled
576    /// from the RAM profile so large repos persist instead of rebuilding forever
577    /// (issue #249).
578    pub fn bm25_max_cache_mb_effective(&self) -> u64 {
579        if self.bm25_max_cache_mb != serde_defaults::default_bm25_max_cache_mb() {
580            return self.bm25_max_cache_mb;
581        }
582        let budget = self.max_disk_mb_effective();
583        if budget > 0 {
584            return budget * 10 / 100;
585        }
586        DEFAULT_BM25_PERSIST_MB
587    }
588}
589
590impl Config {
591    /// Safely mutate and persist the GLOBAL config. Reads the global file only
592    /// (no project-local merge), applies `f`, then writes minimally. Refuses
593    /// (returns `Err`) when the file exists but is unparseable, so a typo can
594    /// never clobber a customized config (#443). Returns the saved `Config`.
595    ///
596    /// This is the canonical persistence entry point: prefer it over
597    /// `Config::load()` followed by `save()`, which leaks project-local
598    /// overrides into the global file.
599    pub fn update_global<F>(f: F) -> std::result::Result<Self, super::error::LeanCtxError>
600    where
601        F: FnOnce(&mut Self),
602    {
603        Self::try_update_global(|config| {
604            f(config);
605            Ok(())
606        })
607    }
608
609    /// Fallible mutation using the same preserving writer; refusal never saves.
610    pub fn try_update_global<F>(f: F) -> std::result::Result<Self, super::error::LeanCtxError>
611    where
612        F: FnOnce(&mut Self) -> std::result::Result<(), super::error::LeanCtxError>,
613    {
614        let path = Self::path().ok_or_else(|| {
615            super::error::LeanCtxError::Config("cannot determine home directory".into())
616        })?;
617        Self::try_update_global_at(&path, f)
618    }
619
620    /// Path-parameterized core of [`Config::update_global`], for tests.
621    #[cfg(test)]
622    pub(crate) fn update_global_at<F>(
623        path: &Path,
624        f: F,
625    ) -> std::result::Result<Self, super::error::LeanCtxError>
626    where
627        F: FnOnce(&mut Self),
628    {
629        Self::try_update_global_at(path, |config| {
630            f(config);
631            Ok(())
632        })
633    }
634
635    /// Runs the whole read-modify-write under one hold of the config write
636    /// lock (LR-TEL-01).
637    ///
638    /// This function always loaded the global file straight from disk
639    /// (`try_load_global_from` — no `Config::CACHE`, no project-local merge)
640    /// and still does; that part is unchanged. What changed is that the load
641    /// and the save now happen inside **one** critical section. Previously a
642    /// concurrent writer could commit in the window between them and be
643    /// overwritten by this call's save.
644    ///
645    /// `f` must be pure in-memory mutation. It runs while an OS file lock is
646    /// held, so no network, no I/O, and no nested config write belongs inside
647    /// it — the lock is not reentrant and the wait is bounded.
648    fn try_update_global_at<F>(
649        path: &Path,
650        f: F,
651    ) -> std::result::Result<Self, super::error::LeanCtxError>
652    where
653        F: FnOnce(&mut Self) -> std::result::Result<(), super::error::LeanCtxError>,
654    {
655        let guard = crate::config_io::ConfigWriteGuard::acquire(path)
656            .map_err(super::error::LeanCtxError::Config)?;
657        let mut cfg = Self::try_load_global_from(guard.path())?;
658        f(&mut cfg)?;
659        cfg.save_to_locked(&guard)?;
660        Ok(cfg)
661    }
662
663    /// Persists the current config to the global config file.
664    ///
665    /// Preserves user comments, formatting, and unknown keys, keeps the file
666    /// minimal (defaults that were never set on disk stay implicit), and writes
667    /// atomically with a `.bak` backup so customizations are always recoverable.
668    pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
669        let path = Self::path().ok_or_else(|| {
670            super::error::LeanCtxError::Config("cannot determine home directory".into())
671        })?;
672        self.save_to(&path)
673    }
674
675    /// Path-parameterized core of [`Config::save`] (unit-testable).
676    ///
677    /// Takes the guard itself, then writes through the same guarded path as
678    /// [`Self::save_to_locked`], so a standalone save and a save inside an
679    /// update land on the identical resolved file.
680    pub(super) fn save_to(
681        &self,
682        path: &Path,
683    ) -> std::result::Result<(), super::error::LeanCtxError> {
684        let guard = crate::config_io::ConfigWriteGuard::acquire(path)
685            .map_err(super::error::LeanCtxError::Config)?;
686        self.save_to_locked(&guard)
687    }
688
689    /// [`Self::save_to`] for callers already inside a critical section.
690    ///
691    /// Takes the guard by reference rather than a path: the destination is the
692    /// guard's bound path, so a guarded save cannot be aimed at a file the
693    /// guard is not excluding, and the non-reentrant lock is never acquired
694    /// twice on one call path.
695    pub(super) fn save_to_locked(
696        &self,
697        guard: &crate::config_io::ConfigWriteGuard,
698    ) -> std::result::Result<(), super::error::LeanCtxError> {
699        // A loaded config carries the managed overlay; the user's file keeps
700        // the user's own values for the managed members.
701        let unmanaged;
702        let this = match crate::core::config::managed::current() {
703            Some(managed) if managed.config.is_ok() => {
704                let on_disk = Self::try_load_global_from(guard.path())
705                    .map(|config| config.hive)
706                    .unwrap_or_default();
707                let mut copy = self.clone();
708                managed.keep_user_values(&mut copy.hive, &on_disk);
709                unmanaged = copy;
710                &unmanaged
711            }
712            _ => self,
713        };
714        let (content, defaults) = this.render_for_save(guard.path())?;
715        guard
716            .write_toml_preserving_minimal(&content, &defaults)
717            .map_err(super::error::LeanCtxError::Config)?;
718        Ok(())
719    }
720
721    /// Renders `(content, defaults)` for the minimal-preserving writer and
722    /// ensures the parent directory exists. Shared by both save entry points so
723    /// they stay byte-identical in what they emit.
724    fn render_for_save(
725        &self,
726        path: &Path,
727    ) -> std::result::Result<(String, String), super::error::LeanCtxError> {
728        if let Some(parent) = path.parent() {
729            std::fs::create_dir_all(parent)?;
730        }
731        let content = toml::to_string_pretty(self)
732            .map_err(|e| super::error::LeanCtxError::Config(e.to_string().into()))?;
733        // Baseline = what loading an empty config yields. This honors serde's
734        // field-level `#[serde(default)]` (which can diverge from the struct's
735        // `Default` impl), so minimal mode skips exactly the keys that a fresh
736        // load would produce — no spurious lines on save.
737        let baseline = toml::from_str::<Self>("").unwrap_or_else(|_| Self::default());
738        let defaults = toml::to_string_pretty(&baseline)
739            .map_err(|e| super::error::LeanCtxError::Config(e.to_string().into()))?;
740        Ok((content, defaults))
741    }
742
743    /// Formats the current config as a human-readable string with file paths.
744    pub fn show(&self) -> String {
745        let global_path = Self::path().map_or_else(
746            || "~/.lean-ctx/config.toml".to_string(),
747            |p| p.to_string_lossy().to_string(),
748        );
749        let content = toml::to_string_pretty(self).unwrap_or_default();
750        let mut out = format!("Global config: {global_path}\n\n{content}");
751
752        if let Some(root) = Self::find_project_root() {
753            let local = Self::local_path(&root);
754            if local.exists() {
755                out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
756            } else {
757                out.push_str(&format!(
758                    "\n\nLocal config: not found (create {} to override per-project)\n",
759                    local.display()
760                ));
761            }
762        }
763        out
764    }
765}
766
767/// Resolve the verbatim turn budget from the two configured limits (#1582).
768///
769/// Pure so the precedence rules are testable without touching process env:
770/// `0` means unlimited, and the verbatim budget is never smaller than the
771/// ordinary one.
772fn resolve_verbatim_limit(ordinary: usize, verbatim: usize) -> usize {
773    if verbatim == 0 {
774        return 0;
775    }
776    verbatim.max(ordinary)
777}
778
779#[cfg(test)]
780mod verbatim_budget_tests {
781    use super::resolve_verbatim_limit;
782
783    #[test]
784    fn verbatim_budget_is_larger_than_the_ordinary_backstop() {
785        assert_eq!(resolve_verbatim_limit(4096, 32_768), 32_768);
786    }
787
788    #[test]
789    fn zero_means_unlimited_verbatim_reads() {
790        assert_eq!(resolve_verbatim_limit(4096, 0), 0);
791    }
792
793    #[test]
794    fn a_raised_ordinary_budget_is_never_undercut() {
795        // Someone who sets turn_fresh_limit = 64k must not get *less* content
796        // back from an explicit raw read than from a compressed one.
797        assert_eq!(resolve_verbatim_limit(65_536, 32_768), 65_536);
798    }
799}