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