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