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}