Skip to main content

lean_ctx/core/config/
mod.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4use std::sync::Mutex;
5use std::time::SystemTime;
6
7use super::memory_policy::MemoryPolicy;
8
9mod defaults_allowlist;
10mod enums;
11mod memory;
12mod proxy;
13pub mod schema;
14mod sections;
15mod serde_defaults;
16pub mod setter;
17mod shell_activation;
18pub use sections::*;
19#[cfg(test)]
20mod tests;
21
22pub(crate) use defaults_allowlist::default_shell_allowlist;
23pub use enums::{
24    CompressionLevel, OutputDensity, ResponseVerbosity, RulesScope, TeeMode, TerseAgent,
25};
26pub use memory::{MemoryCleanup, MemoryGuardConfig, MemoryProfile, SavingsFooter};
27pub use proxy::{is_local_proxy_url, normalize_url, normalize_url_opt, ProxyConfig, ProxyProvider};
28pub use shell_activation::ShellActivation;
29
30/// Default BM25 cache cap from config (also used by `bm25_index` heuristics).
31pub fn default_bm25_max_cache_mb() -> u64 {
32    serde_defaults::default_bm25_max_cache_mb()
33}
34
35/// Effective on-disk ceiling (MB) for the persisted BM25 index when nothing is
36/// explicitly configured (no `bm25_max_cache_mb`, no `max_disk_mb` budget).
37///
38/// Deliberately decoupled from the RAM `MemoryProfile` (64/128/512 MB): this is
39/// a *disk* file, and tying it to the profile silently refused persistence on
40/// large repos under Low/Balanced, forcing a cold rebuild on every call (the
41/// perpetual "index warming" of issue #249). 512 MB compressed covers
42/// essentially every real repo; RAM pressure is governed separately by the
43/// eviction orchestrator (which measures real heap).
44pub const DEFAULT_BM25_PERSIST_MB: u64 = 512;
45
46// Compile-time regression guard (#249): the default disk ceiling must stay well
47// above the old RAM-profile caps (64/128 MB) that starved large repos.
48const _: () = assert!(DEFAULT_BM25_PERSIST_MB >= 512);
49
50/// Global lean-ctx configuration loaded from `config.toml`, merged with project-local overrides.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[serde(default)]
53pub struct Config {
54    pub ultra_compact: bool,
55    #[serde(default, deserialize_with = "serde_defaults::deserialize_tee_mode")]
56    pub tee_mode: TeeMode,
57    #[serde(default)]
58    pub output_density: OutputDensity,
59    pub checkpoint_interval: u32,
60    pub excluded_commands: Vec<String>,
61    pub passthrough_urls: Vec<String>,
62    pub custom_aliases: Vec<AliasEntry>,
63    /// Output formats that are already compact/token-oriented and must be
64    /// preserved verbatim instead of being recompressed (#342). Matched against
65    /// the *output shape* (not the command name), so any tool emitting the
66    /// format is covered without enumerating commands in `excluded_commands`.
67    /// Default: `["toon"]`. Set to `[]` to disable and always recompress.
68    #[serde(default = "serde_defaults::default_preserve_compact_formats")]
69    pub preserve_compact_formats: Vec<String>,
70    /// Commands taking longer than this threshold (ms) are recorded in the slow log.
71    /// Set to 0 to disable slow logging.
72    pub slow_command_threshold_ms: u64,
73    #[serde(default = "serde_defaults::default_theme")]
74    pub theme: String,
75    #[serde(default)]
76    pub cloud: CloudConfig,
77    #[serde(default)]
78    pub gain: GainConfig,
79    #[serde(default)]
80    pub autonomy: AutonomyConfig,
81    #[serde(default)]
82    pub providers: ProvidersConfig,
83    #[serde(default)]
84    pub proxy: ProxyConfig,
85    /// Whether the API proxy is enabled. Tri-state:
86    /// - None: undecided (fresh install, will prompt on interactive setup)
87    /// - Some(true): user opted in, proxy managed by lean-ctx
88    /// - Some(false): user opted out, never touch proxy or endpoints
89    #[serde(default)]
90    pub proxy_enabled: Option<bool>,
91    #[serde(default)]
92    pub proxy_port: Option<u16>,
93    /// Proxy reachability timeout in milliseconds. Default: 200.
94    /// Override via LEAN_CTX_PROXY_TIMEOUT_MS env var.
95    #[serde(default)]
96    pub proxy_timeout_ms: Option<u64>,
97    #[serde(default = "serde_defaults::default_buddy_enabled")]
98    pub buddy_enabled: bool,
99    #[serde(default = "serde_defaults::default_true")]
100    pub enable_wakeup_ctx: bool,
101    #[serde(default)]
102    pub redirect_exclude: Vec<String>,
103    /// Tools to exclude from the MCP tool list returned by list_tools.
104    /// Accepts exact tool names (e.g. `["ctx_graph", "ctx_agent"]`).
105    /// Empty by default — all tools listed, no behaviour change.
106    #[serde(default)]
107    pub disabled_tools: Vec<String>,
108    /// Tool categories to activate by default for dynamic-tool-capable clients.
109    /// Values: "core" (always on), "arch", "debug", "memory", "metrics", "session".
110    /// Example: `default_tool_categories = ["core", "arch", "memory"]`
111    /// Override via LCTX_DEFAULT_CATEGORIES env var (comma-separated).
112    /// Empty = lean-ctx default (core + session).
113    #[serde(default)]
114    pub default_tool_categories: Vec<String>,
115    /// Disable all automatic read-mode degradation (auto_degrade + context_gate pressure).
116    /// When true, lean-ctx never downgrades requested read modes regardless of pressure.
117    /// Override via LCTX_NO_DEGRADE=1 env var.
118    #[serde(default)]
119    pub no_degrade: bool,
120    /// Persistent profile name. Checked after LEAN_CTX_PROFILE env var.
121    /// Set via `lean-ctx config set profile passthrough` or editing config.toml.
122    #[serde(default)]
123    pub profile: Option<String>,
124    /// Tool visibility profile: "minimal" (6), "standard" (21), or "power" (all).
125    /// Override via LEAN_CTX_TOOL_PROFILE env var.
126    /// Existing installs default to "power" (backward compat).
127    #[serde(default)]
128    pub tool_profile: Option<String>,
129    /// Explicit list of enabled tool names (overrides tool_profile when non-empty).
130    /// Example: `tools_enabled = ["ctx_read", "ctx_shell", "ctx_search"]`
131    #[serde(default)]
132    pub tools_enabled: Vec<String>,
133    #[serde(default)]
134    pub loop_detection: LoopDetectionConfig,
135    /// Controls where lean-ctx installs agent rule files.
136    /// Values: "both" (default), "global" (home-dir only), "project" (repo-local only).
137    /// Override via LEAN_CTX_RULES_SCOPE env var.
138    #[serde(default)]
139    pub rules_scope: Option<String>,
140    /// Extra glob patterns to ignore in graph/overview/preload (repo-local).
141    /// Example: `["externals/**", "target/**", "temp/**"]`
142    #[serde(default)]
143    pub extra_ignore_patterns: Vec<String>,
144    /// Controls agent output verbosity via instructions injection.
145    /// Values: "off" (default), "lite", "full", "ultra".
146    /// Override via LEAN_CTX_TERSE_AGENT env var.
147    #[serde(default)]
148    pub terse_agent: TerseAgent,
149    /// Unified compression level (replaces separate terse_agent + output_density).
150    /// Values: "off" (default), "lite", "standard", "max".
151    /// Override via LEAN_CTX_COMPRESSION env var.
152    #[serde(default)]
153    pub compression_level: CompressionLevel,
154    /// Archive configuration for zero-loss compression.
155    #[serde(default)]
156    pub archive: ArchiveConfig,
157    /// Memory policy (knowledge/episodic/procedural/lifecycle budgets & thresholds).
158    #[serde(default)]
159    pub memory: MemoryPolicy,
160    /// Additional paths allowed by PathJail (absolute).
161    /// Useful for multi-project workspaces where the jail root is a parent directory.
162    /// Override via LEAN_CTX_ALLOW_PATH env var (path-list separator).
163    #[serde(default)]
164    pub allow_paths: Vec<String>,
165    /// Extra project roots for multi-root workspaces.
166    /// Tools like ctx_tree and ctx_search can scan across all roots in a single call.
167    /// These paths are automatically added to PathJail's allow-list.
168    /// Override via LEAN_CTX_EXTRA_ROOTS env var (path-list separator).
169    #[serde(default)]
170    pub extra_roots: Vec<String>,
171    /// Enable content-defined chunking (Rabin-Karp) for cache-optimal output ordering.
172    /// Stable chunks are emitted first to maximize prompt cache hits.
173    #[serde(default)]
174    pub content_defined_chunking: bool,
175    /// Skip session/knowledge/gotcha blocks in MCP instructions to minimize token overhead.
176    /// Override via LEAN_CTX_MINIMAL env var.
177    #[serde(default)]
178    pub minimal_overhead: bool,
179    /// Opt-in: substitute long identifiers with short α-codes (+ a `§MAP` table)
180    /// in `aggressive` reads for projects with >50 source files. Off by default —
181    /// the abbreviated form is confusing for editing/refactoring, where the agent
182    /// needs the real package and symbol names. Enable for max exploration savings.
183    #[serde(default)]
184    pub symbol_map_auto: bool,
185    /// Team server URL for opt-in savings roll-up.
186    /// Set via `lean-ctx config set team_url https://...` or `[team] url` in config.toml.
187    /// Override via LEAN_CTX_TEAM_URL env var.
188    #[serde(default)]
189    pub team_url: Option<String>,
190    /// Enable human-readable activity journal (~/.lean-ctx/journal.md).
191    #[serde(default)]
192    pub journal_enabled: bool,
193    /// Opt-in: auto-persist interesting findings as knowledge facts.
194    #[serde(default)]
195    pub auto_capture: bool,
196    /// Hybrid search weights (BM25/dense/candidates).
197    #[serde(default)]
198    pub search: crate::core::hybrid_search::HybridConfig,
199    /// Optional LLM enhancement (query expansion, contradiction explanation).
200    #[serde(default)]
201    pub llm: crate::core::llm_enhance::LlmConfig,
202    /// Semantic-embedding engine settings (which local ONNX model to use).
203    #[serde(default)]
204    pub embedding: EmbeddingConfig,
205    /// Disable shell hook injection (the _lc() function that wraps CLI commands).
206    /// Override via LEAN_CTX_NO_HOOK env var.
207    #[serde(default)]
208    pub shell_hook_disabled: bool,
209    /// Shadow mode: transparently intercepts native tool calls (Read/Grep/Shell)
210    /// via hooks, strengthens MCP instructions to MUST-level, and activates
211    /// immediate bypass hints on first native tool use. Enables "transparent
212    /// replacement" so agents use ctx_* without explicit opt-in.
213    #[serde(default)]
214    pub shadow_mode: bool,
215    /// Controls when the shell hook auto-activates aliases.
216    /// - `always`: (Default) Aliases active in every interactive shell.
217    /// - `agents-only`: Aliases only active when an AI agent env var is detected.
218    /// - `off`: Aliases never auto-activate (user must call `lean-ctx-on` manually).
219    ///
220    /// Override via `LEAN_CTX_SHELL_ACTIVATION` env var.
221    #[serde(default)]
222    pub shell_activation: ShellActivation,
223    /// Disable the daily version check against leanctx.com/version.txt.
224    /// Override via LEAN_CTX_NO_UPDATE_CHECK env var.
225    #[serde(default)]
226    pub update_check_disabled: bool,
227    #[serde(default)]
228    pub updates: UpdatesConfig,
229    /// Maximum BM25 cache file size in MB. Indexes exceeding this are quarantined on load
230    /// and refused on save. Override via LEAN_CTX_BM25_MAX_CACHE_MB env var.
231    #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
232    pub bm25_max_cache_mb: u64,
233    /// Maximum number of files scanned by the lightweight JSON graph index.
234    /// 0 = unlimited (default). Set >0 to cap for constrained systems.
235    #[serde(default = "serde_defaults::default_graph_index_max_files")]
236    pub graph_index_max_files: u64,
237    /// Controls RAM vs feature trade-off. Values: "low", "balanced" (default), "performance".
238    /// Override via LEAN_CTX_MEMORY_PROFILE env var.
239    #[serde(default)]
240    pub memory_profile: MemoryProfile,
241    /// Controls how aggressively memory is freed when idle.
242    /// Values: "aggressive" (default, 5 min TTL), "shared" (30 min TTL for multi-IDE use).
243    /// Override via LEAN_CTX_MEMORY_CLEANUP env var.
244    #[serde(default)]
245    pub memory_cleanup: MemoryCleanup,
246    /// Maximum percentage of system RAM that lean-ctx may use (default: 5).
247    /// Override via LEAN_CTX_MAX_RAM_PERCENT env var.
248    #[serde(default = "serde_defaults::default_max_ram_percent")]
249    pub max_ram_percent: u8,
250    /// Simplified disk budget (MB). When set and detail values are at defaults,
251    /// distributes proportionally: archive=25%, bm25=10%, remainder for stores.
252    /// 0 = disabled (use individual settings). Override via LEAN_CTX_MAX_DISK_MB.
253    #[serde(default)]
254    pub max_disk_mb: u64,
255    /// Auto-purge data older than this many days. 0 = disabled.
256    /// Flows into archive.max_age_hours and lifecycle idle TTL.
257    #[serde(default)]
258    pub max_staleness_days: u32,
259    /// Controls visibility of token savings footers in tool output.
260    /// Values: "always" (default, show on every response), "never", "auto" (legacy compatibility).
261    /// Override via LEAN_CTX_SAVINGS_FOOTER or LEAN_CTX_SHOW_SAVINGS=1|0 env var.
262    #[serde(default)]
263    pub savings_footer: SavingsFooter,
264    /// Explicit project root override. When set, lean-ctx uses this instead of auto-detection.
265    /// This prevents accidental home-directory scans when running from $HOME.
266    /// Override via LEAN_CTX_PROJECT_ROOT env var.
267    #[serde(default)]
268    pub project_root: Option<String>,
269    /// LSP server overrides. Map language name to custom binary path.
270    /// Example: `[lsp]\nrust = "/opt/rust-analyzer"\npython = "~/.venvs/main/bin/pylsp"`
271    #[serde(default)]
272    pub lsp: std::collections::HashMap<String, String>,
273    /// Per-IDE allowed paths. Restricts which directories lean-ctx will scan/index for each IDE.
274    /// Example: `[ide_paths]\ncursor = ["/home/user/projects/app1"]\ncodex = ["/home/user/codex"]`
275    /// When set, only these paths are indexed for the matching agent. Global `allow_paths` still applies.
276    #[serde(default)]
277    pub ide_paths: HashMap<String, Vec<String>>,
278    /// Custom model context window overrides.
279    /// Example: `[model_context_windows]\n"my-custom-model" = 500000`
280    #[serde(default)]
281    pub model_context_windows: HashMap<String, usize>,
282    /// Controls how much detail tool responses include.
283    ///
284    /// - `full` (default): complete compressed output
285    /// - `headers_only`: metadata line only (path, mode, token count)
286    ///
287    /// Override via `LEAN_CTX_RESPONSE_VERBOSITY` env var.
288    #[serde(default)]
289    pub response_verbosity: ResponseVerbosity,
290    /// Bypass hint mode. When agents use native Read/Grep instead of lean-ctx tools,
291    /// a hint is appended to the next tool response.
292    /// Values: "on" (default), "off", "aggressive" (hint on every call, no cooldown).
293    /// Override via LEAN_CTX_BYPASS_HINTS env var.
294    #[serde(default)]
295    pub bypass_hints: Option<String>,
296    /// Cache policy for ctx_read. Controls behavior on cache hits.
297    /// Values: "aggressive" (default, 13-tok stubs + compaction-aware reset),
298    /// "safe" (delivers map instead of stub), "off" (no caching, always disk read).
299    /// Override via LEAN_CTX_CACHE_POLICY env var.
300    #[serde(default)]
301    pub cache_policy: Option<String>,
302    /// Cross-project boundary policy.
303    /// Controls whether cross-project search/import is allowed and whether access is audited.
304    #[serde(default)]
305    pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
306    #[serde(default)]
307    pub secret_detection: SecretDetectionConfig,
308    /// Allow automatic project-root re-rooting when absolute paths outside the jail are seen.
309    /// When false (default), absolute paths outside the jail are rejected without re-rooting.
310    /// Override via LEAN_CTX_ALLOW_REROOT env var.
311    #[serde(default)]
312    pub allow_auto_reroot: bool,
313    /// Disable PathJail entirely. Set to false to allow all paths.
314    /// Useful in container/Docker environments. Override via LEAN_CTX_NO_JAIL=1.
315    #[serde(default)]
316    pub path_jail: Option<bool>,
317    /// Sandbox level for code execution (ctx_exec).
318    /// 0 = subprocess only (current), 1 = OS-level restriction (Seatbelt/Landlock).
319    /// Override via LEAN_CTX_SANDBOX_LEVEL env var.
320    #[serde(default)]
321    pub sandbox_level: u8,
322    /// When true, large tool outputs (>4000 chars) are stored as references
323    /// and a short URI is returned instead of the full content.
324    /// Override via LEAN_CTX_REFERENCE_RESULTS env var.
325    #[serde(default)]
326    pub reference_results: bool,
327    /// Default per-agent token budget. 0 means unlimited.
328    /// Override per-agent via ctx_session or programmatically.
329    #[serde(default)]
330    pub agent_token_budget: usize,
331    /// Optional shell command allowlist. When non-empty, only commands whose base binary
332    /// is in this list are permitted by ctx_shell. Empty = disable allowlist (allow all).
333    /// Default includes common dev tools. Set to `[]` to disable.
334    /// Override via LEAN_CTX_SHELL_ALLOWLIST env var (comma-separated).
335    #[serde(default = "default_shell_allowlist")]
336    pub shell_allowlist: Vec<String>,
337
338    /// Extra commands MERGED on top of the effective `shell_allowlist` without replacing
339    /// the defaults. Setting `shell_allowlist` replaces the whole built-in list (a common
340    /// footgun); entries here are purely additive, which is what `lean-ctx allow <cmd>`
341    /// writes. Only applied in restricted mode (when the base allowlist is non-empty).
342    #[serde(default)]
343    pub shell_allowlist_extra: Vec<String>,
344
345    /// When true, block command substitution ($(), backticks) and process substitution
346    /// (<(), >()) in shell arguments. When false (default), only warn via tracing.
347    /// Default false preserves backward compatibility — set true for maximum security.
348    #[serde(default)]
349    pub shell_strict_mode: bool,
350    /// Setup behavior: controls what gets injected during setup and updates.
351    #[serde(default)]
352    pub setup: SetupConfig,
353}
354
355impl Default for Config {
356    fn default() -> Self {
357        Self {
358            ultra_compact: false,
359            tee_mode: TeeMode::default(),
360            output_density: OutputDensity::default(),
361            checkpoint_interval: 15,
362            excluded_commands: Vec::new(),
363            passthrough_urls: Vec::new(),
364            custom_aliases: Vec::new(),
365            preserve_compact_formats: serde_defaults::default_preserve_compact_formats(),
366            slow_command_threshold_ms: 5000,
367            theme: serde_defaults::default_theme(),
368            cloud: CloudConfig::default(),
369            gain: GainConfig::default(),
370            autonomy: AutonomyConfig::default(),
371            providers: ProvidersConfig::default(),
372            proxy: ProxyConfig::default(),
373            proxy_enabled: None,
374            proxy_port: None,
375            proxy_timeout_ms: None,
376            buddy_enabled: serde_defaults::default_buddy_enabled(),
377            enable_wakeup_ctx: true,
378            redirect_exclude: Vec::new(),
379            disabled_tools: Vec::new(),
380            default_tool_categories: Vec::new(),
381            no_degrade: false,
382            profile: None,
383            tool_profile: None,
384            tools_enabled: Vec::new(),
385            loop_detection: LoopDetectionConfig::default(),
386            rules_scope: None,
387            extra_ignore_patterns: Vec::new(),
388            terse_agent: TerseAgent::default(),
389            compression_level: CompressionLevel::default(),
390            archive: ArchiveConfig::default(),
391            memory: MemoryPolicy::default(),
392            allow_paths: Vec::new(),
393            extra_roots: Vec::new(),
394            content_defined_chunking: false,
395            minimal_overhead: true,
396            symbol_map_auto: false,
397            team_url: None,
398            journal_enabled: true,
399            auto_capture: true,
400            search: crate::core::hybrid_search::HybridConfig::default(),
401            llm: crate::core::llm_enhance::LlmConfig::default(),
402            embedding: EmbeddingConfig::default(),
403            shell_hook_disabled: false,
404            shadow_mode: false,
405            shell_activation: ShellActivation::default(),
406            update_check_disabled: false,
407            updates: UpdatesConfig::default(),
408            graph_index_max_files: serde_defaults::default_graph_index_max_files(),
409            bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
410            memory_profile: MemoryProfile::default(),
411            memory_cleanup: MemoryCleanup::default(),
412            max_ram_percent: serde_defaults::default_max_ram_percent(),
413            max_disk_mb: 0,
414            max_staleness_days: 0,
415            savings_footer: SavingsFooter::default(),
416            project_root: None,
417            lsp: std::collections::HashMap::new(),
418            ide_paths: HashMap::new(),
419            model_context_windows: HashMap::new(),
420            response_verbosity: ResponseVerbosity::default(),
421            bypass_hints: None,
422            cache_policy: None,
423            boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
424            secret_detection: SecretDetectionConfig::default(),
425            allow_auto_reroot: false,
426            path_jail: None,
427            sandbox_level: 0,
428            reference_results: false,
429            agent_token_budget: 0,
430            shell_allowlist: default_shell_allowlist(),
431            shell_allowlist_extra: Vec::new(),
432            shell_strict_mode: false,
433            setup: SetupConfig::default(),
434        }
435    }
436}
437
438/// Holds the most recent global `config.toml` parse error, if the file currently
439/// fails to parse. When that happens `Config::load()` silently falls back to the
440/// built-in defaults and only logs to stderr — which is invisible over an MCP/stdio
441/// transport. Recording it here lets callers (e.g. the shell-allowlist diagnostic
442/// and `lean-ctx doctor`) surface "you're on defaults because your config is broken".
443static LAST_PARSE_ERROR: Mutex<Option<String>> = Mutex::new(None);
444
445/// Returns the most recent global config parse error, or `None` if the current
446/// `config.toml` parsed successfully (or no config file exists).
447#[must_use]
448pub fn last_config_parse_error() -> Option<String> {
449    LAST_PARSE_ERROR.lock().ok().and_then(|g| g.clone())
450}
451
452fn record_parse_error(err: Option<String>) {
453    if let Ok(mut guard) = LAST_PARSE_ERROR.lock() {
454        *guard = err;
455    }
456}
457
458impl Config {
459    /// Returns the effective rules scope, preferring env var over config file.
460    pub fn rules_scope_effective(&self) -> RulesScope {
461        let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
462            .ok()
463            .or_else(|| self.rules_scope.clone())
464            .unwrap_or_default();
465        match raw.trim().to_lowercase().as_str() {
466            "global" => RulesScope::Global,
467            "project" => RulesScope::Project,
468            _ => RulesScope::Both,
469        }
470    }
471
472    fn parse_disabled_tools_env(val: &str) -> Vec<String> {
473        val.split(',')
474            .map(|s| s.trim().to_string())
475            .filter(|s| !s.is_empty())
476            .collect()
477    }
478
479    /// Returns the effective disabled tools list, preferring env var over config file.
480    pub fn disabled_tools_effective(&self) -> Vec<String> {
481        if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
482            Self::parse_disabled_tools_env(&val)
483        } else {
484            self.disabled_tools.clone()
485        }
486    }
487
488    /// Returns `true` if minimal overhead is enabled via env var or config.
489    pub fn minimal_overhead_effective(&self) -> bool {
490        std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
491    }
492
493    /// Returns `true` if minimal overhead should be enabled for this MCP client.
494    ///
495    /// This is a superset of `minimal_overhead_effective()`:
496    /// - `LEAN_CTX_OVERHEAD_MODE=minimal` forces minimal overhead
497    /// - `LEAN_CTX_OVERHEAD_MODE=full` disables client/model heuristics (still honors LEAN_CTX_MINIMAL / config)
498    /// - In auto mode (default), certain low-context clients/models are treated as minimal to prevent
499    ///   large metadata blocks from destabilizing smaller context windows (e.g. Hermes + MiniMax).
500    pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
501        if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
502            match raw.trim().to_lowercase().as_str() {
503                "minimal" => return true,
504                "full" => return self.minimal_overhead_effective(),
505                _ => {}
506            }
507        }
508
509        if self.minimal_overhead_effective() {
510            return true;
511        }
512
513        let client_lower = client_name.trim().to_lowercase();
514        if !client_lower.is_empty() {
515            if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
516                for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
517                    if !needle.is_empty() && client_lower.contains(&needle) {
518                        return true;
519                    }
520                }
521            } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
522                return true;
523            }
524        }
525
526        let model = std::env::var("LEAN_CTX_MODEL")
527            .or_else(|_| std::env::var("LCTX_MODEL"))
528            .unwrap_or_default();
529        let model = model.trim().to_lowercase();
530        if !model.is_empty() {
531            let m = model.replace(['_', ' '], "-");
532            if m.contains("minimax")
533                || m.contains("mini-max")
534                || m.contains("m2.7")
535                || m.contains("m2-7")
536            {
537                return true;
538            }
539        }
540
541        false
542    }
543
544    /// Returns `true` if shell hook injection is disabled via env var or config.
545    pub fn shell_hook_disabled_effective(&self) -> bool {
546        std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
547    }
548
549    /// Returns the effective shell activation mode (env var > config > default).
550    pub fn shell_activation_effective(&self) -> ShellActivation {
551        ShellActivation::effective(self)
552    }
553
554    /// Returns `true` if the daily update check is disabled via env var or config.
555    pub fn update_check_disabled_effective(&self) -> bool {
556        std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
557    }
558
559    pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
560        let mut policy = self.memory.clone();
561        policy.apply_env_overrides();
562
563        // Scale memory limits proportionally when max_disk_mb is set
564        // and individual limits are still at their defaults.
565        let budget = self.max_disk_mb_effective();
566        if budget > 0 {
567            let scale_factor = (budget as f64 / 500.0).clamp(0.5, 10.0);
568            let default_policy = MemoryPolicy::default();
569            if policy.knowledge.max_facts == default_policy.knowledge.max_facts {
570                policy.knowledge.max_facts = (200.0 * scale_factor) as usize;
571            }
572            if policy.knowledge.max_patterns == default_policy.knowledge.max_patterns {
573                policy.knowledge.max_patterns = (50.0 * scale_factor) as usize;
574            }
575            if policy.episodic.max_episodes == default_policy.episodic.max_episodes {
576                policy.episodic.max_episodes = (500.0 * scale_factor) as usize;
577            }
578            if policy.procedural.max_procedures == default_policy.procedural.max_procedures {
579                policy.procedural.max_procedures = (100.0 * scale_factor) as usize;
580            }
581        }
582
583        policy.validate()?;
584        Ok(policy)
585    }
586
587    /// Returns the effective set of default tool categories.
588    /// Priority: LCTX_DEFAULT_CATEGORIES env var > config.toml > hardcoded default.
589    pub fn default_tool_categories_effective(&self) -> Vec<String> {
590        if let Ok(val) = std::env::var("LCTX_DEFAULT_CATEGORIES") {
591            return val
592                .split(',')
593                .map(|s| s.trim().to_lowercase())
594                .filter(|s| !s.is_empty())
595                .collect();
596        }
597        if !self.default_tool_categories.is_empty() {
598            return self
599                .default_tool_categories
600                .iter()
601                .map(|s| s.to_lowercase())
602                .collect();
603        }
604        vec!["core".to_string(), "session".to_string()]
605    }
606
607    /// Returns the effective tool profile.
608    /// Priority: LEAN_CTX_TOOL_PROFILE env > config tool_profile > config tools_enabled > power.
609    pub fn tool_profile_effective(&self) -> super::tool_profiles::ToolProfile {
610        super::tool_profiles::ToolProfile::from_config(self)
611    }
612
613    /// Returns `true` if all automatic read-mode degradation is disabled.
614    /// Checks LCTX_NO_DEGRADE env var first, then config.toml field.
615    pub fn no_degrade_effective(&self) -> bool {
616        if let Ok(val) = std::env::var("LCTX_NO_DEGRADE") {
617            return val == "1" || val.eq_ignore_ascii_case("true");
618        }
619        self.no_degrade
620    }
621
622    /// Effective max_disk_mb from env or config.
623    pub fn max_disk_mb_effective(&self) -> u64 {
624        std::env::var("LEAN_CTX_MAX_DISK_MB")
625            .ok()
626            .and_then(|v| v.parse().ok())
627            .unwrap_or(self.max_disk_mb)
628    }
629
630    /// Effective max_staleness_days from env or config.
631    pub fn max_staleness_days_effective(&self) -> u32 {
632        std::env::var("LEAN_CTX_MAX_STALENESS_DAYS")
633            .ok()
634            .and_then(|v| v.parse().ok())
635            .unwrap_or(self.max_staleness_days)
636    }
637
638    /// Archive max_disk_mb derived from simplified max_disk_mb if the detail
639    /// value is still at its default. Explicit overrides take priority.
640    pub fn archive_max_disk_mb_effective(&self) -> u64 {
641        let budget = self.max_disk_mb_effective();
642        if budget > 0 && self.archive.max_disk_mb == ArchiveConfig::default().max_disk_mb {
643            budget * 25 / 100
644        } else {
645            self.archive.max_disk_mb
646        }
647    }
648
649    /// Archive max_age_hours derived from max_staleness_days if the detail
650    /// value is still at its default. Explicit overrides take priority.
651    pub fn archive_max_age_hours_effective(&self) -> u64 {
652        let staleness = self.max_staleness_days_effective();
653        if staleness > 0 && self.archive.max_age_hours == ArchiveConfig::default().max_age_hours {
654            staleness as u64 * 24
655        } else {
656            self.archive.max_age_hours
657        }
658    }
659
660    /// Effective on-disk ceiling (MB) for the persisted BM25 index. Single source
661    /// of truth for `save`/`load`, `cache prune`, and the doctor health check.
662    ///
663    /// Priority: explicit `bm25_max_cache_mb` › `max_disk_mb` budget (10%) ›
664    /// generous default ([`DEFAULT_BM25_PERSIST_MB`]). The default is decoupled
665    /// from the RAM profile so large repos persist instead of rebuilding forever
666    /// (issue #249).
667    pub fn bm25_max_cache_mb_effective(&self) -> u64 {
668        // Explicit per-key override always wins.
669        if self.bm25_max_cache_mb != serde_defaults::default_bm25_max_cache_mb() {
670            return self.bm25_max_cache_mb;
671        }
672        // Otherwise derive from an explicit overall disk budget when present …
673        let budget = self.max_disk_mb_effective();
674        if budget > 0 {
675            return budget * 10 / 100;
676        }
677        // … else fall back to the generous, profile-independent disk default.
678        DEFAULT_BM25_PERSIST_MB
679    }
680}
681
682impl Config {
683    /// Returns the path to the global config file (`~/.lean-ctx/config.toml`).
684    pub fn path() -> Option<PathBuf> {
685        crate::core::data_dir::lean_ctx_data_dir()
686            .ok()
687            .map(|d| d.join("config.toml"))
688    }
689
690    /// Returns the path to the project-local config override file.
691    pub fn local_path(project_root: &str) -> PathBuf {
692        PathBuf::from(project_root).join(".lean-ctx.toml")
693    }
694
695    fn find_project_root() -> Option<String> {
696        static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
697        ROOT_CACHE
698            .get_or_init(Self::find_project_root_inner)
699            .clone()
700    }
701
702    fn find_project_root_inner() -> Option<String> {
703        if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
704            if !env_root.is_empty() {
705                return Some(env_root);
706            }
707        }
708
709        let cwd = std::env::current_dir().ok();
710
711        if let Some(root) =
712            crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
713        {
714            let root_path = std::path::Path::new(&root);
715            let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
716            let has_marker = root_path.join(".git").exists()
717                || root_path.join("Cargo.toml").exists()
718                || root_path.join("package.json").exists()
719                || root_path.join("go.mod").exists()
720                || root_path.join("pyproject.toml").exists()
721                || root_path.join(".lean-ctx.toml").exists();
722
723            if cwd_is_under_root || has_marker {
724                return Some(root);
725            }
726        }
727
728        if let Some(ref cwd) = cwd {
729            let git_root = std::process::Command::new("git")
730                .args(["rev-parse", "--show-toplevel"])
731                .current_dir(cwd)
732                .stdout(std::process::Stdio::piped())
733                .stderr(std::process::Stdio::null())
734                .output()
735                .ok()
736                .and_then(|o| {
737                    if o.status.success() {
738                        String::from_utf8(o.stdout)
739                            .ok()
740                            .map(|s| s.trim().to_string())
741                    } else {
742                        None
743                    }
744                });
745            if let Some(root) = git_root {
746                return Some(root);
747            }
748            if !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
749                return Some(cwd.to_string_lossy().to_string());
750            }
751        }
752        None
753    }
754
755    /// Loads config from disk with caching, merging global + project-local overrides.
756    pub fn load() -> Self {
757        static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
758
759        let Some(path) = Self::path() else {
760            return Self::default();
761        };
762
763        let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
764
765        let mtime = std::fs::metadata(&path)
766            .and_then(|m| m.modified())
767            .unwrap_or(SystemTime::UNIX_EPOCH);
768
769        let local_mtime = local_path
770            .as_ref()
771            .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
772
773        if let Ok(guard) = CACHE.lock() {
774            if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
775                if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
776                    return cfg.clone();
777                }
778            }
779        }
780
781        let mut cfg: Config = if let Ok(content) = std::fs::read_to_string(&path) {
782            match toml::from_str(&content) {
783                Ok(c) => {
784                    record_parse_error(None);
785                    c
786                }
787                Err(e) => {
788                    record_parse_error(Some(format!("{e}")));
789                    tracing::warn!("config parse error in {}: {e}", path.display());
790                    eprintln!(
791                        "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n  \
792                         Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
793                        path.display()
794                    );
795                    Self::default()
796                }
797            }
798        } else {
799            record_parse_error(None);
800            Self::default()
801        };
802
803        if let Some(ref lp) = local_path {
804            if let Ok(local_content) = std::fs::read_to_string(lp) {
805                cfg.merge_local(&local_content);
806            }
807        }
808
809        if let Ok(mut guard) = CACHE.lock() {
810            *guard = Some((cfg.clone(), mtime, local_mtime));
811        }
812
813        cfg
814    }
815
816    fn merge_local(&mut self, local_toml: &str) {
817        let local: Config = match toml::from_str(local_toml) {
818            Ok(c) => c,
819            Err(e) => {
820                tracing::warn!("local config parse error: {e}");
821                eprintln!(
822                    "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n  \
823                     Local overrides skipped.\x1b[0m"
824                );
825                return;
826            }
827        };
828        if local.ultra_compact {
829            self.ultra_compact = true;
830        }
831        if local.tee_mode != TeeMode::default() {
832            self.tee_mode = local.tee_mode;
833        }
834        if local.output_density != OutputDensity::default() {
835            self.output_density = local.output_density;
836        }
837        if local.checkpoint_interval != 15 {
838            self.checkpoint_interval = local.checkpoint_interval;
839        }
840        if !local.excluded_commands.is_empty() {
841            self.excluded_commands.extend(local.excluded_commands);
842        }
843        if !local.passthrough_urls.is_empty() {
844            self.passthrough_urls.extend(local.passthrough_urls);
845        }
846        if !local.custom_aliases.is_empty() {
847            self.custom_aliases.extend(local.custom_aliases);
848        }
849        // Additive merge with dedup: project-local config can add formats on top
850        // of the global default (`["toon"]`) without re-listing it.
851        for fmt in local.preserve_compact_formats {
852            if !self
853                .preserve_compact_formats
854                .iter()
855                .any(|f| f.eq_ignore_ascii_case(&fmt))
856            {
857                self.preserve_compact_formats.push(fmt);
858            }
859        }
860        if local.slow_command_threshold_ms != 5000 {
861            self.slow_command_threshold_ms = local.slow_command_threshold_ms;
862        }
863        if local.theme != "default" {
864            self.theme = local.theme;
865        }
866        if !local.buddy_enabled {
867            self.buddy_enabled = false;
868        }
869        if !local.enable_wakeup_ctx {
870            self.enable_wakeup_ctx = false;
871        }
872        if !local.redirect_exclude.is_empty() {
873            self.redirect_exclude.extend(local.redirect_exclude);
874        }
875        if !local.disabled_tools.is_empty() {
876            self.disabled_tools.extend(local.disabled_tools);
877        }
878        if !local.extra_ignore_patterns.is_empty() {
879            self.extra_ignore_patterns
880                .extend(local.extra_ignore_patterns);
881        }
882        if local.rules_scope.is_some() {
883            self.rules_scope = local.rules_scope;
884        }
885        if local.proxy.anthropic_upstream.is_some() {
886            self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
887        }
888        if local.proxy.openai_upstream.is_some() {
889            self.proxy.openai_upstream = local.proxy.openai_upstream;
890        }
891        if local.proxy.gemini_upstream.is_some() {
892            self.proxy.gemini_upstream = local.proxy.gemini_upstream;
893        }
894        if !local.autonomy.enabled {
895            self.autonomy.enabled = false;
896        }
897        if !local.autonomy.auto_preload {
898            self.autonomy.auto_preload = false;
899        }
900        if !local.autonomy.auto_dedup {
901            self.autonomy.auto_dedup = false;
902        }
903        if !local.autonomy.auto_related {
904            self.autonomy.auto_related = false;
905        }
906        if !local.autonomy.auto_consolidate {
907            self.autonomy.auto_consolidate = false;
908        }
909        if local.autonomy.silent_preload {
910            self.autonomy.silent_preload = true;
911        }
912        if !local.autonomy.silent_preload && self.autonomy.silent_preload {
913            self.autonomy.silent_preload = false;
914        }
915        if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
916            self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
917        }
918        if local.autonomy.consolidate_every_calls
919            != AutonomyConfig::default().consolidate_every_calls
920        {
921            self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
922        }
923        if local.autonomy.consolidate_cooldown_secs
924            != AutonomyConfig::default().consolidate_cooldown_secs
925        {
926            self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
927        }
928        if !local.autonomy.cognition_loop_enabled {
929            self.autonomy.cognition_loop_enabled = false;
930        }
931        if local.autonomy.cognition_loop_interval_secs
932            != AutonomyConfig::default().cognition_loop_interval_secs
933        {
934            self.autonomy.cognition_loop_interval_secs =
935                local.autonomy.cognition_loop_interval_secs;
936        }
937        if local.autonomy.cognition_loop_max_steps
938            != AutonomyConfig::default().cognition_loop_max_steps
939        {
940            self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
941        }
942        if local_toml.contains("compression_level") {
943            self.compression_level = local.compression_level;
944        }
945        if local_toml.contains("terse_agent") {
946            self.terse_agent = local.terse_agent;
947        }
948        if !local.archive.enabled {
949            self.archive.enabled = false;
950        }
951        if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
952            self.archive.threshold_chars = local.archive.threshold_chars;
953        }
954        if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
955            self.archive.max_age_hours = local.archive.max_age_hours;
956        }
957        if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
958            self.archive.max_disk_mb = local.archive.max_disk_mb;
959        }
960        if !local.archive.ephemeral {
961            self.archive.ephemeral = false;
962        }
963        let mem_def = MemoryPolicy::default();
964        if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
965            self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
966        }
967        if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
968            self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
969        }
970        if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
971            self.memory.knowledge.max_history = local.memory.knowledge.max_history;
972        }
973        if local.memory.knowledge.contradiction_threshold
974            != mem_def.knowledge.contradiction_threshold
975        {
976            self.memory.knowledge.contradiction_threshold =
977                local.memory.knowledge.contradiction_threshold;
978        }
979
980        if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
981            self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
982        }
983        if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
984        {
985            self.memory.episodic.max_actions_per_episode =
986                local.memory.episodic.max_actions_per_episode;
987        }
988        if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
989            self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
990        }
991
992        if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
993            self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
994        }
995        if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
996            self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
997        }
998        if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
999            self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1000        }
1001        if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1002            self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1003        }
1004
1005        if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1006            self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1007        }
1008        if local.memory.lifecycle.low_confidence_threshold
1009            != mem_def.lifecycle.low_confidence_threshold
1010        {
1011            self.memory.lifecycle.low_confidence_threshold =
1012                local.memory.lifecycle.low_confidence_threshold;
1013        }
1014        if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1015            self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1016        }
1017        if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1018            self.memory.lifecycle.similarity_threshold =
1019                local.memory.lifecycle.similarity_threshold;
1020        }
1021
1022        if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1023            self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1024        }
1025        if !local.allow_paths.is_empty() {
1026            self.allow_paths.extend(local.allow_paths);
1027        }
1028        if !local.extra_roots.is_empty() {
1029            self.extra_roots.extend(local.extra_roots);
1030        }
1031        if local.minimal_overhead {
1032            self.minimal_overhead = true;
1033        }
1034        if local.shell_hook_disabled {
1035            self.shell_hook_disabled = true;
1036        }
1037        if local.shell_activation != ShellActivation::default() {
1038            self.shell_activation = local.shell_activation.clone();
1039        }
1040        if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1041            self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1042        }
1043        if local.memory_profile != MemoryProfile::default() {
1044            self.memory_profile = local.memory_profile;
1045        }
1046        if local.memory_cleanup != MemoryCleanup::default() {
1047            self.memory_cleanup = local.memory_cleanup;
1048        }
1049        if !local.shell_allowlist.is_empty() {
1050            self.shell_allowlist = local.shell_allowlist;
1051        }
1052        if !local.shell_allowlist_extra.is_empty() {
1053            self.shell_allowlist_extra
1054                .extend(local.shell_allowlist_extra);
1055        }
1056        if !local.default_tool_categories.is_empty() {
1057            self.default_tool_categories = local.default_tool_categories;
1058        }
1059        if local.tool_profile.is_some() {
1060            self.tool_profile = local.tool_profile;
1061        }
1062        if !local.tools_enabled.is_empty() {
1063            self.tools_enabled = local.tools_enabled;
1064        }
1065        if local.no_degrade {
1066            self.no_degrade = true;
1067        }
1068        if local.profile.is_some() {
1069            self.profile = local.profile;
1070        }
1071        if local.proxy_timeout_ms.is_some() {
1072            self.proxy_timeout_ms = local.proxy_timeout_ms;
1073        }
1074    }
1075
1076    /// Persists the current config to the global config file.
1077    ///
1078    /// Preserves user comments, formatting, and unknown keys, keeps the file
1079    /// minimal (defaults that were never set on disk stay implicit), and writes
1080    /// atomically with a `.bak` backup so customizations are always recoverable.
1081    pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1082        let path = Self::path().ok_or_else(|| {
1083            super::error::LeanCtxError::Config("cannot determine home directory".into())
1084        })?;
1085        if let Some(parent) = path.parent() {
1086            std::fs::create_dir_all(parent)?;
1087        }
1088        let content = toml::to_string_pretty(self)
1089            .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1090        // Baseline = what loading an empty config yields. This honors serde's
1091        // field-level `#[serde(default)]` (which can diverge from the struct's
1092        // `Default` impl), so minimal mode skips exactly the keys that a fresh
1093        // load would produce — no spurious lines on save.
1094        let baseline = toml::from_str::<Self>("").unwrap_or_else(|_| Self::default());
1095        let defaults = toml::to_string_pretty(&baseline)
1096            .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1097        crate::config_io::write_toml_preserving_minimal(&path, &content, &defaults)
1098            .map_err(super::error::LeanCtxError::Config)?;
1099        Ok(())
1100    }
1101
1102    /// Formats the current config as a human-readable string with file paths.
1103    pub fn show(&self) -> String {
1104        let global_path = Self::path().map_or_else(
1105            || "~/.lean-ctx/config.toml".to_string(),
1106            |p| p.to_string_lossy().to_string(),
1107        );
1108        let content = toml::to_string_pretty(self).unwrap_or_default();
1109        let mut out = format!("Global config: {global_path}\n\n{content}");
1110
1111        if let Some(root) = Self::find_project_root() {
1112            let local = Self::local_path(&root);
1113            if local.exists() {
1114                out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1115            } else {
1116                out.push_str(&format!(
1117                    "\n\nLocal config: not found (create {} to override per-project)\n",
1118                    local.display()
1119                ));
1120            }
1121        }
1122        out
1123    }
1124}