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 enums;
10mod memory;
11mod proxy;
12pub mod schema;
13mod serde_defaults;
14pub mod setter;
15mod shell_activation;
16
17pub use enums::{
18    CompressionLevel, OutputDensity, ResponseVerbosity, RulesScope, TeeMode, TerseAgent,
19};
20pub use memory::{MemoryCleanup, MemoryGuardConfig, MemoryProfile, SavingsFooter};
21pub use proxy::{is_local_proxy_url, normalize_url, normalize_url_opt, ProxyConfig, ProxyProvider};
22pub use shell_activation::ShellActivation;
23
24/// Default BM25 cache cap from config (also used by `bm25_index` heuristics).
25pub fn default_bm25_max_cache_mb() -> u64 {
26    serde_defaults::default_bm25_max_cache_mb()
27}
28
29/// Effective on-disk ceiling (MB) for the persisted BM25 index when nothing is
30/// explicitly configured (no `bm25_max_cache_mb`, no `max_disk_mb` budget).
31///
32/// Deliberately decoupled from the RAM `MemoryProfile` (64/128/512 MB): this is
33/// a *disk* file, and tying it to the profile silently refused persistence on
34/// large repos under Low/Balanced, forcing a cold rebuild on every call (the
35/// perpetual "index warming" of issue #249). 512 MB compressed covers
36/// essentially every real repo; RAM pressure is governed separately by the
37/// eviction orchestrator (which measures real heap).
38pub const DEFAULT_BM25_PERSIST_MB: u64 = 512;
39
40// Compile-time regression guard (#249): the default disk ceiling must stay well
41// above the old RAM-profile caps (64/128 MB) that starved large repos.
42const _: () = assert!(DEFAULT_BM25_PERSIST_MB >= 512);
43
44/// Global lean-ctx configuration loaded from `config.toml`, merged with project-local overrides.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(default)]
47pub struct Config {
48    pub ultra_compact: bool,
49    #[serde(default, deserialize_with = "serde_defaults::deserialize_tee_mode")]
50    pub tee_mode: TeeMode,
51    #[serde(default)]
52    pub output_density: OutputDensity,
53    pub checkpoint_interval: u32,
54    pub excluded_commands: Vec<String>,
55    pub passthrough_urls: Vec<String>,
56    pub custom_aliases: Vec<AliasEntry>,
57    /// Commands taking longer than this threshold (ms) are recorded in the slow log.
58    /// Set to 0 to disable slow logging.
59    pub slow_command_threshold_ms: u64,
60    #[serde(default = "serde_defaults::default_theme")]
61    pub theme: String,
62    #[serde(default)]
63    pub cloud: CloudConfig,
64    #[serde(default)]
65    pub gain: GainConfig,
66    #[serde(default)]
67    pub autonomy: AutonomyConfig,
68    #[serde(default)]
69    pub providers: ProvidersConfig,
70    #[serde(default)]
71    pub proxy: ProxyConfig,
72    /// Whether the API proxy is enabled. Tri-state:
73    /// - None: undecided (fresh install, will prompt on interactive setup)
74    /// - Some(true): user opted in, proxy managed by lean-ctx
75    /// - Some(false): user opted out, never touch proxy or endpoints
76    #[serde(default)]
77    pub proxy_enabled: Option<bool>,
78    #[serde(default)]
79    pub proxy_port: Option<u16>,
80    /// Proxy reachability timeout in milliseconds. Default: 200.
81    /// Override via LEAN_CTX_PROXY_TIMEOUT_MS env var.
82    #[serde(default)]
83    pub proxy_timeout_ms: Option<u64>,
84    #[serde(default = "serde_defaults::default_buddy_enabled")]
85    pub buddy_enabled: bool,
86    #[serde(default = "serde_defaults::default_true")]
87    pub enable_wakeup_ctx: bool,
88    #[serde(default)]
89    pub redirect_exclude: Vec<String>,
90    /// Tools to exclude from the MCP tool list returned by list_tools.
91    /// Accepts exact tool names (e.g. `["ctx_graph", "ctx_agent"]`).
92    /// Empty by default — all tools listed, no behaviour change.
93    #[serde(default)]
94    pub disabled_tools: Vec<String>,
95    /// Tool categories to activate by default for dynamic-tool-capable clients.
96    /// Values: "core" (always on), "arch", "debug", "memory", "metrics", "session".
97    /// Example: `default_tool_categories = ["core", "arch", "memory"]`
98    /// Override via LCTX_DEFAULT_CATEGORIES env var (comma-separated).
99    /// Empty = lean-ctx default (core + session).
100    #[serde(default)]
101    pub default_tool_categories: Vec<String>,
102    /// Disable all automatic read-mode degradation (auto_degrade + context_gate pressure).
103    /// When true, lean-ctx never downgrades requested read modes regardless of pressure.
104    /// Override via LCTX_NO_DEGRADE=1 env var.
105    #[serde(default)]
106    pub no_degrade: bool,
107    /// Persistent profile name. Checked after LEAN_CTX_PROFILE env var.
108    /// Set via `lean-ctx config set profile passthrough` or editing config.toml.
109    #[serde(default)]
110    pub profile: Option<String>,
111    /// Tool visibility profile: "minimal" (5), "standard" (20), or "power" (all).
112    /// Override via LEAN_CTX_TOOL_PROFILE env var.
113    /// Existing installs default to "power" (backward compat).
114    #[serde(default)]
115    pub tool_profile: Option<String>,
116    /// Explicit list of enabled tool names (overrides tool_profile when non-empty).
117    /// Example: `tools_enabled = ["ctx_read", "ctx_shell", "ctx_search"]`
118    #[serde(default)]
119    pub tools_enabled: Vec<String>,
120    #[serde(default)]
121    pub loop_detection: LoopDetectionConfig,
122    /// Controls where lean-ctx installs agent rule files.
123    /// Values: "both" (default), "global" (home-dir only), "project" (repo-local only).
124    /// Override via LEAN_CTX_RULES_SCOPE env var.
125    #[serde(default)]
126    pub rules_scope: Option<String>,
127    /// Extra glob patterns to ignore in graph/overview/preload (repo-local).
128    /// Example: `["externals/**", "target/**", "temp/**"]`
129    #[serde(default)]
130    pub extra_ignore_patterns: Vec<String>,
131    /// Controls agent output verbosity via instructions injection.
132    /// Values: "off" (default), "lite", "full", "ultra".
133    /// Override via LEAN_CTX_TERSE_AGENT env var.
134    #[serde(default)]
135    pub terse_agent: TerseAgent,
136    /// Unified compression level (replaces separate terse_agent + output_density).
137    /// Values: "off" (default), "lite", "standard", "max".
138    /// Override via LEAN_CTX_COMPRESSION env var.
139    #[serde(default)]
140    pub compression_level: CompressionLevel,
141    /// Archive configuration for zero-loss compression.
142    #[serde(default)]
143    pub archive: ArchiveConfig,
144    /// Memory policy (knowledge/episodic/procedural/lifecycle budgets & thresholds).
145    #[serde(default)]
146    pub memory: MemoryPolicy,
147    /// Additional paths allowed by PathJail (absolute).
148    /// Useful for multi-project workspaces where the jail root is a parent directory.
149    /// Override via LEAN_CTX_ALLOW_PATH env var (path-list separator).
150    #[serde(default)]
151    pub allow_paths: Vec<String>,
152    /// Extra project roots for multi-root workspaces.
153    /// Tools like ctx_tree and ctx_search can scan across all roots in a single call.
154    /// These paths are automatically added to PathJail's allow-list.
155    /// Override via LEAN_CTX_EXTRA_ROOTS env var (path-list separator).
156    #[serde(default)]
157    pub extra_roots: Vec<String>,
158    /// Enable content-defined chunking (Rabin-Karp) for cache-optimal output ordering.
159    /// Stable chunks are emitted first to maximize prompt cache hits.
160    #[serde(default)]
161    pub content_defined_chunking: bool,
162    /// Skip session/knowledge/gotcha blocks in MCP instructions to minimize token overhead.
163    /// Override via LEAN_CTX_MINIMAL env var.
164    #[serde(default)]
165    pub minimal_overhead: bool,
166    /// Auto-enable SymbolMap for projects with >50 source files.
167    #[serde(default = "serde_defaults::default_true")]
168    pub symbol_map_auto: bool,
169    /// Enable human-readable activity journal (~/.lean-ctx/journal.md).
170    #[serde(default)]
171    pub journal_enabled: bool,
172    /// Opt-in: auto-persist interesting findings as knowledge facts.
173    #[serde(default)]
174    pub auto_capture: bool,
175    /// Hybrid search weights (BM25/dense/candidates).
176    #[serde(default)]
177    pub search: crate::core::hybrid_search::HybridConfig,
178    /// Optional LLM enhancement (query expansion, contradiction explanation).
179    #[serde(default)]
180    pub llm: crate::core::llm_enhance::LlmConfig,
181    /// Semantic-embedding engine settings (which local ONNX model to use).
182    #[serde(default)]
183    pub embedding: EmbeddingConfig,
184    /// Disable shell hook injection (the _lc() function that wraps CLI commands).
185    /// Override via LEAN_CTX_NO_HOOK env var.
186    #[serde(default)]
187    pub shell_hook_disabled: bool,
188    /// Shadow mode: transparently intercepts native tool calls (Read/Grep/Shell)
189    /// via hooks, strengthens MCP instructions to MUST-level, and activates
190    /// immediate bypass hints on first native tool use. Enables "transparent
191    /// replacement" so agents use ctx_* without explicit opt-in.
192    #[serde(default)]
193    pub shadow_mode: bool,
194    /// Controls when the shell hook auto-activates aliases.
195    /// - `always`: (Default) Aliases active in every interactive shell.
196    /// - `agents-only`: Aliases only active when an AI agent env var is detected.
197    /// - `off`: Aliases never auto-activate (user must call `lean-ctx-on` manually).
198    ///
199    /// Override via `LEAN_CTX_SHELL_ACTIVATION` env var.
200    #[serde(default)]
201    pub shell_activation: ShellActivation,
202    /// Disable the daily version check against leanctx.com/version.txt.
203    /// Override via LEAN_CTX_NO_UPDATE_CHECK env var.
204    #[serde(default)]
205    pub update_check_disabled: bool,
206    #[serde(default)]
207    pub updates: UpdatesConfig,
208    /// Maximum BM25 cache file size in MB. Indexes exceeding this are quarantined on load
209    /// and refused on save. Override via LEAN_CTX_BM25_MAX_CACHE_MB env var.
210    #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
211    pub bm25_max_cache_mb: u64,
212    /// Maximum number of files scanned by the lightweight JSON graph index.
213    /// 0 = unlimited (default). Set >0 to cap for constrained systems.
214    #[serde(default = "serde_defaults::default_graph_index_max_files")]
215    pub graph_index_max_files: u64,
216    /// Controls RAM vs feature trade-off. Values: "low", "balanced" (default), "performance".
217    /// Override via LEAN_CTX_MEMORY_PROFILE env var.
218    #[serde(default)]
219    pub memory_profile: MemoryProfile,
220    /// Controls how aggressively memory is freed when idle.
221    /// Values: "aggressive" (default, 5 min TTL), "shared" (30 min TTL for multi-IDE use).
222    /// Override via LEAN_CTX_MEMORY_CLEANUP env var.
223    #[serde(default)]
224    pub memory_cleanup: MemoryCleanup,
225    /// Maximum percentage of system RAM that lean-ctx may use (default: 5).
226    /// Override via LEAN_CTX_MAX_RAM_PERCENT env var.
227    #[serde(default = "serde_defaults::default_max_ram_percent")]
228    pub max_ram_percent: u8,
229    /// Simplified disk budget (MB). When set and detail values are at defaults,
230    /// distributes proportionally: archive=25%, bm25=10%, remainder for stores.
231    /// 0 = disabled (use individual settings). Override via LEAN_CTX_MAX_DISK_MB.
232    #[serde(default)]
233    pub max_disk_mb: u64,
234    /// Auto-purge data older than this many days. 0 = disabled.
235    /// Flows into archive.max_age_hours and lifecycle idle TTL.
236    #[serde(default)]
237    pub max_staleness_days: u32,
238    /// Controls visibility of token savings footers in tool output.
239    /// Values: "always" (default, show on every response), "never", "auto" (legacy compatibility).
240    /// Override via LEAN_CTX_SAVINGS_FOOTER or LEAN_CTX_SHOW_SAVINGS=1|0 env var.
241    #[serde(default)]
242    pub savings_footer: SavingsFooter,
243    /// Explicit project root override. When set, lean-ctx uses this instead of auto-detection.
244    /// This prevents accidental home-directory scans when running from $HOME.
245    /// Override via LEAN_CTX_PROJECT_ROOT env var.
246    #[serde(default)]
247    pub project_root: Option<String>,
248    /// LSP server overrides. Map language name to custom binary path.
249    /// Example: `[lsp]\nrust = "/opt/rust-analyzer"\npython = "~/.venvs/main/bin/pylsp"`
250    #[serde(default)]
251    pub lsp: std::collections::HashMap<String, String>,
252    /// Per-IDE allowed paths. Restricts which directories lean-ctx will scan/index for each IDE.
253    /// Example: `[ide_paths]\ncursor = ["/home/user/projects/app1"]\ncodex = ["/home/user/codex"]`
254    /// When set, only these paths are indexed for the matching agent. Global `allow_paths` still applies.
255    #[serde(default)]
256    pub ide_paths: HashMap<String, Vec<String>>,
257    /// Custom model context window overrides.
258    /// Example: `[model_context_windows]\n"my-custom-model" = 500000`
259    #[serde(default)]
260    pub model_context_windows: HashMap<String, usize>,
261    /// Controls how much detail tool responses include.
262    ///
263    /// - `full` (default): complete compressed output
264    /// - `headers_only`: metadata line only (path, mode, token count)
265    ///
266    /// Override via `LEAN_CTX_RESPONSE_VERBOSITY` env var.
267    #[serde(default)]
268    pub response_verbosity: ResponseVerbosity,
269    /// Bypass hint mode. When agents use native Read/Grep instead of lean-ctx tools,
270    /// a hint is appended to the next tool response.
271    /// Values: "on" (default), "off", "aggressive" (hint on every call, no cooldown).
272    /// Override via LEAN_CTX_BYPASS_HINTS env var.
273    #[serde(default)]
274    pub bypass_hints: Option<String>,
275    /// Cache policy for ctx_read. Controls behavior on cache hits.
276    /// Values: "aggressive" (default, 13-tok stubs + compaction-aware reset),
277    /// "safe" (delivers map instead of stub), "off" (no caching, always disk read).
278    /// Override via LEAN_CTX_CACHE_POLICY env var.
279    #[serde(default)]
280    pub cache_policy: Option<String>,
281    /// Cross-project boundary policy.
282    /// Controls whether cross-project search/import is allowed and whether access is audited.
283    #[serde(default)]
284    pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
285    #[serde(default)]
286    pub secret_detection: SecretDetectionConfig,
287    /// Allow automatic project-root re-rooting when absolute paths outside the jail are seen.
288    /// When false (default), absolute paths outside the jail are rejected without re-rooting.
289    /// Override via LEAN_CTX_ALLOW_REROOT env var.
290    #[serde(default)]
291    pub allow_auto_reroot: bool,
292    /// Disable PathJail entirely. Set to false to allow all paths.
293    /// Useful in container/Docker environments. Override via LEAN_CTX_NO_JAIL=1.
294    #[serde(default)]
295    pub path_jail: Option<bool>,
296    /// Sandbox level for code execution (ctx_exec).
297    /// 0 = subprocess only (current), 1 = OS-level restriction (Seatbelt/Landlock).
298    /// Override via LEAN_CTX_SANDBOX_LEVEL env var.
299    #[serde(default)]
300    pub sandbox_level: u8,
301    /// When true, large tool outputs (>4000 chars) are stored as references
302    /// and a short URI is returned instead of the full content.
303    /// Override via LEAN_CTX_REFERENCE_RESULTS env var.
304    #[serde(default)]
305    pub reference_results: bool,
306    /// Default per-agent token budget. 0 means unlimited.
307    /// Override per-agent via ctx_session or programmatically.
308    #[serde(default)]
309    pub agent_token_budget: usize,
310    /// Optional shell command allowlist. When non-empty, only commands whose base binary
311    /// is in this list are permitted by ctx_shell. Empty = disable allowlist (allow all).
312    /// Default includes common dev tools. Set to `[]` to disable.
313    /// Override via LEAN_CTX_SHELL_ALLOWLIST env var (comma-separated).
314    #[serde(default = "default_shell_allowlist")]
315    pub shell_allowlist: Vec<String>,
316
317    /// When true, block command substitution ($(), backticks) and process substitution
318    /// (<(), >()) in shell arguments. When false (default), only warn via tracing.
319    /// Default false preserves backward compatibility — set true for maximum security.
320    #[serde(default)]
321    pub shell_strict_mode: bool,
322    /// Setup behavior: controls what gets injected during setup and updates.
323    #[serde(default)]
324    pub setup: SetupConfig,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize)]
328#[serde(default)]
329pub struct SecretDetectionConfig {
330    pub enabled: bool,
331    pub redact: bool,
332    pub custom_patterns: Vec<String>,
333}
334
335/// Controls what lean-ctx injects during `setup` and `update --rewire`.
336/// Fresh installs default to non-invasive (rules/skills off, MCP on).
337/// Users who ran setup interactively get explicit true/false.
338/// `None` = undecided (legacy: check if rules already exist and preserve behavior).
339#[derive(Debug, Clone, Serialize, Deserialize)]
340#[serde(default)]
341pub struct SetupConfig {
342    /// Inject agent rule files (CLAUDE.md, .cursor/rules/, etc.).
343    /// None = undecided (legacy compat: inject if rules already present).
344    /// Some(true) = always inject. Some(false) = never inject.
345    pub auto_inject_rules: Option<bool>,
346    /// Install SKILL.md files for supported agents.
347    /// None = undecided. Some(true) = install. Some(false) = skip.
348    pub auto_inject_skills: Option<bool>,
349    /// Register lean-ctx as an MCP server in editor configs.
350    #[serde(default = "serde_defaults::default_true")]
351    pub auto_update_mcp: bool,
352}
353
354impl Default for SetupConfig {
355    fn default() -> Self {
356        Self {
357            auto_inject_rules: None,
358            auto_inject_skills: None,
359            auto_update_mcp: true,
360        }
361    }
362}
363
364impl SetupConfig {
365    /// Returns whether rules should be injected, considering legacy installs.
366    /// If undecided (None), checks if lean-ctx rules markers already exist
367    /// in any agent config — if so, keeps injecting for backward compat.
368    pub fn should_inject_rules(&self) -> bool {
369        match self.auto_inject_rules {
370            Some(v) => v,
371            None => Self::rules_already_present(),
372        }
373    }
374
375    /// Returns whether skills should be installed.
376    pub fn should_inject_skills(&self) -> bool {
377        match self.auto_inject_skills {
378            Some(v) => v,
379            None => Self::rules_already_present(),
380        }
381    }
382
383    /// Check if lean-ctx rules markers exist in any known agent config location.
384    fn rules_already_present() -> bool {
385        let Some(home) = dirs::home_dir() else {
386            return false;
387        };
388        let marker = crate::rules_inject::RULES_MARKER;
389        let check_paths = [
390            home.join(".cursor/rules/lean-ctx.mdc"),
391            crate::core::editor_registry::claude_rules_dir(&home).join("lean-ctx.md"),
392            home.join(".gemini/GEMINI.md"),
393            home.join(".codeium/windsurf/rules/lean-ctx.md"),
394        ];
395        for p in &check_paths {
396            if let Ok(content) = std::fs::read_to_string(p) {
397                if content.contains(marker) {
398                    return true;
399                }
400            }
401        }
402        false
403    }
404}
405
406impl Default for SecretDetectionConfig {
407    fn default() -> Self {
408        Self {
409            enabled: true,
410            redact: true,
411            custom_patterns: Vec::new(),
412        }
413    }
414}
415
416/// Settings for the zero-loss compression archive (large tool outputs saved to disk).
417#[derive(Debug, Clone, Serialize, Deserialize)]
418#[serde(default)]
419pub struct ArchiveConfig {
420    pub enabled: bool,
421    pub threshold_chars: usize,
422    pub max_age_hours: u64,
423    pub max_disk_mb: u64,
424    pub ephemeral: bool,
425}
426
427impl Default for ArchiveConfig {
428    fn default() -> Self {
429        Self {
430            enabled: true,
431            threshold_chars: 800,
432            max_age_hours: 48,
433            max_disk_mb: 500,
434            ephemeral: true,
435        }
436    }
437}
438
439impl ArchiveConfig {
440    pub fn ephemeral_effective(&self) -> bool {
441        if let Ok(v) = std::env::var("LEAN_CTX_EPHEMERAL") {
442            return !matches!(v.trim(), "0" | "false" | "off");
443        }
444        self.ephemeral && self.enabled
445    }
446}
447
448/// Configuration for external context providers (GitHub, GitLab, Jira, etc.).
449/// Each provider can be enabled/disabled and configured with auth tokens.
450/// Override individual tokens via env vars (GITHUB_TOKEN, GITLAB_TOKEN, etc.).
451#[derive(Debug, Clone, Serialize, Deserialize)]
452#[serde(default)]
453pub struct ProvidersConfig {
454    /// Master switch for the provider subsystem.
455    pub enabled: bool,
456    /// GitHub provider configuration.
457    pub github: ProviderEntryConfig,
458    /// GitLab provider configuration.
459    pub gitlab: ProviderEntryConfig,
460    /// Auto-ingest provider results into BM25/embedding indexes.
461    pub auto_index: bool,
462    /// Default cache TTL for provider results (seconds).
463    pub cache_ttl_secs: u64,
464    /// MCP Bridge providers: `{ "name" = { url = "...", description = "..." } }`.
465    #[serde(default)]
466    pub mcp_bridges: std::collections::HashMap<String, McpBridgeEntry>,
467}
468
469impl Default for ProvidersConfig {
470    fn default() -> Self {
471        Self {
472            enabled: true,
473            github: ProviderEntryConfig::default(),
474            gitlab: ProviderEntryConfig::default(),
475            auto_index: true,
476            cache_ttl_secs: 120,
477            mcp_bridges: std::collections::HashMap::new(),
478        }
479    }
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct McpBridgeEntry {
484    /// HTTP/SSE URL for remote MCP servers.
485    #[serde(default)]
486    pub url: Option<String>,
487    /// Command to spawn a local MCP server (stdio transport).
488    #[serde(default)]
489    pub command: Option<String>,
490    /// Arguments for the command.
491    #[serde(default)]
492    pub args: Vec<String>,
493    /// Human-readable description.
494    #[serde(default)]
495    pub description: Option<String>,
496    /// Environment variable name containing an auth token.
497    #[serde(default)]
498    pub auth_env: Option<String>,
499}
500
501/// Per-provider configuration entry.
502#[derive(Debug, Clone, Serialize, Deserialize)]
503#[serde(default)]
504pub struct ProviderEntryConfig {
505    /// Whether this specific provider is enabled.
506    pub enabled: bool,
507    /// Auth token (prefer env var; only use this for project-local overrides).
508    pub token: Option<String>,
509    /// API base URL override (for GitHub Enterprise, self-hosted GitLab, etc.).
510    pub api_url: Option<String>,
511    /// Default project/repo for this provider (auto-detected from git remote if empty).
512    pub project: Option<String>,
513}
514
515impl Default for ProviderEntryConfig {
516    fn default() -> Self {
517        Self {
518            enabled: true,
519            token: None,
520            api_url: None,
521            project: None,
522        }
523    }
524}
525
526/// Controls autonomous background behaviors (preload, dedup, consolidation).
527#[derive(Debug, Clone, Serialize, Deserialize)]
528#[serde(default)]
529pub struct AutonomyConfig {
530    pub enabled: bool,
531    pub auto_preload: bool,
532    pub auto_dedup: bool,
533    pub auto_related: bool,
534    pub auto_consolidate: bool,
535    pub silent_preload: bool,
536    pub dedup_threshold: usize,
537    pub consolidate_every_calls: u32,
538    pub consolidate_cooldown_secs: u64,
539    #[serde(default = "serde_defaults::default_true")]
540    pub cognition_loop_enabled: bool,
541    #[serde(default = "serde_defaults::default_cognition_loop_interval")]
542    pub cognition_loop_interval_secs: u64,
543    #[serde(default = "serde_defaults::default_cognition_loop_max_steps")]
544    pub cognition_loop_max_steps: u8,
545}
546
547impl Default for AutonomyConfig {
548    fn default() -> Self {
549        Self {
550            enabled: true,
551            auto_preload: true,
552            auto_dedup: true,
553            auto_related: true,
554            auto_consolidate: true,
555            silent_preload: true,
556            dedup_threshold: 8,
557            consolidate_every_calls: 25,
558            consolidate_cooldown_secs: 120,
559            cognition_loop_enabled: true,
560            cognition_loop_interval_secs: 3600,
561            cognition_loop_max_steps: 8,
562        }
563    }
564}
565
566/// Controls automatic update behavior. All defaults are OFF — auto-updates
567/// require explicit opt-in via `lean-ctx setup` or `lean-ctx update --schedule`.
568#[derive(Debug, Clone, Serialize, Deserialize)]
569#[serde(default)]
570pub struct UpdatesConfig {
571    pub auto_update: bool,
572    pub check_interval_hours: u64,
573    pub notify_only: bool,
574}
575
576impl Default for UpdatesConfig {
577    fn default() -> Self {
578        Self {
579            auto_update: false,
580            check_interval_hours: 6,
581            notify_only: false,
582        }
583    }
584}
585
586impl UpdatesConfig {
587    pub fn from_env() -> Self {
588        let mut cfg = Self::default();
589        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_UPDATE") {
590            cfg.auto_update = v == "1" || v.eq_ignore_ascii_case("true");
591        }
592        if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_INTERVAL_HOURS") {
593            if let Ok(h) = v.parse::<u64>() {
594                cfg.check_interval_hours = h.clamp(1, 168);
595            }
596        }
597        if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_NOTIFY_ONLY") {
598            cfg.notify_only = v == "1" || v.eq_ignore_ascii_case("true");
599        }
600        cfg
601    }
602}
603
604impl AutonomyConfig {
605    /// Creates an autonomy config from env vars, falling back to defaults.
606    pub fn from_env() -> Self {
607        let mut cfg = Self::default();
608        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
609            if v == "false" || v == "0" {
610                cfg.enabled = false;
611            }
612        }
613        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
614            cfg.auto_preload = v != "false" && v != "0";
615        }
616        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
617            cfg.auto_dedup = v != "false" && v != "0";
618        }
619        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
620            cfg.auto_related = v != "false" && v != "0";
621        }
622        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
623            cfg.auto_consolidate = v != "false" && v != "0";
624        }
625        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
626            cfg.silent_preload = v != "false" && v != "0";
627        }
628        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
629            if let Ok(n) = v.parse() {
630                cfg.dedup_threshold = n;
631            }
632        }
633        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
634            if let Ok(n) = v.parse() {
635                cfg.consolidate_every_calls = n;
636            }
637        }
638        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
639            if let Ok(n) = v.parse() {
640                cfg.consolidate_cooldown_secs = n;
641            }
642        }
643        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
644            cfg.cognition_loop_enabled = v != "false" && v != "0";
645        }
646        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
647            if let Ok(n) = v.parse() {
648                cfg.cognition_loop_interval_secs = n;
649            }
650        }
651        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
652            if let Ok(n) = v.parse() {
653                cfg.cognition_loop_max_steps = n;
654            }
655        }
656        cfg
657    }
658
659    /// Loads autonomy config from disk, with env var overrides applied.
660    pub fn load() -> Self {
661        let file_cfg = Config::load().autonomy;
662        let mut cfg = file_cfg;
663        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
664            if v == "false" || v == "0" {
665                cfg.enabled = false;
666            }
667        }
668        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
669            cfg.auto_preload = v != "false" && v != "0";
670        }
671        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
672            cfg.auto_dedup = v != "false" && v != "0";
673        }
674        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
675            cfg.auto_related = v != "false" && v != "0";
676        }
677        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
678            cfg.silent_preload = v != "false" && v != "0";
679        }
680        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
681            if let Ok(n) = v.parse() {
682                cfg.dedup_threshold = n;
683            }
684        }
685        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
686            cfg.cognition_loop_enabled = v != "false" && v != "0";
687        }
688        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
689            if let Ok(n) = v.parse() {
690                cfg.cognition_loop_interval_secs = n;
691            }
692        }
693        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
694            if let Ok(n) = v.parse() {
695                cfg.cognition_loop_max_steps = n;
696            }
697        }
698        cfg
699    }
700}
701
702/// Cloud sync and contribution settings (pattern sharing, model pulls).
703#[derive(Debug, Clone, Serialize, Deserialize, Default)]
704#[serde(default)]
705pub struct CloudConfig {
706    pub contribute_enabled: bool,
707    pub last_contribute: Option<String>,
708    pub last_sync: Option<String>,
709    pub last_gain_sync: Option<String>,
710    pub last_model_pull: Option<String>,
711}
712
713/// Settings for publishing your token-savings recap (`gain --publish` / auto-publish).
714///
715/// Publishing is always opt-in: it sends a small, whitelisted *aggregate* payload (tokens
716/// saved, $ avoided, compression % — never code, paths or counts) to the cloud.
717/// `auto_publish` simply removes the need to re-run `gain --publish` by hand; it stays off
718/// until the user explicitly enables it.
719#[derive(Debug, Clone, Serialize, Deserialize)]
720#[serde(default)]
721pub struct GainConfig {
722    /// When true, `lean-ctx gain` automatically (re)publishes the recap, throttled to
723    /// `auto_publish_interval_hours`. Off by default.
724    pub auto_publish: bool,
725    /// When auto-publishing, also opt into the public leaderboard.
726    pub leaderboard: bool,
727    /// Optional display name for the published card / leaderboard entry.
728    pub display_name: Option<String>,
729    /// Minimum hours between automatic publishes (throttle).
730    pub auto_publish_interval_hours: u64,
731    /// Runtime state — RFC3339 timestamp of the last automatic publish. Managed by the
732    /// tool, not meant to be set by hand.
733    pub last_auto_publish: Option<String>,
734}
735
736impl Default for GainConfig {
737    fn default() -> Self {
738        Self {
739            auto_publish: false,
740            leaderboard: false,
741            display_name: None,
742            auto_publish_interval_hours: 24,
743            last_auto_publish: None,
744        }
745    }
746}
747
748/// A user-defined command alias mapping for shell compression patterns.
749#[derive(Debug, Clone, Serialize, Deserialize)]
750pub struct AliasEntry {
751    pub command: String,
752    pub alias: String,
753}
754
755/// Thresholds for detecting and throttling repetitive agent tool call loops.
756#[derive(Debug, Clone, Serialize, Deserialize)]
757#[serde(default)]
758pub struct LoopDetectionConfig {
759    pub normal_threshold: u32,
760    pub reduced_threshold: u32,
761    pub blocked_threshold: u32,
762    pub window_secs: u64,
763    pub search_group_limit: u32,
764    pub tool_total_limits: HashMap<String, u32>,
765}
766
767impl Default for LoopDetectionConfig {
768    fn default() -> Self {
769        let mut tool_total_limits = HashMap::new();
770        tool_total_limits.insert("ctx_read".to_string(), 100);
771        tool_total_limits.insert("ctx_search".to_string(), 80);
772        tool_total_limits.insert("ctx_shell".to_string(), 50);
773        tool_total_limits.insert("ctx_semantic_search".to_string(), 60);
774        Self {
775            normal_threshold: 2,
776            reduced_threshold: 4,
777            blocked_threshold: 0,
778            window_secs: 300,
779            search_group_limit: 10,
780            tool_total_limits,
781        }
782    }
783}
784
785/// Semantic-embedding engine settings.
786///
787/// `model` selects which local ONNX embedding model lean-ctx downloads and uses for
788/// `ctx_semantic_search`. Accepts the same aliases as the `LEAN_CTX_EMBEDDING_MODEL` env
789/// var: `minilm` (all-MiniLM-L6-v2, 384d — the default), `jina-code-v2` (768d,
790/// code-optimized) or `nomic` (768d). When the env var is set it takes precedence; an
791/// unset/`None` value uses the default model. Switching models triggers a one-time
792/// re-index on the next semantic search (vector dimensions follow from the model).
793#[derive(Debug, Clone, Default, Serialize, Deserialize)]
794#[serde(default)]
795pub struct EmbeddingConfig {
796    #[serde(default, skip_serializing_if = "Option::is_none")]
797    pub model: Option<String>,
798}
799
800impl Default for Config {
801    fn default() -> Self {
802        Self {
803            ultra_compact: false,
804            tee_mode: TeeMode::default(),
805            output_density: OutputDensity::default(),
806            checkpoint_interval: 15,
807            excluded_commands: Vec::new(),
808            passthrough_urls: Vec::new(),
809            custom_aliases: Vec::new(),
810            slow_command_threshold_ms: 5000,
811            theme: serde_defaults::default_theme(),
812            cloud: CloudConfig::default(),
813            gain: GainConfig::default(),
814            autonomy: AutonomyConfig::default(),
815            providers: ProvidersConfig::default(),
816            proxy: ProxyConfig::default(),
817            proxy_enabled: None,
818            proxy_port: None,
819            proxy_timeout_ms: None,
820            buddy_enabled: serde_defaults::default_buddy_enabled(),
821            enable_wakeup_ctx: true,
822            redirect_exclude: Vec::new(),
823            disabled_tools: Vec::new(),
824            default_tool_categories: Vec::new(),
825            no_degrade: false,
826            profile: None,
827            tool_profile: None,
828            tools_enabled: Vec::new(),
829            loop_detection: LoopDetectionConfig::default(),
830            rules_scope: None,
831            extra_ignore_patterns: Vec::new(),
832            terse_agent: TerseAgent::default(),
833            compression_level: CompressionLevel::default(),
834            archive: ArchiveConfig::default(),
835            memory: MemoryPolicy::default(),
836            allow_paths: Vec::new(),
837            extra_roots: Vec::new(),
838            content_defined_chunking: false,
839            minimal_overhead: true,
840            symbol_map_auto: true,
841            journal_enabled: true,
842            auto_capture: true,
843            search: crate::core::hybrid_search::HybridConfig::default(),
844            llm: crate::core::llm_enhance::LlmConfig::default(),
845            embedding: EmbeddingConfig::default(),
846            shell_hook_disabled: false,
847            shadow_mode: false,
848            shell_activation: ShellActivation::default(),
849            update_check_disabled: false,
850            updates: UpdatesConfig::default(),
851            graph_index_max_files: serde_defaults::default_graph_index_max_files(),
852            bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
853            memory_profile: MemoryProfile::default(),
854            memory_cleanup: MemoryCleanup::default(),
855            max_ram_percent: serde_defaults::default_max_ram_percent(),
856            max_disk_mb: 0,
857            max_staleness_days: 0,
858            savings_footer: SavingsFooter::default(),
859            project_root: None,
860            lsp: std::collections::HashMap::new(),
861            ide_paths: HashMap::new(),
862            model_context_windows: HashMap::new(),
863            response_verbosity: ResponseVerbosity::default(),
864            bypass_hints: None,
865            cache_policy: None,
866            boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
867            secret_detection: SecretDetectionConfig::default(),
868            allow_auto_reroot: false,
869            path_jail: None,
870            sandbox_level: 0,
871            reference_results: false,
872            agent_token_budget: 0,
873            shell_allowlist: default_shell_allowlist(),
874            shell_strict_mode: false,
875            setup: SetupConfig::default(),
876        }
877    }
878}
879
880pub(crate) fn default_shell_allowlist() -> Vec<String> {
881    [
882        // VCS
883        "git",
884        "gh",
885        "svn",
886        "hg",
887        // Build tools
888        "cargo",
889        "npm",
890        "npx",
891        "yarn",
892        "pnpm",
893        "bun",
894        "bunx",
895        "make",
896        "cmake",
897        "pip",
898        "pip3",
899        "poetry",
900        "uv",
901        "go",
902        "mvn",
903        "gradle",
904        "mix",
905        "dotnet",
906        "swift",
907        "zig",
908        "rustup",
909        "rustc",
910        "deno",
911        "bazel",
912        // Package managers
913        "pipenv",
914        "conda",
915        "mamba",
916        "brew",
917        "apt",
918        "apt-get",
919        "apk",
920        "nix",
921        // Common CLI
922        "ls",
923        "cat",
924        "head",
925        "tail",
926        "wc",
927        "sort",
928        "uniq",
929        "tr",
930        "cut",
931        "grep",
932        "rg",
933        "find",
934        "fd",
935        "ag",
936        "ack",
937        "sed",
938        "awk",
939        "echo",
940        "printf",
941        "true",
942        "false",
943        "test",
944        "expr",
945        "cd",
946        "pwd",
947        "basename",
948        "dirname",
949        "realpath",
950        "readlink",
951        "cp",
952        "mv",
953        "mkdir",
954        "rm",
955        "rmdir",
956        "touch",
957        "ln",
958        "chmod",
959        "chown",
960        "diff",
961        "patch",
962        "tar",
963        "zip",
964        "unzip",
965        "gzip",
966        "gunzip",
967        "zstd",
968        "curl",
969        "wget",
970        "tree",
971        "du",
972        "df",
973        "ps",
974        "lsof",
975        "watch",
976        "tee",
977        "less",
978        "more",
979        "id",
980        "whoami",
981        "uname",
982        "hostname",
983        // Dev tools
984        // docker/podman removed from default: mount-based PathJail bypass risk
985        // Add explicitly if needed: shell_allowlist = [..., "docker"]
986        "node",
987        "python",
988        "python3",
989        "ruby",
990        "perl",
991        "java",
992        "javac",
993        "tsc",
994        "eslint",
995        "prettier",
996        "black",
997        "ruff",
998        "clippy",
999        "jq",
1000        "yq",
1001        "which",
1002        "type",
1003        "file",
1004        "stat",
1005        "date",
1006        "sleep",
1007        "timeout",
1008        "nice",
1009        "ionice",
1010        // Testing frameworks
1011        "pytest",
1012        "py.test",
1013        "jest",
1014        "vitest",
1015        "mocha",
1016        "cypress",
1017        "playwright",
1018        "puppeteer",
1019        // Pre-commit & git hooks
1020        "pre-commit",
1021        "husky",
1022        "lint-staged",
1023        "lefthook",
1024        "overcommit",
1025        "commitlint",
1026        // Linters & formatters
1027        "mypy",
1028        "pyright",
1029        "pylint",
1030        "flake8",
1031        "bandit",
1032        "isort",
1033        "autopep8",
1034        "yapf",
1035        "golangci-lint",
1036        "shellcheck",
1037        "markdownlint",
1038        "stylelint",
1039        // Bundlers & dev servers
1040        "webpack",
1041        "vite",
1042        "esbuild",
1043        "rollup",
1044        "turbo",
1045        "nx",
1046        "lerna",
1047        "next",
1048        "nuxt",
1049        // Ruby ecosystem
1050        "bundle",
1051        "bundler",
1052        "rake",
1053        "rails",
1054        "rspec",
1055        "rubocop",
1056        // PHP ecosystem
1057        "php",
1058        "composer",
1059        "phpunit",
1060        "artisan",
1061        // Mobile
1062        "flutter",
1063        "dart",
1064        "xcodebuild",
1065        "xcrun",
1066        "pod",
1067        "fastlane",
1068        // Cloud & infra
1069        "terraform",
1070        "ansible",
1071        "kubectl",
1072        "helm",
1073        "az",
1074        "aws",
1075        "gcloud",
1076        "firebase",
1077        "heroku",
1078        "vercel",
1079        "netlify",
1080        "fly",
1081        "wrangler",
1082        "pulumi",
1083        // Database
1084        "psql",
1085        "mysql",
1086        "sqlite3",
1087        "mongosh",
1088        "redis-cli",
1089        "pg_dump",
1090        "pg_restore",
1091        "mysqldump",
1092        // JVM ecosystem
1093        "scala",
1094        "sbt",
1095        "kotlin",
1096        "kotlinc",
1097        // Elixir
1098        "elixir",
1099        "iex",
1100        // lean-ctx itself
1101        "lean-ctx",
1102    ]
1103    .iter()
1104    .map(|s| (*s).to_string())
1105    .collect()
1106}
1107
1108impl Config {
1109    /// Returns the effective rules scope, preferring env var over config file.
1110    pub fn rules_scope_effective(&self) -> RulesScope {
1111        let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
1112            .ok()
1113            .or_else(|| self.rules_scope.clone())
1114            .unwrap_or_default();
1115        match raw.trim().to_lowercase().as_str() {
1116            "global" => RulesScope::Global,
1117            "project" => RulesScope::Project,
1118            _ => RulesScope::Both,
1119        }
1120    }
1121
1122    fn parse_disabled_tools_env(val: &str) -> Vec<String> {
1123        val.split(',')
1124            .map(|s| s.trim().to_string())
1125            .filter(|s| !s.is_empty())
1126            .collect()
1127    }
1128
1129    /// Returns the effective disabled tools list, preferring env var over config file.
1130    pub fn disabled_tools_effective(&self) -> Vec<String> {
1131        if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
1132            Self::parse_disabled_tools_env(&val)
1133        } else {
1134            self.disabled_tools.clone()
1135        }
1136    }
1137
1138    /// Returns `true` if minimal overhead is enabled via env var or config.
1139    pub fn minimal_overhead_effective(&self) -> bool {
1140        std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
1141    }
1142
1143    /// Returns `true` if minimal overhead should be enabled for this MCP client.
1144    ///
1145    /// This is a superset of `minimal_overhead_effective()`:
1146    /// - `LEAN_CTX_OVERHEAD_MODE=minimal` forces minimal overhead
1147    /// - `LEAN_CTX_OVERHEAD_MODE=full` disables client/model heuristics (still honors LEAN_CTX_MINIMAL / config)
1148    /// - In auto mode (default), certain low-context clients/models are treated as minimal to prevent
1149    ///   large metadata blocks from destabilizing smaller context windows (e.g. Hermes + MiniMax).
1150    pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
1151        if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
1152            match raw.trim().to_lowercase().as_str() {
1153                "minimal" => return true,
1154                "full" => return self.minimal_overhead_effective(),
1155                _ => {}
1156            }
1157        }
1158
1159        if self.minimal_overhead_effective() {
1160            return true;
1161        }
1162
1163        let client_lower = client_name.trim().to_lowercase();
1164        if !client_lower.is_empty() {
1165            if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
1166                for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
1167                    if !needle.is_empty() && client_lower.contains(&needle) {
1168                        return true;
1169                    }
1170                }
1171            } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
1172                return true;
1173            }
1174        }
1175
1176        let model = std::env::var("LEAN_CTX_MODEL")
1177            .or_else(|_| std::env::var("LCTX_MODEL"))
1178            .unwrap_or_default();
1179        let model = model.trim().to_lowercase();
1180        if !model.is_empty() {
1181            let m = model.replace(['_', ' '], "-");
1182            if m.contains("minimax")
1183                || m.contains("mini-max")
1184                || m.contains("m2.7")
1185                || m.contains("m2-7")
1186            {
1187                return true;
1188            }
1189        }
1190
1191        false
1192    }
1193
1194    /// Returns `true` if shell hook injection is disabled via env var or config.
1195    pub fn shell_hook_disabled_effective(&self) -> bool {
1196        std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
1197    }
1198
1199    /// Returns the effective shell activation mode (env var > config > default).
1200    pub fn shell_activation_effective(&self) -> ShellActivation {
1201        ShellActivation::effective(self)
1202    }
1203
1204    /// Returns `true` if the daily update check is disabled via env var or config.
1205    pub fn update_check_disabled_effective(&self) -> bool {
1206        std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
1207    }
1208
1209    pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
1210        let mut policy = self.memory.clone();
1211        policy.apply_env_overrides();
1212
1213        // Scale memory limits proportionally when max_disk_mb is set
1214        // and individual limits are still at their defaults.
1215        let budget = self.max_disk_mb_effective();
1216        if budget > 0 {
1217            let scale_factor = (budget as f64 / 500.0).clamp(0.5, 10.0);
1218            let default_policy = MemoryPolicy::default();
1219            if policy.knowledge.max_facts == default_policy.knowledge.max_facts {
1220                policy.knowledge.max_facts = (200.0 * scale_factor) as usize;
1221            }
1222            if policy.knowledge.max_patterns == default_policy.knowledge.max_patterns {
1223                policy.knowledge.max_patterns = (50.0 * scale_factor) as usize;
1224            }
1225            if policy.episodic.max_episodes == default_policy.episodic.max_episodes {
1226                policy.episodic.max_episodes = (500.0 * scale_factor) as usize;
1227            }
1228            if policy.procedural.max_procedures == default_policy.procedural.max_procedures {
1229                policy.procedural.max_procedures = (100.0 * scale_factor) as usize;
1230            }
1231        }
1232
1233        policy.validate()?;
1234        Ok(policy)
1235    }
1236
1237    /// Returns the effective set of default tool categories.
1238    /// Priority: LCTX_DEFAULT_CATEGORIES env var > config.toml > hardcoded default.
1239    pub fn default_tool_categories_effective(&self) -> Vec<String> {
1240        if let Ok(val) = std::env::var("LCTX_DEFAULT_CATEGORIES") {
1241            return val
1242                .split(',')
1243                .map(|s| s.trim().to_lowercase())
1244                .filter(|s| !s.is_empty())
1245                .collect();
1246        }
1247        if !self.default_tool_categories.is_empty() {
1248            return self
1249                .default_tool_categories
1250                .iter()
1251                .map(|s| s.to_lowercase())
1252                .collect();
1253        }
1254        vec!["core".to_string(), "session".to_string()]
1255    }
1256
1257    /// Returns the effective tool profile.
1258    /// Priority: LEAN_CTX_TOOL_PROFILE env > config tool_profile > config tools_enabled > power.
1259    pub fn tool_profile_effective(&self) -> super::tool_profiles::ToolProfile {
1260        super::tool_profiles::ToolProfile::from_config(self)
1261    }
1262
1263    /// Returns `true` if all automatic read-mode degradation is disabled.
1264    /// Checks LCTX_NO_DEGRADE env var first, then config.toml field.
1265    pub fn no_degrade_effective(&self) -> bool {
1266        if let Ok(val) = std::env::var("LCTX_NO_DEGRADE") {
1267            return val == "1" || val.eq_ignore_ascii_case("true");
1268        }
1269        self.no_degrade
1270    }
1271
1272    /// Effective max_disk_mb from env or config.
1273    pub fn max_disk_mb_effective(&self) -> u64 {
1274        std::env::var("LEAN_CTX_MAX_DISK_MB")
1275            .ok()
1276            .and_then(|v| v.parse().ok())
1277            .unwrap_or(self.max_disk_mb)
1278    }
1279
1280    /// Effective max_staleness_days from env or config.
1281    pub fn max_staleness_days_effective(&self) -> u32 {
1282        std::env::var("LEAN_CTX_MAX_STALENESS_DAYS")
1283            .ok()
1284            .and_then(|v| v.parse().ok())
1285            .unwrap_or(self.max_staleness_days)
1286    }
1287
1288    /// Archive max_disk_mb derived from simplified max_disk_mb if the detail
1289    /// value is still at its default. Explicit overrides take priority.
1290    pub fn archive_max_disk_mb_effective(&self) -> u64 {
1291        let budget = self.max_disk_mb_effective();
1292        if budget > 0 && self.archive.max_disk_mb == ArchiveConfig::default().max_disk_mb {
1293            budget * 25 / 100
1294        } else {
1295            self.archive.max_disk_mb
1296        }
1297    }
1298
1299    /// Archive max_age_hours derived from max_staleness_days if the detail
1300    /// value is still at its default. Explicit overrides take priority.
1301    pub fn archive_max_age_hours_effective(&self) -> u64 {
1302        let staleness = self.max_staleness_days_effective();
1303        if staleness > 0 && self.archive.max_age_hours == ArchiveConfig::default().max_age_hours {
1304            staleness as u64 * 24
1305        } else {
1306            self.archive.max_age_hours
1307        }
1308    }
1309
1310    /// Effective on-disk ceiling (MB) for the persisted BM25 index. Single source
1311    /// of truth for `save`/`load`, `cache prune`, and the doctor health check.
1312    ///
1313    /// Priority: explicit `bm25_max_cache_mb` › `max_disk_mb` budget (10%) ›
1314    /// generous default ([`DEFAULT_BM25_PERSIST_MB`]). The default is decoupled
1315    /// from the RAM profile so large repos persist instead of rebuilding forever
1316    /// (issue #249).
1317    pub fn bm25_max_cache_mb_effective(&self) -> u64 {
1318        // Explicit per-key override always wins.
1319        if self.bm25_max_cache_mb != serde_defaults::default_bm25_max_cache_mb() {
1320            return self.bm25_max_cache_mb;
1321        }
1322        // Otherwise derive from an explicit overall disk budget when present …
1323        let budget = self.max_disk_mb_effective();
1324        if budget > 0 {
1325            return budget * 10 / 100;
1326        }
1327        // … else fall back to the generous, profile-independent disk default.
1328        DEFAULT_BM25_PERSIST_MB
1329    }
1330}
1331
1332#[cfg(test)]
1333mod disabled_tools_tests {
1334    use super::*;
1335
1336    #[test]
1337    fn config_field_default_is_empty() {
1338        let cfg = Config::default();
1339        assert!(cfg.disabled_tools.is_empty());
1340    }
1341
1342    #[test]
1343    fn effective_returns_config_field_when_no_env_var() {
1344        // Only meaningful when LEAN_CTX_DISABLED_TOOLS is unset; skip otherwise.
1345        if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
1346            return;
1347        }
1348        let cfg = Config {
1349            disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
1350            ..Default::default()
1351        };
1352        assert_eq!(
1353            cfg.disabled_tools_effective(),
1354            vec!["ctx_graph", "ctx_agent"]
1355        );
1356    }
1357
1358    #[test]
1359    fn parse_env_basic() {
1360        let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
1361        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
1362    }
1363
1364    #[test]
1365    fn parse_env_trims_whitespace_and_skips_empty() {
1366        let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
1367        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
1368    }
1369
1370    #[test]
1371    fn parse_env_single_entry() {
1372        let result = Config::parse_disabled_tools_env("ctx_graph");
1373        assert_eq!(result, vec!["ctx_graph"]);
1374    }
1375
1376    #[test]
1377    fn parse_env_empty_string_returns_empty() {
1378        let result = Config::parse_disabled_tools_env("");
1379        assert!(result.is_empty());
1380    }
1381
1382    #[test]
1383    fn disabled_tools_deserialization_defaults_to_empty() {
1384        let cfg: Config = toml::from_str("").unwrap();
1385        assert!(cfg.disabled_tools.is_empty());
1386    }
1387
1388    #[test]
1389    fn disabled_tools_deserialization_from_toml() {
1390        let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
1391        assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
1392    }
1393}
1394
1395#[cfg(test)]
1396mod default_tool_categories_tests {
1397    use super::*;
1398
1399    // --- Defaults ---
1400
1401    #[test]
1402    fn default_returns_core_and_session() {
1403        if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1404            return;
1405        }
1406        let cfg = Config::default();
1407        assert_eq!(
1408            cfg.default_tool_categories_effective(),
1409            vec!["core", "session"]
1410        );
1411    }
1412
1413    #[test]
1414    fn default_struct_field_is_empty_vec() {
1415        let cfg = Config::default();
1416        assert!(cfg.default_tool_categories.is_empty());
1417    }
1418
1419    // --- Config field overrides ---
1420
1421    #[test]
1422    fn config_field_overrides_default() {
1423        if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1424            return;
1425        }
1426        let cfg = Config {
1427            default_tool_categories: vec![
1428                "core".to_string(),
1429                "arch".to_string(),
1430                "memory".to_string(),
1431            ],
1432            ..Default::default()
1433        };
1434        assert_eq!(
1435            cfg.default_tool_categories_effective(),
1436            vec!["core", "arch", "memory"]
1437        );
1438    }
1439
1440    #[test]
1441    fn single_category_in_config() {
1442        if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1443            return;
1444        }
1445        let cfg = Config {
1446            default_tool_categories: vec!["debug".to_string()],
1447            ..Default::default()
1448        };
1449        assert_eq!(cfg.default_tool_categories_effective(), vec!["debug"]);
1450    }
1451
1452    #[test]
1453    fn all_six_categories_in_config() {
1454        if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1455            return;
1456        }
1457        let cfg = Config {
1458            default_tool_categories: vec![
1459                "core".to_string(),
1460                "arch".to_string(),
1461                "debug".to_string(),
1462                "memory".to_string(),
1463                "metrics".to_string(),
1464                "session".to_string(),
1465            ],
1466            ..Default::default()
1467        };
1468        let effective = cfg.default_tool_categories_effective();
1469        assert_eq!(effective.len(), 6);
1470        assert!(effective.contains(&"core".to_string()));
1471        assert!(effective.contains(&"metrics".to_string()));
1472    }
1473
1474    // --- TOML deserialization ---
1475
1476    #[test]
1477    fn deserialization_defaults_to_empty() {
1478        let cfg: Config = toml::from_str("").unwrap();
1479        assert!(cfg.default_tool_categories.is_empty());
1480    }
1481
1482    #[test]
1483    fn deserialization_from_toml() {
1484        let cfg: Config =
1485            toml::from_str(r#"default_tool_categories = ["core", "arch", "debug"]"#).unwrap();
1486        assert_eq!(cfg.default_tool_categories, vec!["core", "arch", "debug"]);
1487    }
1488
1489    #[test]
1490    fn deserialization_empty_array() {
1491        let cfg: Config = toml::from_str(r"default_tool_categories = []").unwrap();
1492        assert!(cfg.default_tool_categories.is_empty());
1493    }
1494
1495    #[test]
1496    fn deserialization_single_entry() {
1497        let cfg: Config = toml::from_str(r#"default_tool_categories = ["memory"]"#).unwrap();
1498        assert_eq!(cfg.default_tool_categories, vec!["memory"]);
1499    }
1500
1501    // --- Edge cases ---
1502
1503    #[test]
1504    fn effective_normalizes_config_to_lowercase() {
1505        if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1506            return;
1507        }
1508        let cfg = Config {
1509            default_tool_categories: vec!["ARCH".to_string(), "Debug".to_string()],
1510            ..Default::default()
1511        };
1512        let effective = cfg.default_tool_categories_effective();
1513        assert_eq!(effective, vec!["arch", "debug"]);
1514    }
1515}
1516
1517#[cfg(test)]
1518mod no_degrade_tests {
1519    use super::*;
1520
1521    // --- Defaults ---
1522
1523    #[test]
1524    fn default_is_false() {
1525        let cfg = Config::default();
1526        assert!(!cfg.no_degrade);
1527    }
1528
1529    #[test]
1530    fn effective_false_when_unset() {
1531        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1532            return;
1533        }
1534        let cfg = Config::default();
1535        assert!(!cfg.no_degrade_effective());
1536    }
1537
1538    // --- Config field ---
1539
1540    #[test]
1541    fn config_field_true_respected_when_no_env() {
1542        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1543            return;
1544        }
1545        let cfg = Config {
1546            no_degrade: true,
1547            ..Default::default()
1548        };
1549        assert!(cfg.no_degrade_effective());
1550    }
1551
1552    #[test]
1553    fn config_field_false_respected_when_no_env() {
1554        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1555            return;
1556        }
1557        let cfg = Config {
1558            no_degrade: false,
1559            ..Default::default()
1560        };
1561        assert!(!cfg.no_degrade_effective());
1562    }
1563
1564    // --- TOML deserialization ---
1565
1566    #[test]
1567    fn deserialization_true() {
1568        let cfg: Config = toml::from_str("no_degrade = true").unwrap();
1569        assert!(cfg.no_degrade);
1570    }
1571
1572    #[test]
1573    fn deserialization_false() {
1574        let cfg: Config = toml::from_str("no_degrade = false").unwrap();
1575        assert!(!cfg.no_degrade);
1576    }
1577
1578    #[test]
1579    fn deserialization_absent_defaults_false() {
1580        let cfg: Config = toml::from_str("").unwrap();
1581        assert!(!cfg.no_degrade);
1582    }
1583
1584    // --- Coexistence with other config fields ---
1585
1586    #[test]
1587    fn no_degrade_independent_of_disabled_tools() {
1588        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1589            return;
1590        }
1591        let cfg = Config {
1592            no_degrade: true,
1593            disabled_tools: vec!["ctx_graph".to_string()],
1594            ..Default::default()
1595        };
1596        assert!(cfg.no_degrade_effective());
1597        assert!(!cfg.disabled_tools.is_empty());
1598    }
1599
1600    #[test]
1601    fn no_degrade_independent_of_tool_categories() {
1602        if std::env::var("LCTX_NO_DEGRADE").is_ok()
1603            || std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok()
1604        {
1605            return;
1606        }
1607        let cfg = Config {
1608            no_degrade: true,
1609            default_tool_categories: vec!["core".to_string(), "arch".to_string()],
1610            ..Default::default()
1611        };
1612        assert!(cfg.no_degrade_effective());
1613        assert_eq!(
1614            cfg.default_tool_categories_effective(),
1615            vec!["core", "arch"]
1616        );
1617    }
1618}
1619
1620#[cfg(test)]
1621mod rules_scope_tests {
1622    use super::*;
1623
1624    #[test]
1625    fn default_is_both() {
1626        let cfg = Config::default();
1627        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1628    }
1629
1630    #[test]
1631    fn config_global() {
1632        let cfg = Config {
1633            rules_scope: Some("global".to_string()),
1634            ..Default::default()
1635        };
1636        assert_eq!(cfg.rules_scope_effective(), RulesScope::Global);
1637    }
1638
1639    #[test]
1640    fn config_project() {
1641        let cfg = Config {
1642            rules_scope: Some("project".to_string()),
1643            ..Default::default()
1644        };
1645        assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1646    }
1647
1648    #[test]
1649    fn unknown_value_falls_back_to_both() {
1650        let cfg = Config {
1651            rules_scope: Some("nonsense".to_string()),
1652            ..Default::default()
1653        };
1654        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1655    }
1656
1657    #[test]
1658    fn deserialization_none_by_default() {
1659        let cfg: Config = toml::from_str("").unwrap();
1660        assert!(cfg.rules_scope.is_none());
1661        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1662    }
1663
1664    #[test]
1665    fn deserialization_from_toml() {
1666        let cfg: Config = toml::from_str(r#"rules_scope = "project""#).unwrap();
1667        assert_eq!(cfg.rules_scope.as_deref(), Some("project"));
1668        assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1669    }
1670}
1671
1672#[cfg(test)]
1673mod loop_detection_config_tests {
1674    use super::*;
1675
1676    #[test]
1677    fn defaults_are_reasonable() {
1678        let cfg = LoopDetectionConfig::default();
1679        assert_eq!(cfg.normal_threshold, 2);
1680        assert_eq!(cfg.reduced_threshold, 4);
1681        // 0 = blocking disabled by default (LeanCTX philosophy: always help, never block)
1682        assert_eq!(cfg.blocked_threshold, 0);
1683        assert_eq!(cfg.window_secs, 300);
1684        assert_eq!(cfg.search_group_limit, 10);
1685    }
1686
1687    #[test]
1688    fn deserialization_defaults_when_missing() {
1689        let cfg: Config = toml::from_str("").unwrap();
1690        // 0 = blocking disabled by default
1691        assert_eq!(cfg.loop_detection.blocked_threshold, 0);
1692        assert_eq!(cfg.loop_detection.search_group_limit, 10);
1693    }
1694
1695    #[test]
1696    fn deserialization_from_toml() {
1697        let cfg: Config = toml::from_str(
1698            r"
1699            [loop_detection]
1700            normal_threshold = 1
1701            reduced_threshold = 3
1702            blocked_threshold = 5
1703            window_secs = 120
1704            search_group_limit = 8
1705            ",
1706        )
1707        .unwrap();
1708        assert_eq!(cfg.loop_detection.normal_threshold, 1);
1709        assert_eq!(cfg.loop_detection.reduced_threshold, 3);
1710        assert_eq!(cfg.loop_detection.blocked_threshold, 5);
1711        assert_eq!(cfg.loop_detection.window_secs, 120);
1712        assert_eq!(cfg.loop_detection.search_group_limit, 8);
1713    }
1714
1715    #[test]
1716    fn partial_override_keeps_defaults() {
1717        let cfg: Config = toml::from_str(
1718            r"
1719            [loop_detection]
1720            blocked_threshold = 10
1721            ",
1722        )
1723        .unwrap();
1724        assert_eq!(cfg.loop_detection.blocked_threshold, 10);
1725        assert_eq!(cfg.loop_detection.normal_threshold, 2);
1726        assert_eq!(cfg.loop_detection.search_group_limit, 10);
1727    }
1728}
1729
1730impl Config {
1731    /// Returns the path to the global config file (`~/.lean-ctx/config.toml`).
1732    pub fn path() -> Option<PathBuf> {
1733        crate::core::data_dir::lean_ctx_data_dir()
1734            .ok()
1735            .map(|d| d.join("config.toml"))
1736    }
1737
1738    /// Returns the path to the project-local config override file.
1739    pub fn local_path(project_root: &str) -> PathBuf {
1740        PathBuf::from(project_root).join(".lean-ctx.toml")
1741    }
1742
1743    fn find_project_root() -> Option<String> {
1744        static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
1745        ROOT_CACHE
1746            .get_or_init(Self::find_project_root_inner)
1747            .clone()
1748    }
1749
1750    fn find_project_root_inner() -> Option<String> {
1751        if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
1752            if !env_root.is_empty() {
1753                return Some(env_root);
1754            }
1755        }
1756
1757        let cwd = std::env::current_dir().ok();
1758
1759        if let Some(root) =
1760            crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
1761        {
1762            let root_path = std::path::Path::new(&root);
1763            let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
1764            let has_marker = root_path.join(".git").exists()
1765                || root_path.join("Cargo.toml").exists()
1766                || root_path.join("package.json").exists()
1767                || root_path.join("go.mod").exists()
1768                || root_path.join("pyproject.toml").exists()
1769                || root_path.join(".lean-ctx.toml").exists();
1770
1771            if cwd_is_under_root || has_marker {
1772                return Some(root);
1773            }
1774        }
1775
1776        if let Some(ref cwd) = cwd {
1777            let git_root = std::process::Command::new("git")
1778                .args(["rev-parse", "--show-toplevel"])
1779                .current_dir(cwd)
1780                .stdout(std::process::Stdio::piped())
1781                .stderr(std::process::Stdio::null())
1782                .output()
1783                .ok()
1784                .and_then(|o| {
1785                    if o.status.success() {
1786                        String::from_utf8(o.stdout)
1787                            .ok()
1788                            .map(|s| s.trim().to_string())
1789                    } else {
1790                        None
1791                    }
1792                });
1793            if let Some(root) = git_root {
1794                return Some(root);
1795            }
1796            if !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
1797                return Some(cwd.to_string_lossy().to_string());
1798            }
1799        }
1800        None
1801    }
1802
1803    /// Loads config from disk with caching, merging global + project-local overrides.
1804    pub fn load() -> Self {
1805        static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
1806
1807        let Some(path) = Self::path() else {
1808            return Self::default();
1809        };
1810
1811        let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
1812
1813        let mtime = std::fs::metadata(&path)
1814            .and_then(|m| m.modified())
1815            .unwrap_or(SystemTime::UNIX_EPOCH);
1816
1817        let local_mtime = local_path
1818            .as_ref()
1819            .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
1820
1821        if let Ok(guard) = CACHE.lock() {
1822            if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
1823                if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
1824                    return cfg.clone();
1825                }
1826            }
1827        }
1828
1829        let mut cfg: Config = match std::fs::read_to_string(&path) {
1830            Ok(content) => match toml::from_str(&content) {
1831                Ok(c) => c,
1832                Err(e) => {
1833                    tracing::warn!("config parse error in {}: {e}", path.display());
1834                    eprintln!(
1835                        "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n  \
1836                         Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
1837                        path.display()
1838                    );
1839                    Self::default()
1840                }
1841            },
1842            Err(_) => Self::default(),
1843        };
1844
1845        if let Some(ref lp) = local_path {
1846            if let Ok(local_content) = std::fs::read_to_string(lp) {
1847                cfg.merge_local(&local_content);
1848            }
1849        }
1850
1851        if let Ok(mut guard) = CACHE.lock() {
1852            *guard = Some((cfg.clone(), mtime, local_mtime));
1853        }
1854
1855        cfg
1856    }
1857
1858    fn merge_local(&mut self, local_toml: &str) {
1859        let local: Config = match toml::from_str(local_toml) {
1860            Ok(c) => c,
1861            Err(e) => {
1862                tracing::warn!("local config parse error: {e}");
1863                eprintln!(
1864                    "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n  \
1865                     Local overrides skipped.\x1b[0m"
1866                );
1867                return;
1868            }
1869        };
1870        if local.ultra_compact {
1871            self.ultra_compact = true;
1872        }
1873        if local.tee_mode != TeeMode::default() {
1874            self.tee_mode = local.tee_mode;
1875        }
1876        if local.output_density != OutputDensity::default() {
1877            self.output_density = local.output_density;
1878        }
1879        if local.checkpoint_interval != 15 {
1880            self.checkpoint_interval = local.checkpoint_interval;
1881        }
1882        if !local.excluded_commands.is_empty() {
1883            self.excluded_commands.extend(local.excluded_commands);
1884        }
1885        if !local.passthrough_urls.is_empty() {
1886            self.passthrough_urls.extend(local.passthrough_urls);
1887        }
1888        if !local.custom_aliases.is_empty() {
1889            self.custom_aliases.extend(local.custom_aliases);
1890        }
1891        if local.slow_command_threshold_ms != 5000 {
1892            self.slow_command_threshold_ms = local.slow_command_threshold_ms;
1893        }
1894        if local.theme != "default" {
1895            self.theme = local.theme;
1896        }
1897        if !local.buddy_enabled {
1898            self.buddy_enabled = false;
1899        }
1900        if !local.enable_wakeup_ctx {
1901            self.enable_wakeup_ctx = false;
1902        }
1903        if !local.redirect_exclude.is_empty() {
1904            self.redirect_exclude.extend(local.redirect_exclude);
1905        }
1906        if !local.disabled_tools.is_empty() {
1907            self.disabled_tools.extend(local.disabled_tools);
1908        }
1909        if !local.extra_ignore_patterns.is_empty() {
1910            self.extra_ignore_patterns
1911                .extend(local.extra_ignore_patterns);
1912        }
1913        if local.rules_scope.is_some() {
1914            self.rules_scope = local.rules_scope;
1915        }
1916        if local.proxy.anthropic_upstream.is_some() {
1917            self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
1918        }
1919        if local.proxy.openai_upstream.is_some() {
1920            self.proxy.openai_upstream = local.proxy.openai_upstream;
1921        }
1922        if local.proxy.gemini_upstream.is_some() {
1923            self.proxy.gemini_upstream = local.proxy.gemini_upstream;
1924        }
1925        if !local.autonomy.enabled {
1926            self.autonomy.enabled = false;
1927        }
1928        if !local.autonomy.auto_preload {
1929            self.autonomy.auto_preload = false;
1930        }
1931        if !local.autonomy.auto_dedup {
1932            self.autonomy.auto_dedup = false;
1933        }
1934        if !local.autonomy.auto_related {
1935            self.autonomy.auto_related = false;
1936        }
1937        if !local.autonomy.auto_consolidate {
1938            self.autonomy.auto_consolidate = false;
1939        }
1940        if local.autonomy.silent_preload {
1941            self.autonomy.silent_preload = true;
1942        }
1943        if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1944            self.autonomy.silent_preload = false;
1945        }
1946        if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1947            self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1948        }
1949        if local.autonomy.consolidate_every_calls
1950            != AutonomyConfig::default().consolidate_every_calls
1951        {
1952            self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1953        }
1954        if local.autonomy.consolidate_cooldown_secs
1955            != AutonomyConfig::default().consolidate_cooldown_secs
1956        {
1957            self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1958        }
1959        if !local.autonomy.cognition_loop_enabled {
1960            self.autonomy.cognition_loop_enabled = false;
1961        }
1962        if local.autonomy.cognition_loop_interval_secs
1963            != AutonomyConfig::default().cognition_loop_interval_secs
1964        {
1965            self.autonomy.cognition_loop_interval_secs =
1966                local.autonomy.cognition_loop_interval_secs;
1967        }
1968        if local.autonomy.cognition_loop_max_steps
1969            != AutonomyConfig::default().cognition_loop_max_steps
1970        {
1971            self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
1972        }
1973        if local_toml.contains("compression_level") {
1974            self.compression_level = local.compression_level;
1975        }
1976        if local_toml.contains("terse_agent") {
1977            self.terse_agent = local.terse_agent;
1978        }
1979        if !local.archive.enabled {
1980            self.archive.enabled = false;
1981        }
1982        if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1983            self.archive.threshold_chars = local.archive.threshold_chars;
1984        }
1985        if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1986            self.archive.max_age_hours = local.archive.max_age_hours;
1987        }
1988        if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1989            self.archive.max_disk_mb = local.archive.max_disk_mb;
1990        }
1991        if !local.archive.ephemeral {
1992            self.archive.ephemeral = false;
1993        }
1994        let mem_def = MemoryPolicy::default();
1995        if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1996            self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1997        }
1998        if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1999            self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
2000        }
2001        if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
2002            self.memory.knowledge.max_history = local.memory.knowledge.max_history;
2003        }
2004        if local.memory.knowledge.contradiction_threshold
2005            != mem_def.knowledge.contradiction_threshold
2006        {
2007            self.memory.knowledge.contradiction_threshold =
2008                local.memory.knowledge.contradiction_threshold;
2009        }
2010
2011        if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
2012            self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
2013        }
2014        if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
2015        {
2016            self.memory.episodic.max_actions_per_episode =
2017                local.memory.episodic.max_actions_per_episode;
2018        }
2019        if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
2020            self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
2021        }
2022
2023        if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
2024            self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
2025        }
2026        if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
2027            self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
2028        }
2029        if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
2030            self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
2031        }
2032        if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
2033            self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
2034        }
2035
2036        if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
2037            self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
2038        }
2039        if local.memory.lifecycle.low_confidence_threshold
2040            != mem_def.lifecycle.low_confidence_threshold
2041        {
2042            self.memory.lifecycle.low_confidence_threshold =
2043                local.memory.lifecycle.low_confidence_threshold;
2044        }
2045        if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
2046            self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
2047        }
2048        if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
2049            self.memory.lifecycle.similarity_threshold =
2050                local.memory.lifecycle.similarity_threshold;
2051        }
2052
2053        if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
2054            self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
2055        }
2056        if !local.allow_paths.is_empty() {
2057            self.allow_paths.extend(local.allow_paths);
2058        }
2059        if !local.extra_roots.is_empty() {
2060            self.extra_roots.extend(local.extra_roots);
2061        }
2062        if local.minimal_overhead {
2063            self.minimal_overhead = true;
2064        }
2065        if local.shell_hook_disabled {
2066            self.shell_hook_disabled = true;
2067        }
2068        if local.shell_activation != ShellActivation::default() {
2069            self.shell_activation = local.shell_activation.clone();
2070        }
2071        if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
2072            self.bm25_max_cache_mb = local.bm25_max_cache_mb;
2073        }
2074        if local.memory_profile != MemoryProfile::default() {
2075            self.memory_profile = local.memory_profile;
2076        }
2077        if local.memory_cleanup != MemoryCleanup::default() {
2078            self.memory_cleanup = local.memory_cleanup;
2079        }
2080        if !local.shell_allowlist.is_empty() {
2081            self.shell_allowlist = local.shell_allowlist;
2082        }
2083        if !local.default_tool_categories.is_empty() {
2084            self.default_tool_categories = local.default_tool_categories;
2085        }
2086        if local.tool_profile.is_some() {
2087            self.tool_profile = local.tool_profile;
2088        }
2089        if !local.tools_enabled.is_empty() {
2090            self.tools_enabled = local.tools_enabled;
2091        }
2092        if local.no_degrade {
2093            self.no_degrade = true;
2094        }
2095        if local.profile.is_some() {
2096            self.profile = local.profile;
2097        }
2098        if local.proxy_timeout_ms.is_some() {
2099            self.proxy_timeout_ms = local.proxy_timeout_ms;
2100        }
2101    }
2102
2103    /// Persists the current config to the global config file.
2104    ///
2105    /// Preserves user comments, formatting, and unknown keys, keeps the file
2106    /// minimal (defaults that were never set on disk stay implicit), and writes
2107    /// atomically with a `.bak` backup so customizations are always recoverable.
2108    pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
2109        let path = Self::path().ok_or_else(|| {
2110            super::error::LeanCtxError::Config("cannot determine home directory".into())
2111        })?;
2112        if let Some(parent) = path.parent() {
2113            std::fs::create_dir_all(parent)?;
2114        }
2115        let content = toml::to_string_pretty(self)
2116            .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
2117        // Baseline = what loading an empty config yields. This honors serde's
2118        // field-level `#[serde(default)]` (which can diverge from the struct's
2119        // `Default` impl), so minimal mode skips exactly the keys that a fresh
2120        // load would produce — no spurious lines on save.
2121        let baseline = toml::from_str::<Self>("").unwrap_or_else(|_| Self::default());
2122        let defaults = toml::to_string_pretty(&baseline)
2123            .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
2124        crate::config_io::write_toml_preserving_minimal(&path, &content, &defaults)
2125            .map_err(super::error::LeanCtxError::Config)?;
2126        Ok(())
2127    }
2128
2129    /// Formats the current config as a human-readable string with file paths.
2130    pub fn show(&self) -> String {
2131        let global_path = Self::path().map_or_else(
2132            || "~/.lean-ctx/config.toml".to_string(),
2133            |p| p.to_string_lossy().to_string(),
2134        );
2135        let content = toml::to_string_pretty(self).unwrap_or_default();
2136        let mut out = format!("Global config: {global_path}\n\n{content}");
2137
2138        if let Some(root) = Self::find_project_root() {
2139            let local = Self::local_path(&root);
2140            if local.exists() {
2141                out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
2142            } else {
2143                out.push_str(&format!(
2144                    "\n\nLocal config: not found (create {} to override per-project)\n",
2145                    local.display()
2146                ));
2147            }
2148        }
2149        out
2150    }
2151}
2152
2153#[cfg(test)]
2154mod extra_roots_tests {
2155    use super::*;
2156
2157    #[test]
2158    fn default_is_empty() {
2159        let cfg = Config::default();
2160        assert!(cfg.extra_roots.is_empty());
2161    }
2162
2163    #[test]
2164    fn deserialization_from_toml() {
2165        let cfg: Config = toml::from_str(r#"extra_roots = ["/data/store", "/test/env"]"#).unwrap();
2166        assert_eq!(cfg.extra_roots, vec!["/data/store", "/test/env"]);
2167    }
2168
2169    #[test]
2170    fn merge_extends() {
2171        let mut base = Config {
2172            extra_roots: vec!["/base".to_string()],
2173            ..Config::default()
2174        };
2175        base.merge_local(r#"extra_roots = ["/local"]"#);
2176        assert_eq!(base.extra_roots, vec!["/base", "/local"]);
2177    }
2178}
2179
2180#[cfg(test)]
2181mod compression_level_tests {
2182    use super::*;
2183
2184    #[test]
2185    fn default_is_lite() {
2186        // Friendly default: plain-English concise guidance, not the symbolic
2187        // dense/expert-terse styles (those are opt-in power modes).
2188        assert_eq!(CompressionLevel::default(), CompressionLevel::Lite);
2189    }
2190
2191    #[test]
2192    fn to_components_off() {
2193        let (ta, od, crp, tm) = CompressionLevel::Off.to_components();
2194        assert_eq!(ta, TerseAgent::Off);
2195        assert_eq!(od, OutputDensity::Normal);
2196        assert_eq!(crp, "off");
2197        assert!(!tm);
2198    }
2199
2200    #[test]
2201    fn to_components_lite() {
2202        let (ta, od, crp, tm) = CompressionLevel::Lite.to_components();
2203        assert_eq!(ta, TerseAgent::Lite);
2204        assert_eq!(od, OutputDensity::Terse);
2205        assert_eq!(crp, "off");
2206        assert!(tm);
2207    }
2208
2209    #[test]
2210    fn to_components_standard() {
2211        let (ta, od, crp, tm) = CompressionLevel::Standard.to_components();
2212        assert_eq!(ta, TerseAgent::Full);
2213        assert_eq!(od, OutputDensity::Terse);
2214        assert_eq!(crp, "compact");
2215        assert!(tm);
2216    }
2217
2218    #[test]
2219    fn to_components_max() {
2220        let (ta, od, crp, tm) = CompressionLevel::Max.to_components();
2221        assert_eq!(ta, TerseAgent::Ultra);
2222        assert_eq!(od, OutputDensity::Ultra);
2223        assert_eq!(crp, "tdd");
2224        assert!(tm);
2225    }
2226
2227    #[test]
2228    fn from_legacy_ultra_agent_maps_to_max() {
2229        assert_eq!(
2230            CompressionLevel::from_legacy(&TerseAgent::Ultra, &OutputDensity::Normal),
2231            CompressionLevel::Max
2232        );
2233    }
2234
2235    #[test]
2236    fn from_legacy_ultra_density_maps_to_max() {
2237        assert_eq!(
2238            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Ultra),
2239            CompressionLevel::Max
2240        );
2241    }
2242
2243    #[test]
2244    fn from_legacy_full_agent_maps_to_standard() {
2245        assert_eq!(
2246            CompressionLevel::from_legacy(&TerseAgent::Full, &OutputDensity::Normal),
2247            CompressionLevel::Standard
2248        );
2249    }
2250
2251    #[test]
2252    fn from_legacy_lite_agent_maps_to_lite() {
2253        assert_eq!(
2254            CompressionLevel::from_legacy(&TerseAgent::Lite, &OutputDensity::Normal),
2255            CompressionLevel::Lite
2256        );
2257    }
2258
2259    #[test]
2260    fn from_legacy_terse_density_maps_to_lite() {
2261        assert_eq!(
2262            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Terse),
2263            CompressionLevel::Lite
2264        );
2265    }
2266
2267    #[test]
2268    fn from_legacy_both_off_maps_to_off() {
2269        assert_eq!(
2270            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Normal),
2271            CompressionLevel::Off
2272        );
2273    }
2274
2275    #[test]
2276    fn labels_match() {
2277        assert_eq!(CompressionLevel::Off.label(), "off");
2278        assert_eq!(CompressionLevel::Lite.label(), "lite");
2279        assert_eq!(CompressionLevel::Standard.label(), "standard");
2280        assert_eq!(CompressionLevel::Max.label(), "max");
2281    }
2282
2283    #[test]
2284    fn is_active_false_for_off() {
2285        assert!(!CompressionLevel::Off.is_active());
2286    }
2287
2288    #[test]
2289    fn is_active_true_for_all_others() {
2290        assert!(CompressionLevel::Lite.is_active());
2291        assert!(CompressionLevel::Standard.is_active());
2292        assert!(CompressionLevel::Max.is_active());
2293    }
2294
2295    #[test]
2296    fn deserialization_defaults_to_lite() {
2297        let cfg: Config = toml::from_str("").unwrap();
2298        assert_eq!(cfg.compression_level, CompressionLevel::Lite);
2299    }
2300
2301    #[test]
2302    fn deserialization_from_toml() {
2303        let cfg: Config = toml::from_str(r#"compression_level = "standard""#).unwrap();
2304        assert_eq!(cfg.compression_level, CompressionLevel::Standard);
2305    }
2306
2307    #[test]
2308    fn roundtrip_all_levels() {
2309        for level in [
2310            CompressionLevel::Off,
2311            CompressionLevel::Lite,
2312            CompressionLevel::Standard,
2313            CompressionLevel::Max,
2314        ] {
2315            let (ta, od, crp, tm) = level.to_components();
2316            assert!(!crp.is_empty());
2317            if level == CompressionLevel::Off {
2318                assert!(!tm);
2319                assert_eq!(ta, TerseAgent::Off);
2320                assert_eq!(od, OutputDensity::Normal);
2321            } else {
2322                assert!(tm);
2323            }
2324        }
2325    }
2326}
2327
2328#[cfg(test)]
2329mod memory_cleanup_tests {
2330    use super::*;
2331
2332    #[test]
2333    fn default_is_aggressive() {
2334        assert_eq!(MemoryCleanup::default(), MemoryCleanup::Aggressive);
2335    }
2336
2337    #[test]
2338    fn aggressive_ttl_is_300() {
2339        assert_eq!(MemoryCleanup::Aggressive.idle_ttl_secs(), 300);
2340    }
2341
2342    #[test]
2343    fn shared_ttl_is_1800() {
2344        assert_eq!(MemoryCleanup::Shared.idle_ttl_secs(), 1800);
2345    }
2346
2347    #[test]
2348    fn index_retention_multiplier_values() {
2349        assert!(
2350            (MemoryCleanup::Aggressive.index_retention_multiplier() - 1.0).abs() < f64::EPSILON
2351        );
2352        assert!((MemoryCleanup::Shared.index_retention_multiplier() - 3.0).abs() < f64::EPSILON);
2353    }
2354
2355    #[test]
2356    fn deserialization_defaults_to_aggressive() {
2357        let cfg: Config = toml::from_str("").unwrap();
2358        assert_eq!(cfg.memory_cleanup, MemoryCleanup::Aggressive);
2359    }
2360
2361    #[test]
2362    fn deserialization_from_toml() {
2363        let cfg: Config = toml::from_str(r#"memory_cleanup = "shared""#).unwrap();
2364        assert_eq!(cfg.memory_cleanup, MemoryCleanup::Shared);
2365    }
2366
2367    #[test]
2368    fn effective_uses_config_when_no_env() {
2369        let cfg = Config {
2370            memory_cleanup: MemoryCleanup::Shared,
2371            ..Default::default()
2372        };
2373        let eff = MemoryCleanup::effective(&cfg);
2374        assert_eq!(eff, MemoryCleanup::Shared);
2375    }
2376}
2377
2378#[cfg(test)]
2379mod simplified_config_tests {
2380    use super::*;
2381
2382    #[test]
2383    fn max_disk_mb_zero_means_disabled() {
2384        let cfg = Config::default();
2385        assert_eq!(cfg.max_disk_mb, 0);
2386        assert_eq!(cfg.max_disk_mb_effective(), 0);
2387    }
2388
2389    #[test]
2390    fn archive_derives_from_disk_budget() {
2391        let cfg = Config {
2392            max_disk_mb: 4000,
2393            ..Default::default()
2394        };
2395        assert_eq!(cfg.archive_max_disk_mb_effective(), 1000);
2396    }
2397
2398    #[test]
2399    fn archive_explicit_overrides_derived() {
2400        let cfg = Config {
2401            max_disk_mb: 4000,
2402            archive: ArchiveConfig {
2403                max_disk_mb: 800,
2404                ..Default::default()
2405            },
2406            ..Default::default()
2407        };
2408        assert_eq!(cfg.archive_max_disk_mb_effective(), 800);
2409    }
2410
2411    #[test]
2412    fn bm25_derives_from_disk_budget() {
2413        let cfg = Config {
2414            max_disk_mb: 4000,
2415            ..Default::default()
2416        };
2417        assert_eq!(cfg.bm25_max_cache_mb_effective(), 400);
2418    }
2419
2420    #[test]
2421    fn bm25_explicit_overrides_derived() {
2422        let cfg = Config {
2423            max_disk_mb: 4000,
2424            bm25_max_cache_mb: 256,
2425            ..Default::default()
2426        };
2427        assert_eq!(cfg.bm25_max_cache_mb_effective(), 256);
2428    }
2429
2430    #[test]
2431    fn bm25_pure_default_is_generous_not_ram_profile() {
2432        // No explicit cap and no disk budget: must fall back to the generous disk
2433        // default (512), NOT the RAM-profile value (which starved large repos and
2434        // caused perpetual cold rebuilds, issue #249).
2435        let cfg = Config {
2436            memory_profile: MemoryProfile::Balanced,
2437            ..Default::default()
2438        };
2439        assert_eq!(cfg.bm25_max_cache_mb_effective(), DEFAULT_BM25_PERSIST_MB);
2440    }
2441
2442    #[test]
2443    fn staleness_days_derives_archive_age() {
2444        let cfg = Config {
2445            max_staleness_days: 30,
2446            ..Default::default()
2447        };
2448        assert_eq!(cfg.archive_max_age_hours_effective(), 720);
2449    }
2450
2451    #[test]
2452    fn staleness_explicit_archive_age_overrides() {
2453        let cfg = Config {
2454            max_staleness_days: 30,
2455            archive: ArchiveConfig {
2456                max_age_hours: 96,
2457                ..Default::default()
2458            },
2459            ..Default::default()
2460        };
2461        assert_eq!(cfg.archive_max_age_hours_effective(), 96);
2462    }
2463
2464    #[test]
2465    fn no_budget_returns_defaults() {
2466        let cfg = Config::default();
2467        assert_eq!(
2468            cfg.archive_max_disk_mb_effective(),
2469            ArchiveConfig::default().max_disk_mb
2470        );
2471        assert_eq!(
2472            cfg.archive_max_age_hours_effective(),
2473            ArchiveConfig::default().max_age_hours
2474        );
2475    }
2476
2477    #[test]
2478    fn memory_limits_scale_with_disk_budget() {
2479        let cfg = Config {
2480            max_disk_mb: 2000,
2481            ..Default::default()
2482        };
2483        let policy = cfg.memory_policy_effective().unwrap();
2484        // factor = 2000/500 = 4.0
2485        assert_eq!(policy.knowledge.max_facts, 800);
2486        assert_eq!(policy.knowledge.max_patterns, 200);
2487        assert_eq!(policy.episodic.max_episodes, 2000);
2488        assert_eq!(policy.procedural.max_procedures, 400);
2489    }
2490
2491    #[test]
2492    fn memory_limits_clamped_at_max_factor() {
2493        let cfg = Config {
2494            max_disk_mb: 50_000,
2495            ..Default::default()
2496        };
2497        let policy = cfg.memory_policy_effective().unwrap();
2498        // factor clamped at 10.0
2499        assert_eq!(policy.knowledge.max_facts, 2000);
2500        assert_eq!(policy.episodic.max_episodes, 5000);
2501    }
2502
2503    #[test]
2504    fn memory_limits_unchanged_when_no_budget() {
2505        let cfg = Config::default();
2506        let policy = cfg.memory_policy_effective().unwrap();
2507        assert_eq!(policy.knowledge.max_facts, 200);
2508        assert_eq!(policy.episodic.max_episodes, 500);
2509    }
2510
2511    #[test]
2512    fn simplified_template_is_valid_toml() {
2513        let parsed: Result<toml::Table, _> = toml::from_str(crate::cli::SIMPLIFIED_TEMPLATE);
2514        assert!(parsed.is_ok(), "Template must be valid TOML");
2515    }
2516}
2517
2518#[cfg(test)]
2519mod setup_config_tests {
2520    use super::*;
2521
2522    #[test]
2523    fn default_is_none_for_rules_and_skills() {
2524        let cfg = SetupConfig::default();
2525        assert!(cfg.auto_inject_rules.is_none());
2526        assert!(cfg.auto_inject_skills.is_none());
2527        assert!(cfg.auto_update_mcp);
2528    }
2529
2530    #[test]
2531    fn explicit_true_injects() {
2532        let cfg = SetupConfig {
2533            auto_inject_rules: Some(true),
2534            auto_inject_skills: Some(true),
2535            auto_update_mcp: true,
2536        };
2537        assert!(cfg.should_inject_rules());
2538        assert!(cfg.should_inject_skills());
2539    }
2540
2541    #[test]
2542    fn explicit_false_skips() {
2543        let cfg = SetupConfig {
2544            auto_inject_rules: Some(false),
2545            auto_inject_skills: Some(false),
2546            auto_update_mcp: true,
2547        };
2548        assert!(!cfg.should_inject_rules());
2549        assert!(!cfg.should_inject_skills());
2550    }
2551
2552    #[test]
2553    fn deserialization_defaults_when_absent() {
2554        let cfg: Config = toml::from_str("").unwrap();
2555        assert!(cfg.setup.auto_inject_rules.is_none());
2556        assert!(cfg.setup.auto_inject_skills.is_none());
2557        assert!(cfg.setup.auto_update_mcp);
2558    }
2559
2560    #[test]
2561    fn deserialization_from_toml() {
2562        let cfg: Config = toml::from_str(
2563            r"
2564            [setup]
2565            auto_inject_rules = true
2566            auto_inject_skills = false
2567            auto_update_mcp = true
2568            ",
2569        )
2570        .unwrap();
2571        assert_eq!(cfg.setup.auto_inject_rules, Some(true));
2572        assert_eq!(cfg.setup.auto_inject_skills, Some(false));
2573        assert!(cfg.setup.auto_update_mcp);
2574    }
2575
2576    #[test]
2577    fn deserialization_null_values() {
2578        let cfg: Config = toml::from_str(
2579            r"
2580            [setup]
2581            auto_update_mcp = false
2582            ",
2583        )
2584        .unwrap();
2585        assert!(cfg.setup.auto_inject_rules.is_none());
2586        assert!(cfg.setup.auto_inject_skills.is_none());
2587        assert!(!cfg.setup.auto_update_mcp);
2588    }
2589
2590    #[test]
2591    fn roundtrip_serialize_deserialize() {
2592        let original = Config {
2593            setup: SetupConfig {
2594                auto_inject_rules: Some(true),
2595                auto_inject_skills: Some(false),
2596                auto_update_mcp: true,
2597            },
2598            ..Config::default()
2599        };
2600        let toml_str = toml::to_string_pretty(&original).unwrap();
2601        let parsed: Config = toml::from_str(&toml_str).unwrap();
2602        assert_eq!(parsed.setup.auto_inject_rules, Some(true));
2603        assert_eq!(parsed.setup.auto_inject_skills, Some(false));
2604        assert!(parsed.setup.auto_update_mcp);
2605    }
2606
2607    #[test]
2608    fn fresh_install_no_rules_should_not_inject() {
2609        let cfg = SetupConfig::default();
2610        // On a test machine without lean-ctx rules in home, None should resolve to false
2611        // (rules_already_present checks real filesystem — on CI this is always false)
2612        let result = cfg.should_inject_rules();
2613        // We can't assert false here because the test machine might have lean-ctx installed.
2614        // Instead, verify the method doesn't panic and returns a bool.
2615        let _ = result;
2616    }
2617}