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. The store refuses new receipts at the
542 /// ceiling and deletes nothing (`receipt_store::capacity`, G09).
543 ///
544 /// Priority: `LEAN_CTX_GATEWAY_STORE_MAX_MB` › `max_disk_mb` budget (10%) ›
545 /// `receipt_store::capacity::DEFAULT_STORE_MB`.
546 /// Deliberately not a `[context_gateway]` key: that table is hashed into
547 /// every receipt's policy digest, and a storage ceiling decides no
548 /// admission.
549 pub fn gateway_store_max_mb_effective(&self) -> u64 {
550 if let Some(explicit) = std::env::var("LEAN_CTX_GATEWAY_STORE_MAX_MB")
551 .ok()
552 .and_then(|value| value.trim().parse().ok())
553 {
554 return explicit;
555 }
556 self.gateway_store_configured_mb()
557 }
558
559 /// [`Self::gateway_store_max_mb_effective`] without the environment
560 /// override: what this configuration gives. Noted for the store on every
561 /// load, because the store's writers must not load a configuration.
562 pub(crate) fn gateway_store_configured_mb(&self) -> u64 {
563 let budget = self.max_disk_mb_effective();
564 if budget > 0 {
565 return (budget * 10 / 100).max(1);
566 }
567 crate::core::context_admission::receipt_store::capacity::DEFAULT_STORE_MB
568 }
569
570 /// Effective on-disk ceiling (MB) for the persisted BM25 index. Single source
571 /// of truth for `save`/`load`, `cache prune`, and the doctor health check.
572 ///
573 /// Priority: explicit `bm25_max_cache_mb` › `max_disk_mb` budget (10%) ›
574 /// generous default ([`DEFAULT_BM25_PERSIST_MB`]). The default is decoupled
575 /// from the RAM profile so large repos persist instead of rebuilding forever
576 /// (issue #249).
577 pub fn bm25_max_cache_mb_effective(&self) -> u64 {
578 if self.bm25_max_cache_mb != serde_defaults::default_bm25_max_cache_mb() {
579 return self.bm25_max_cache_mb;
580 }
581 let budget = self.max_disk_mb_effective();
582 if budget > 0 {
583 return budget * 10 / 100;
584 }
585 DEFAULT_BM25_PERSIST_MB
586 }
587}
588
589impl Config {
590 /// Safely mutate and persist the GLOBAL config. Reads the global file only
591 /// (no project-local merge), applies `f`, then writes minimally. Refuses
592 /// (returns `Err`) when the file exists but is unparseable, so a typo can
593 /// never clobber a customized config (#443). Returns the saved `Config`.
594 ///
595 /// This is the canonical persistence entry point: prefer it over
596 /// `Config::load()` followed by `save()`, which leaks project-local
597 /// overrides into the global file.
598 pub fn update_global<F>(f: F) -> std::result::Result<Self, super::error::LeanCtxError>
599 where
600 F: FnOnce(&mut Self),
601 {
602 Self::try_update_global(|config| {
603 f(config);
604 Ok(())
605 })
606 }
607
608 /// Fallible mutation using the same preserving writer; refusal never saves.
609 pub fn try_update_global<F>(f: F) -> std::result::Result<Self, super::error::LeanCtxError>
610 where
611 F: FnOnce(&mut Self) -> std::result::Result<(), super::error::LeanCtxError>,
612 {
613 let path = Self::path().ok_or_else(|| {
614 super::error::LeanCtxError::Config("cannot determine home directory".into())
615 })?;
616 Self::try_update_global_at(&path, f)
617 }
618
619 /// Path-parameterized core of [`Config::update_global`], for tests.
620 #[cfg(test)]
621 pub(crate) fn update_global_at<F>(
622 path: &Path,
623 f: F,
624 ) -> std::result::Result<Self, super::error::LeanCtxError>
625 where
626 F: FnOnce(&mut Self),
627 {
628 Self::try_update_global_at(path, |config| {
629 f(config);
630 Ok(())
631 })
632 }
633
634 /// Runs the whole read-modify-write under one hold of the config write
635 /// lock (LR-TEL-01).
636 ///
637 /// This function always loaded the global file straight from disk
638 /// (`try_load_global_from` — no `Config::CACHE`, no project-local merge)
639 /// and still does; that part is unchanged. What changed is that the load
640 /// and the save now happen inside **one** critical section. Previously a
641 /// concurrent writer could commit in the window between them and be
642 /// overwritten by this call's save.
643 ///
644 /// `f` must be pure in-memory mutation. It runs while an OS file lock is
645 /// held, so no network, no I/O, and no nested config write belongs inside
646 /// it — the lock is not reentrant and the wait is bounded.
647 fn try_update_global_at<F>(
648 path: &Path,
649 f: F,
650 ) -> std::result::Result<Self, super::error::LeanCtxError>
651 where
652 F: FnOnce(&mut Self) -> std::result::Result<(), super::error::LeanCtxError>,
653 {
654 let guard = crate::config_io::ConfigWriteGuard::acquire(path)
655 .map_err(super::error::LeanCtxError::Config)?;
656 let mut cfg = Self::try_load_global_from(guard.path())?;
657 f(&mut cfg)?;
658 cfg.save_to_locked(&guard)?;
659 Ok(cfg)
660 }
661
662 /// Persists the current config to the global config file.
663 ///
664 /// Preserves user comments, formatting, and unknown keys, keeps the file
665 /// minimal (defaults that were never set on disk stay implicit), and writes
666 /// atomically with a `.bak` backup so customizations are always recoverable.
667 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
668 let path = Self::path().ok_or_else(|| {
669 super::error::LeanCtxError::Config("cannot determine home directory".into())
670 })?;
671 self.save_to(&path)
672 }
673
674 /// Path-parameterized core of [`Config::save`] (unit-testable).
675 ///
676 /// Takes the guard itself, then writes through the same guarded path as
677 /// [`Self::save_to_locked`], so a standalone save and a save inside an
678 /// update land on the identical resolved file.
679 pub(super) fn save_to(
680 &self,
681 path: &Path,
682 ) -> std::result::Result<(), super::error::LeanCtxError> {
683 let guard = crate::config_io::ConfigWriteGuard::acquire(path)
684 .map_err(super::error::LeanCtxError::Config)?;
685 self.save_to_locked(&guard)
686 }
687
688 /// [`Self::save_to`] for callers already inside a critical section.
689 ///
690 /// Takes the guard by reference rather than a path: the destination is the
691 /// guard's bound path, so a guarded save cannot be aimed at a file the
692 /// guard is not excluding, and the non-reentrant lock is never acquired
693 /// twice on one call path.
694 pub(super) fn save_to_locked(
695 &self,
696 guard: &crate::config_io::ConfigWriteGuard,
697 ) -> std::result::Result<(), super::error::LeanCtxError> {
698 let (content, defaults) = self.render_for_save(guard.path())?;
699 guard
700 .write_toml_preserving_minimal(&content, &defaults)
701 .map_err(super::error::LeanCtxError::Config)?;
702 Ok(())
703 }
704
705 /// Renders `(content, defaults)` for the minimal-preserving writer and
706 /// ensures the parent directory exists. Shared by both save entry points so
707 /// they stay byte-identical in what they emit.
708 fn render_for_save(
709 &self,
710 path: &Path,
711 ) -> std::result::Result<(String, String), super::error::LeanCtxError> {
712 if let Some(parent) = path.parent() {
713 std::fs::create_dir_all(parent)?;
714 }
715 let content = toml::to_string_pretty(self)
716 .map_err(|e| super::error::LeanCtxError::Config(e.to_string().into()))?;
717 // Baseline = what loading an empty config yields. This honors serde's
718 // field-level `#[serde(default)]` (which can diverge from the struct's
719 // `Default` impl), so minimal mode skips exactly the keys that a fresh
720 // load would produce — no spurious lines on save.
721 let baseline = toml::from_str::<Self>("").unwrap_or_else(|_| Self::default());
722 let defaults = toml::to_string_pretty(&baseline)
723 .map_err(|e| super::error::LeanCtxError::Config(e.to_string().into()))?;
724 Ok((content, defaults))
725 }
726
727 /// Formats the current config as a human-readable string with file paths.
728 pub fn show(&self) -> String {
729 let global_path = Self::path().map_or_else(
730 || "~/.lean-ctx/config.toml".to_string(),
731 |p| p.to_string_lossy().to_string(),
732 );
733 let content = toml::to_string_pretty(self).unwrap_or_default();
734 let mut out = format!("Global config: {global_path}\n\n{content}");
735
736 if let Some(root) = Self::find_project_root() {
737 let local = Self::local_path(&root);
738 if local.exists() {
739 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
740 } else {
741 out.push_str(&format!(
742 "\n\nLocal config: not found (create {} to override per-project)\n",
743 local.display()
744 ));
745 }
746 }
747 out
748 }
749}
750
751/// Resolve the verbatim turn budget from the two configured limits (#1582).
752///
753/// Pure so the precedence rules are testable without touching process env:
754/// `0` means unlimited, and the verbatim budget is never smaller than the
755/// ordinary one.
756fn resolve_verbatim_limit(ordinary: usize, verbatim: usize) -> usize {
757 if verbatim == 0 {
758 return 0;
759 }
760 verbatim.max(ordinary)
761}
762
763#[cfg(test)]
764mod verbatim_budget_tests {
765 use super::resolve_verbatim_limit;
766
767 #[test]
768 fn verbatim_budget_is_larger_than_the_ordinary_backstop() {
769 assert_eq!(resolve_verbatim_limit(4096, 32_768), 32_768);
770 }
771
772 #[test]
773 fn zero_means_unlimited_verbatim_reads() {
774 assert_eq!(resolve_verbatim_limit(4096, 0), 0);
775 }
776
777 #[test]
778 fn a_raised_ordinary_budget_is_never_undercut() {
779 // Someone who sets turn_fresh_limit = 64k must not get *less* content
780 // back from an explicit raw read than from a compressed one.
781 assert_eq!(resolve_verbatim_limit(65_536, 32_768), 65_536);
782 }
783}