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