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;
16mod shell_activation;
17
18pub use memory::{MemoryCleanup, MemoryGuardConfig, MemoryProfile, SavingsFooter};
19pub use proxy::{is_local_proxy_url, normalize_url, normalize_url_opt, ProxyConfig, ProxyProvider};
20pub use shell_activation::ShellActivation;
21
22/// Default BM25 cache cap from config (also used by `bm25_index` heuristics).
23pub fn default_bm25_max_cache_mb() -> u64 {
24    serde_defaults::default_bm25_max_cache_mb()
25}
26
27/// Controls when shell output is tee'd to disk for later retrieval.
28#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
29#[serde(rename_all = "lowercase")]
30pub enum TeeMode {
31    Never,
32    #[default]
33    Failures,
34    HighCompression,
35    Always,
36}
37
38/// Legacy: Controls agent output verbosity level injected into MCP instructions.
39/// Superseded by `CompressionLevel`. Kept for backward compatibility with old config.toml files.
40/// New setups use `compression_level` instead. See `CompressionLevel::effective()`.
41#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
42#[serde(rename_all = "lowercase")]
43pub enum TerseAgent {
44    #[default]
45    Off,
46    Lite,
47    Full,
48    Ultra,
49}
50
51impl TerseAgent {
52    /// Reads the terse-agent level from the `LEAN_CTX_TERSE_AGENT` env var.
53    pub fn from_env() -> Self {
54        match std::env::var("LEAN_CTX_TERSE_AGENT")
55            .unwrap_or_default()
56            .to_lowercase()
57            .as_str()
58        {
59            "lite" => Self::Lite,
60            "full" => Self::Full,
61            "ultra" => Self::Ultra,
62            _ => Self::Off,
63        }
64    }
65}
66
67/// Legacy: Controls how dense/compact MCP tool output is formatted.
68/// Superseded by `CompressionLevel`. Kept for backward compatibility with old config.toml files.
69/// New setups use `compression_level` instead. See `CompressionLevel::effective()`.
70#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
71#[serde(rename_all = "lowercase")]
72pub enum OutputDensity {
73    #[default]
74    Normal,
75    Terse,
76    Ultra,
77}
78
79impl OutputDensity {
80    /// Reads the output density from the `LEAN_CTX_OUTPUT_DENSITY` env var.
81    pub fn from_env() -> Self {
82        match std::env::var("LEAN_CTX_OUTPUT_DENSITY")
83            .unwrap_or_default()
84            .to_lowercase()
85            .as_str()
86        {
87            "terse" => Self::Terse,
88            "ultra" => Self::Ultra,
89            _ => Self::Normal,
90        }
91    }
92}
93
94/// Unified compression level that replaces the 4 separate legacy concepts:
95/// `terse_agent`, `output_density`, `terse_mode`, and `crp_mode`.
96///
97/// Controls how much detail tool responses include.
98#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
99#[serde(rename_all = "snake_case")]
100pub enum ResponseVerbosity {
101    #[default]
102    Full,
103    HeadersOnly,
104}
105
106impl ResponseVerbosity {
107    pub fn effective() -> Self {
108        if let Ok(v) = std::env::var("LEAN_CTX_RESPONSE_VERBOSITY") {
109            match v.trim().to_lowercase().as_str() {
110                "headers_only" | "headers" | "minimal" => return Self::HeadersOnly,
111                "full" | "" => return Self::Full,
112                _ => {}
113            }
114        }
115        Config::load().response_verbosity
116    }
117
118    pub fn is_headers_only(&self) -> bool {
119        matches!(self, Self::HeadersOnly)
120    }
121}
122
123/// Each level maps to specific component settings via `to_components()`.
124#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
125#[serde(rename_all = "lowercase")]
126pub enum CompressionLevel {
127    Off,
128    Lite,
129    #[default]
130    Standard,
131    Max,
132}
133
134impl CompressionLevel {
135    /// Decomposes the unified level into legacy component settings.
136    /// Returns (TerseAgent, OutputDensity, crp_mode_str, terse_mode_bool).
137    pub fn to_components(&self) -> (TerseAgent, OutputDensity, &'static str, bool) {
138        match self {
139            Self::Off => (TerseAgent::Off, OutputDensity::Normal, "off", false),
140            Self::Lite => (TerseAgent::Lite, OutputDensity::Terse, "off", true),
141            Self::Standard => (TerseAgent::Full, OutputDensity::Terse, "compact", true),
142            Self::Max => (TerseAgent::Ultra, OutputDensity::Ultra, "tdd", true),
143        }
144    }
145
146    /// Infers a `CompressionLevel` from legacy config keys for backward compatibility.
147    /// Priority: terse_agent > output_density (picks the highest implied level).
148    pub fn from_legacy(terse_agent: &TerseAgent, output_density: &OutputDensity) -> Self {
149        match (terse_agent, output_density) {
150            (TerseAgent::Ultra, _) | (_, OutputDensity::Ultra) => Self::Max,
151            (TerseAgent::Full, _) => Self::Standard,
152            (TerseAgent::Lite, _) | (_, OutputDensity::Terse) => Self::Lite,
153            _ => Self::Off,
154        }
155    }
156
157    /// Reads the compression level from the `LEAN_CTX_COMPRESSION` env var.
158    pub fn from_env() -> Option<Self> {
159        std::env::var("LEAN_CTX_COMPRESSION").ok().and_then(|v| {
160            match v.trim().to_lowercase().as_str() {
161                "off" => Some(Self::Off),
162                "lite" => Some(Self::Lite),
163                "standard" => Some(Self::Standard),
164                "max" => Some(Self::Max),
165                _ => None,
166            }
167        })
168    }
169
170    /// Returns the effective compression level with resolution order:
171    /// 0. Session-level degrade override (set by correction-loop feedback)
172    /// 1. `LEAN_CTX_COMPRESSION` env var
173    /// 2. `compression_level` in config
174    /// 3. Legacy `ultra_compact` flag (maps to `Max`)
175    /// 4. Legacy env vars (`LEAN_CTX_TERSE_AGENT`, `LEAN_CTX_OUTPUT_DENSITY`)
176    /// 5. Legacy config fields (`terse_agent`, `output_density`)
177    pub fn effective(config: &Config) -> Self {
178        if let Some(degraded) = Self::session_degrade_level() {
179            return degraded;
180        }
181        if let Some(env_level) = Self::from_env() {
182            return env_level;
183        }
184        if config.compression_level != Self::Off {
185            return config.compression_level.clone();
186        }
187        if config.ultra_compact {
188            return Self::Max;
189        }
190        let ta_env = TerseAgent::from_env();
191        let od_env = OutputDensity::from_env();
192        let ta = if ta_env == TerseAgent::Off {
193            config.terse_agent.clone()
194        } else {
195            ta_env
196        };
197        let od = if od_env == OutputDensity::Normal {
198            config.output_density.clone()
199        } else {
200            od_env
201        };
202        Self::from_legacy(&ta, &od)
203    }
204
205    /// Session-level degrade: correction loop detected, temporarily reduce compression.
206    /// 0 = no override, 1 = Off, 2 = Lite
207    pub fn session_degrade_level() -> Option<Self> {
208        match SESSION_DEGRADE_LEVEL.load(std::sync::atomic::Ordering::Relaxed) {
209            1 => Some(Self::Off),
210            2 => Some(Self::Lite),
211            _ => None,
212        }
213    }
214
215    /// Sets a session-level compression degrade (called by correction loop detection).
216    pub fn set_session_degrade(level: &Self) {
217        let val = match level {
218            Self::Off => 1u8,
219            Self::Lite => 2u8,
220            _ => 0u8,
221        };
222        SESSION_DEGRADE_LEVEL.store(val, std::sync::atomic::Ordering::Relaxed);
223    }
224
225    /// Clears the session-level degrade (recovery after correction rate drops).
226    pub fn clear_session_degrade() {
227        SESSION_DEGRADE_LEVEL.store(0, std::sync::atomic::Ordering::Relaxed);
228    }
229
230    pub fn from_str_label(s: &str) -> Option<Self> {
231        match s.trim().to_lowercase().as_str() {
232            "off" => Some(Self::Off),
233            "lite" => Some(Self::Lite),
234            "standard" | "std" => Some(Self::Standard),
235            "max" => Some(Self::Max),
236            _ => None,
237        }
238    }
239
240    pub fn is_active(&self) -> bool {
241        !matches!(self, Self::Off)
242    }
243
244    pub fn label(&self) -> &'static str {
245        match self {
246            Self::Off => "off",
247            Self::Lite => "lite",
248            Self::Standard => "standard",
249            Self::Max => "max",
250        }
251    }
252
253    pub fn description(&self) -> &'static str {
254        match self {
255            Self::Off => "No compression — full verbose output",
256            Self::Lite => "Light compression — concise output, basic terse filtering",
257            Self::Standard => {
258                "Standard compression — dense output, compact protocol, pattern-aware"
259            }
260            Self::Max => "Maximum compression — expert mode, TDD protocol, all layers active",
261        }
262    }
263}
264
265/// Global lean-ctx configuration loaded from `config.toml`, merged with project-local overrides.
266#[derive(Debug, Clone, Serialize, Deserialize)]
267#[serde(default)]
268pub struct Config {
269    pub ultra_compact: bool,
270    #[serde(default, deserialize_with = "serde_defaults::deserialize_tee_mode")]
271    pub tee_mode: TeeMode,
272    #[serde(default)]
273    pub output_density: OutputDensity,
274    pub checkpoint_interval: u32,
275    pub excluded_commands: Vec<String>,
276    pub passthrough_urls: Vec<String>,
277    pub custom_aliases: Vec<AliasEntry>,
278    /// Commands taking longer than this threshold (ms) are recorded in the slow log.
279    /// Set to 0 to disable slow logging.
280    pub slow_command_threshold_ms: u64,
281    #[serde(default = "serde_defaults::default_theme")]
282    pub theme: String,
283    #[serde(default)]
284    pub cloud: CloudConfig,
285    #[serde(default)]
286    pub autonomy: AutonomyConfig,
287    #[serde(default)]
288    pub proxy: ProxyConfig,
289    /// Whether the API proxy is enabled. Tri-state:
290    /// - None: undecided (fresh install, will prompt on interactive setup)
291    /// - Some(true): user opted in, proxy managed by lean-ctx
292    /// - Some(false): user opted out, never touch proxy or endpoints
293    #[serde(default)]
294    pub proxy_enabled: Option<bool>,
295    #[serde(default = "serde_defaults::default_buddy_enabled")]
296    pub buddy_enabled: bool,
297    #[serde(default = "serde_defaults::default_true")]
298    pub enable_wakeup_ctx: bool,
299    #[serde(default)]
300    pub redirect_exclude: Vec<String>,
301    /// Tools to exclude from the MCP tool list returned by list_tools.
302    /// Accepts exact tool names (e.g. `["ctx_graph", "ctx_agent"]`).
303    /// Empty by default — all tools listed, no behaviour change.
304    #[serde(default)]
305    pub disabled_tools: Vec<String>,
306    #[serde(default)]
307    pub loop_detection: LoopDetectionConfig,
308    /// Controls where lean-ctx installs agent rule files.
309    /// Values: "both" (default), "global" (home-dir only), "project" (repo-local only).
310    /// Override via LEAN_CTX_RULES_SCOPE env var.
311    #[serde(default)]
312    pub rules_scope: Option<String>,
313    /// Extra glob patterns to ignore in graph/overview/preload (repo-local).
314    /// Example: `["externals/**", "target/**", "temp/**"]`
315    #[serde(default)]
316    pub extra_ignore_patterns: Vec<String>,
317    /// Controls agent output verbosity via instructions injection.
318    /// Values: "off" (default), "lite", "full", "ultra".
319    /// Override via LEAN_CTX_TERSE_AGENT env var.
320    #[serde(default)]
321    pub terse_agent: TerseAgent,
322    /// Unified compression level (replaces separate terse_agent + output_density).
323    /// Values: "off" (default), "lite", "standard", "max".
324    /// Override via LEAN_CTX_COMPRESSION env var.
325    #[serde(default)]
326    pub compression_level: CompressionLevel,
327    /// Archive configuration for zero-loss compression.
328    #[serde(default)]
329    pub archive: ArchiveConfig,
330    /// Memory policy (knowledge/episodic/procedural/lifecycle budgets & thresholds).
331    #[serde(default)]
332    pub memory: MemoryPolicy,
333    /// Additional paths allowed by PathJail (absolute).
334    /// Useful for multi-project workspaces where the jail root is a parent directory.
335    /// Override via LEAN_CTX_ALLOW_PATH env var (path-list separator).
336    #[serde(default)]
337    pub allow_paths: Vec<String>,
338    /// Enable content-defined chunking (Rabin-Karp) for cache-optimal output ordering.
339    /// Stable chunks are emitted first to maximize prompt cache hits.
340    #[serde(default)]
341    pub content_defined_chunking: bool,
342    /// Skip session/knowledge/gotcha blocks in MCP instructions to minimize token overhead.
343    /// Override via LEAN_CTX_MINIMAL env var.
344    #[serde(default)]
345    pub minimal_overhead: bool,
346    /// Disable shell hook injection (the _lc() function that wraps CLI commands).
347    /// Override via LEAN_CTX_NO_HOOK env var.
348    #[serde(default)]
349    pub shell_hook_disabled: bool,
350    /// Controls when the shell hook auto-activates aliases.
351    /// - `always`: (Default) Aliases active in every interactive shell.
352    /// - `agents-only`: Aliases only active when an AI agent env var is detected.
353    /// - `off`: Aliases never auto-activate (user must call `lean-ctx-on` manually).
354    ///
355    /// Override via `LEAN_CTX_SHELL_ACTIVATION` env var.
356    #[serde(default)]
357    pub shell_activation: ShellActivation,
358    /// Disable the daily version check against leanctx.com/version.txt.
359    /// Override via LEAN_CTX_NO_UPDATE_CHECK env var.
360    #[serde(default)]
361    pub update_check_disabled: bool,
362    #[serde(default)]
363    pub updates: UpdatesConfig,
364    /// Maximum BM25 cache file size in MB. Indexes exceeding this are quarantined on load
365    /// and refused on save. Override via LEAN_CTX_BM25_MAX_CACHE_MB env var.
366    #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
367    pub bm25_max_cache_mb: u64,
368    /// Maximum number of files scanned by the lightweight JSON graph index.
369    /// Increase for large monorepos. Default: 5000.
370    #[serde(default = "serde_defaults::default_graph_index_max_files")]
371    pub graph_index_max_files: u64,
372    /// Controls RAM vs feature trade-off. Values: "low", "balanced" (default), "performance".
373    /// Override via LEAN_CTX_MEMORY_PROFILE env var.
374    #[serde(default)]
375    pub memory_profile: MemoryProfile,
376    /// Controls how aggressively memory is freed when idle.
377    /// Values: "aggressive" (default, 5 min TTL), "shared" (30 min TTL for multi-IDE use).
378    /// Override via LEAN_CTX_MEMORY_CLEANUP env var.
379    #[serde(default)]
380    pub memory_cleanup: MemoryCleanup,
381    /// Maximum percentage of system RAM that lean-ctx may use (default: 5).
382    /// Override via LEAN_CTX_MAX_RAM_PERCENT env var.
383    #[serde(default = "serde_defaults::default_max_ram_percent")]
384    pub max_ram_percent: u8,
385    /// Controls visibility of token savings footers in tool output.
386    /// Values: "never" (default, suppress everywhere), "always", "auto" (legacy compatibility).
387    /// Override via LEAN_CTX_SAVINGS_FOOTER env var.
388    #[serde(default)]
389    pub savings_footer: SavingsFooter,
390    /// Explicit project root override. When set, lean-ctx uses this instead of auto-detection.
391    /// This prevents accidental home-directory scans when running from $HOME.
392    /// Override via LEAN_CTX_PROJECT_ROOT env var.
393    #[serde(default)]
394    pub project_root: Option<String>,
395    /// LSP server overrides. Map language name to custom binary path.
396    /// Example: `[lsp]\nrust = "/opt/rust-analyzer"\npython = "~/.venvs/main/bin/pylsp"`
397    #[serde(default)]
398    pub lsp: std::collections::HashMap<String, String>,
399    /// Per-IDE allowed paths. Restricts which directories lean-ctx will scan/index for each IDE.
400    /// Example: `[ide_paths]\ncursor = ["/home/user/projects/app1"]\ncodex = ["/home/user/codex"]`
401    /// When set, only these paths are indexed for the matching agent. Global `allow_paths` still applies.
402    #[serde(default)]
403    pub ide_paths: HashMap<String, Vec<String>>,
404    /// Custom model context window overrides.
405    /// Example: `[model_context_windows]\n"my-custom-model" = 500000`
406    #[serde(default)]
407    pub model_context_windows: HashMap<String, usize>,
408    /// Controls how much detail tool responses include.
409    ///
410    /// - `full` (default): complete compressed output
411    /// - `headers_only`: metadata line only (path, mode, token count)
412    ///
413    /// Override via `LEAN_CTX_RESPONSE_VERBOSITY` env var.
414    #[serde(default)]
415    pub response_verbosity: ResponseVerbosity,
416    /// Bypass hint mode. When agents use native Read/Grep instead of lean-ctx tools,
417    /// a hint is appended to the next tool response.
418    /// Values: "on" (default), "off", "aggressive" (hint on every call, no cooldown).
419    /// Override via LEAN_CTX_BYPASS_HINTS env var.
420    #[serde(default)]
421    pub bypass_hints: Option<String>,
422    /// Cache policy for ctx_read. Controls behavior on cache hits.
423    /// Values: "aggressive" (default, 13-tok stubs + compaction-aware reset),
424    /// "safe" (delivers map instead of stub), "off" (no caching, always disk read).
425    /// Override via LEAN_CTX_CACHE_POLICY env var.
426    #[serde(default)]
427    pub cache_policy: Option<String>,
428    /// Cross-project boundary policy.
429    /// Controls whether cross-project search/import is allowed and whether access is audited.
430    #[serde(default)]
431    pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
432    #[serde(default)]
433    pub secret_detection: SecretDetectionConfig,
434    /// Allow automatic project-root re-rooting when absolute paths outside the jail are seen.
435    /// When false (default), absolute paths outside the jail are rejected without re-rooting.
436    /// Override via LEAN_CTX_ALLOW_REROOT env var.
437    #[serde(default)]
438    pub allow_auto_reroot: bool,
439    /// Disable PathJail entirely. Set to false to allow all paths.
440    /// Useful in container/Docker environments. Override via LEAN_CTX_NO_JAIL=1.
441    #[serde(default)]
442    pub path_jail: Option<bool>,
443    /// Sandbox level for code execution (ctx_exec).
444    /// 0 = subprocess only (current), 1 = OS-level restriction (Seatbelt/Landlock).
445    /// Override via LEAN_CTX_SANDBOX_LEVEL env var.
446    #[serde(default)]
447    pub sandbox_level: u8,
448    /// When true, large tool outputs (>4000 chars) are stored as references
449    /// and a short URI is returned instead of the full content.
450    /// Override via LEAN_CTX_REFERENCE_RESULTS env var.
451    #[serde(default)]
452    pub reference_results: bool,
453    /// Default per-agent token budget. 0 means unlimited.
454    /// Override per-agent via ctx_session or programmatically.
455    #[serde(default)]
456    pub agent_token_budget: usize,
457    /// Optional shell command allowlist. When non-empty, only commands whose base binary
458    /// is in this list are permitted by ctx_shell. Empty = no allowlist (blocklist only).
459    /// Example: `shell_allowlist = ["git", "cargo", "npm", "ls", "cat", "grep", "find", "echo"]`
460    /// Override via LEAN_CTX_SHELL_ALLOWLIST env var (comma-separated).
461    #[serde(default)]
462    pub shell_allowlist: Vec<String>,
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize)]
466#[serde(default)]
467pub struct SecretDetectionConfig {
468    pub enabled: bool,
469    pub redact: bool,
470    pub custom_patterns: Vec<String>,
471}
472
473impl Default for SecretDetectionConfig {
474    fn default() -> Self {
475        Self {
476            enabled: true,
477            redact: false,
478            custom_patterns: Vec::new(),
479        }
480    }
481}
482
483/// Settings for the zero-loss compression archive (large tool outputs saved to disk).
484#[derive(Debug, Clone, Serialize, Deserialize)]
485#[serde(default)]
486pub struct ArchiveConfig {
487    pub enabled: bool,
488    pub threshold_chars: usize,
489    pub max_age_hours: u64,
490    pub max_disk_mb: u64,
491}
492
493impl Default for ArchiveConfig {
494    fn default() -> Self {
495        Self {
496            enabled: true,
497            threshold_chars: 4096,
498            max_age_hours: 48,
499            max_disk_mb: 500,
500        }
501    }
502}
503
504/// Controls autonomous background behaviors (preload, dedup, consolidation).
505#[derive(Debug, Clone, Serialize, Deserialize)]
506#[serde(default)]
507pub struct AutonomyConfig {
508    pub enabled: bool,
509    pub auto_preload: bool,
510    pub auto_dedup: bool,
511    pub auto_related: bool,
512    pub auto_consolidate: bool,
513    pub silent_preload: bool,
514    pub dedup_threshold: usize,
515    pub consolidate_every_calls: u32,
516    pub consolidate_cooldown_secs: u64,
517    #[serde(default = "serde_defaults::default_true")]
518    pub cognition_loop_enabled: bool,
519    #[serde(default = "serde_defaults::default_cognition_loop_interval")]
520    pub cognition_loop_interval_secs: u64,
521    #[serde(default = "serde_defaults::default_cognition_loop_max_steps")]
522    pub cognition_loop_max_steps: u8,
523}
524
525impl Default for AutonomyConfig {
526    fn default() -> Self {
527        Self {
528            enabled: true,
529            auto_preload: true,
530            auto_dedup: true,
531            auto_related: true,
532            auto_consolidate: true,
533            silent_preload: true,
534            dedup_threshold: 8,
535            consolidate_every_calls: 25,
536            consolidate_cooldown_secs: 120,
537            cognition_loop_enabled: true,
538            cognition_loop_interval_secs: 3600,
539            cognition_loop_max_steps: 8,
540        }
541    }
542}
543
544/// Controls automatic update behavior. All defaults are OFF — auto-updates
545/// require explicit opt-in via `lean-ctx setup` or `lean-ctx update --schedule`.
546#[derive(Debug, Clone, Serialize, Deserialize)]
547#[serde(default)]
548pub struct UpdatesConfig {
549    pub auto_update: bool,
550    pub check_interval_hours: u64,
551    pub notify_only: bool,
552}
553
554impl Default for UpdatesConfig {
555    fn default() -> Self {
556        Self {
557            auto_update: false,
558            check_interval_hours: 6,
559            notify_only: false,
560        }
561    }
562}
563
564impl UpdatesConfig {
565    pub fn from_env() -> Self {
566        let mut cfg = Self::default();
567        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_UPDATE") {
568            cfg.auto_update = v == "1" || v.eq_ignore_ascii_case("true");
569        }
570        if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_INTERVAL_HOURS") {
571            if let Ok(h) = v.parse::<u64>() {
572                cfg.check_interval_hours = h.clamp(1, 168);
573            }
574        }
575        if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_NOTIFY_ONLY") {
576            cfg.notify_only = v == "1" || v.eq_ignore_ascii_case("true");
577        }
578        cfg
579    }
580}
581
582impl AutonomyConfig {
583    /// Creates an autonomy config from env vars, falling back to defaults.
584    pub fn from_env() -> Self {
585        let mut cfg = Self::default();
586        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
587            if v == "false" || v == "0" {
588                cfg.enabled = false;
589            }
590        }
591        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
592            cfg.auto_preload = v != "false" && v != "0";
593        }
594        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
595            cfg.auto_dedup = v != "false" && v != "0";
596        }
597        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
598            cfg.auto_related = v != "false" && v != "0";
599        }
600        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
601            cfg.auto_consolidate = v != "false" && v != "0";
602        }
603        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
604            cfg.silent_preload = v != "false" && v != "0";
605        }
606        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
607            if let Ok(n) = v.parse() {
608                cfg.dedup_threshold = n;
609            }
610        }
611        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
612            if let Ok(n) = v.parse() {
613                cfg.consolidate_every_calls = n;
614            }
615        }
616        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
617            if let Ok(n) = v.parse() {
618                cfg.consolidate_cooldown_secs = n;
619            }
620        }
621        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
622            cfg.cognition_loop_enabled = v != "false" && v != "0";
623        }
624        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
625            if let Ok(n) = v.parse() {
626                cfg.cognition_loop_interval_secs = n;
627            }
628        }
629        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
630            if let Ok(n) = v.parse() {
631                cfg.cognition_loop_max_steps = n;
632            }
633        }
634        cfg
635    }
636
637    /// Loads autonomy config from disk, with env var overrides applied.
638    pub fn load() -> Self {
639        let file_cfg = Config::load().autonomy;
640        let mut cfg = file_cfg;
641        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
642            if v == "false" || v == "0" {
643                cfg.enabled = false;
644            }
645        }
646        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
647            cfg.auto_preload = v != "false" && v != "0";
648        }
649        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
650            cfg.auto_dedup = v != "false" && v != "0";
651        }
652        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
653            cfg.auto_related = v != "false" && v != "0";
654        }
655        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
656            cfg.silent_preload = v != "false" && v != "0";
657        }
658        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
659            if let Ok(n) = v.parse() {
660                cfg.dedup_threshold = n;
661            }
662        }
663        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
664            cfg.cognition_loop_enabled = v != "false" && v != "0";
665        }
666        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
667            if let Ok(n) = v.parse() {
668                cfg.cognition_loop_interval_secs = n;
669            }
670        }
671        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
672            if let Ok(n) = v.parse() {
673                cfg.cognition_loop_max_steps = n;
674            }
675        }
676        cfg
677    }
678}
679
680/// Cloud sync and contribution settings (pattern sharing, model pulls).
681#[derive(Debug, Clone, Serialize, Deserialize, Default)]
682#[serde(default)]
683pub struct CloudConfig {
684    pub contribute_enabled: bool,
685    pub last_contribute: Option<String>,
686    pub last_sync: Option<String>,
687    pub last_gain_sync: Option<String>,
688    pub last_model_pull: Option<String>,
689}
690
691/// A user-defined command alias mapping for shell compression patterns.
692#[derive(Debug, Clone, Serialize, Deserialize)]
693pub struct AliasEntry {
694    pub command: String,
695    pub alias: String,
696}
697
698/// Thresholds for detecting and throttling repetitive agent tool call loops.
699#[derive(Debug, Clone, Serialize, Deserialize)]
700#[serde(default)]
701pub struct LoopDetectionConfig {
702    pub normal_threshold: u32,
703    pub reduced_threshold: u32,
704    pub blocked_threshold: u32,
705    pub window_secs: u64,
706    pub search_group_limit: u32,
707    pub tool_total_limits: HashMap<String, u32>,
708}
709
710impl Default for LoopDetectionConfig {
711    fn default() -> Self {
712        let mut tool_total_limits = HashMap::new();
713        tool_total_limits.insert("ctx_read".to_string(), 100);
714        tool_total_limits.insert("ctx_search".to_string(), 80);
715        tool_total_limits.insert("ctx_shell".to_string(), 50);
716        tool_total_limits.insert("ctx_semantic_search".to_string(), 60);
717        Self {
718            normal_threshold: 2,
719            reduced_threshold: 4,
720            blocked_threshold: 0,
721            window_secs: 300,
722            search_group_limit: 10,
723            tool_total_limits,
724        }
725    }
726}
727
728impl Default for Config {
729    fn default() -> Self {
730        Self {
731            ultra_compact: false,
732            tee_mode: TeeMode::default(),
733            output_density: OutputDensity::default(),
734            checkpoint_interval: 15,
735            excluded_commands: Vec::new(),
736            passthrough_urls: Vec::new(),
737            custom_aliases: Vec::new(),
738            slow_command_threshold_ms: 5000,
739            theme: serde_defaults::default_theme(),
740            cloud: CloudConfig::default(),
741            autonomy: AutonomyConfig::default(),
742            proxy: ProxyConfig::default(),
743            proxy_enabled: None,
744            buddy_enabled: serde_defaults::default_buddy_enabled(),
745            enable_wakeup_ctx: true,
746            redirect_exclude: Vec::new(),
747            disabled_tools: Vec::new(),
748            loop_detection: LoopDetectionConfig::default(),
749            rules_scope: None,
750            extra_ignore_patterns: Vec::new(),
751            terse_agent: TerseAgent::default(),
752            compression_level: CompressionLevel::default(),
753            archive: ArchiveConfig::default(),
754            memory: MemoryPolicy::default(),
755            allow_paths: Vec::new(),
756            content_defined_chunking: false,
757            minimal_overhead: false,
758            shell_hook_disabled: false,
759            shell_activation: ShellActivation::default(),
760            update_check_disabled: false,
761            updates: UpdatesConfig::default(),
762            graph_index_max_files: serde_defaults::default_graph_index_max_files(),
763            bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
764            memory_profile: MemoryProfile::default(),
765            memory_cleanup: MemoryCleanup::default(),
766            max_ram_percent: serde_defaults::default_max_ram_percent(),
767            savings_footer: SavingsFooter::default(),
768            project_root: None,
769            lsp: std::collections::HashMap::new(),
770            ide_paths: HashMap::new(),
771            model_context_windows: HashMap::new(),
772            response_verbosity: ResponseVerbosity::default(),
773            bypass_hints: None,
774            cache_policy: None,
775            boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
776            secret_detection: SecretDetectionConfig::default(),
777            allow_auto_reroot: false,
778            path_jail: None,
779            sandbox_level: 0,
780            reference_results: false,
781            agent_token_budget: 0,
782            shell_allowlist: Vec::new(),
783        }
784    }
785}
786
787/// Where agent rule files are installed: global home dir, project-local, or both.
788#[derive(Debug, Clone, Copy, PartialEq, Eq)]
789pub enum RulesScope {
790    Both,
791    Global,
792    Project,
793}
794
795impl Config {
796    /// Returns the effective rules scope, preferring env var over config file.
797    pub fn rules_scope_effective(&self) -> RulesScope {
798        let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
799            .ok()
800            .or_else(|| self.rules_scope.clone())
801            .unwrap_or_default();
802        match raw.trim().to_lowercase().as_str() {
803            "global" => RulesScope::Global,
804            "project" => RulesScope::Project,
805            _ => RulesScope::Both,
806        }
807    }
808
809    fn parse_disabled_tools_env(val: &str) -> Vec<String> {
810        val.split(',')
811            .map(|s| s.trim().to_string())
812            .filter(|s| !s.is_empty())
813            .collect()
814    }
815
816    /// Returns the effective disabled tools list, preferring env var over config file.
817    pub fn disabled_tools_effective(&self) -> Vec<String> {
818        if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
819            Self::parse_disabled_tools_env(&val)
820        } else {
821            self.disabled_tools.clone()
822        }
823    }
824
825    /// Returns `true` if minimal overhead is enabled via env var or config.
826    pub fn minimal_overhead_effective(&self) -> bool {
827        std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
828    }
829
830    /// Returns `true` if minimal overhead should be enabled for this MCP client.
831    ///
832    /// This is a superset of `minimal_overhead_effective()`:
833    /// - `LEAN_CTX_OVERHEAD_MODE=minimal` forces minimal overhead
834    /// - `LEAN_CTX_OVERHEAD_MODE=full` disables client/model heuristics (still honors LEAN_CTX_MINIMAL / config)
835    /// - In auto mode (default), certain low-context clients/models are treated as minimal to prevent
836    ///   large metadata blocks from destabilizing smaller context windows (e.g. Hermes + MiniMax).
837    pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
838        if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
839            match raw.trim().to_lowercase().as_str() {
840                "minimal" => return true,
841                "full" => return self.minimal_overhead_effective(),
842                _ => {}
843            }
844        }
845
846        if self.minimal_overhead_effective() {
847            return true;
848        }
849
850        let client_lower = client_name.trim().to_lowercase();
851        if !client_lower.is_empty() {
852            if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
853                for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
854                    if !needle.is_empty() && client_lower.contains(&needle) {
855                        return true;
856                    }
857                }
858            } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
859                return true;
860            }
861        }
862
863        let model = std::env::var("LEAN_CTX_MODEL")
864            .or_else(|_| std::env::var("LCTX_MODEL"))
865            .unwrap_or_default();
866        let model = model.trim().to_lowercase();
867        if !model.is_empty() {
868            let m = model.replace(['_', ' '], "-");
869            if m.contains("minimax")
870                || m.contains("mini-max")
871                || m.contains("m2.7")
872                || m.contains("m2-7")
873            {
874                return true;
875            }
876        }
877
878        false
879    }
880
881    /// Returns `true` if shell hook injection is disabled via env var or config.
882    pub fn shell_hook_disabled_effective(&self) -> bool {
883        std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
884    }
885
886    /// Returns the effective shell activation mode (env var > config > default).
887    pub fn shell_activation_effective(&self) -> ShellActivation {
888        ShellActivation::effective(self)
889    }
890
891    /// Returns `true` if the daily update check is disabled via env var or config.
892    pub fn update_check_disabled_effective(&self) -> bool {
893        std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
894    }
895
896    pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
897        let mut policy = self.memory.clone();
898        policy.apply_env_overrides();
899        policy.validate()?;
900        Ok(policy)
901    }
902}
903
904#[cfg(test)]
905mod disabled_tools_tests {
906    use super::*;
907
908    #[test]
909    fn config_field_default_is_empty() {
910        let cfg = Config::default();
911        assert!(cfg.disabled_tools.is_empty());
912    }
913
914    #[test]
915    fn effective_returns_config_field_when_no_env_var() {
916        // Only meaningful when LEAN_CTX_DISABLED_TOOLS is unset; skip otherwise.
917        if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
918            return;
919        }
920        let cfg = Config {
921            disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
922            ..Default::default()
923        };
924        assert_eq!(
925            cfg.disabled_tools_effective(),
926            vec!["ctx_graph", "ctx_agent"]
927        );
928    }
929
930    #[test]
931    fn parse_env_basic() {
932        let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
933        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
934    }
935
936    #[test]
937    fn parse_env_trims_whitespace_and_skips_empty() {
938        let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
939        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
940    }
941
942    #[test]
943    fn parse_env_single_entry() {
944        let result = Config::parse_disabled_tools_env("ctx_graph");
945        assert_eq!(result, vec!["ctx_graph"]);
946    }
947
948    #[test]
949    fn parse_env_empty_string_returns_empty() {
950        let result = Config::parse_disabled_tools_env("");
951        assert!(result.is_empty());
952    }
953
954    #[test]
955    fn disabled_tools_deserialization_defaults_to_empty() {
956        let cfg: Config = toml::from_str("").unwrap();
957        assert!(cfg.disabled_tools.is_empty());
958    }
959
960    #[test]
961    fn disabled_tools_deserialization_from_toml() {
962        let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
963        assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
964    }
965}
966
967#[cfg(test)]
968mod rules_scope_tests {
969    use super::*;
970
971    #[test]
972    fn default_is_both() {
973        let cfg = Config::default();
974        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
975    }
976
977    #[test]
978    fn config_global() {
979        let cfg = Config {
980            rules_scope: Some("global".to_string()),
981            ..Default::default()
982        };
983        assert_eq!(cfg.rules_scope_effective(), RulesScope::Global);
984    }
985
986    #[test]
987    fn config_project() {
988        let cfg = Config {
989            rules_scope: Some("project".to_string()),
990            ..Default::default()
991        };
992        assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
993    }
994
995    #[test]
996    fn unknown_value_falls_back_to_both() {
997        let cfg = Config {
998            rules_scope: Some("nonsense".to_string()),
999            ..Default::default()
1000        };
1001        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1002    }
1003
1004    #[test]
1005    fn deserialization_none_by_default() {
1006        let cfg: Config = toml::from_str("").unwrap();
1007        assert!(cfg.rules_scope.is_none());
1008        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1009    }
1010
1011    #[test]
1012    fn deserialization_from_toml() {
1013        let cfg: Config = toml::from_str(r#"rules_scope = "project""#).unwrap();
1014        assert_eq!(cfg.rules_scope.as_deref(), Some("project"));
1015        assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1016    }
1017}
1018
1019#[cfg(test)]
1020mod loop_detection_config_tests {
1021    use super::*;
1022
1023    #[test]
1024    fn defaults_are_reasonable() {
1025        let cfg = LoopDetectionConfig::default();
1026        assert_eq!(cfg.normal_threshold, 2);
1027        assert_eq!(cfg.reduced_threshold, 4);
1028        // 0 = blocking disabled by default (LeanCTX philosophy: always help, never block)
1029        assert_eq!(cfg.blocked_threshold, 0);
1030        assert_eq!(cfg.window_secs, 300);
1031        assert_eq!(cfg.search_group_limit, 10);
1032    }
1033
1034    #[test]
1035    fn deserialization_defaults_when_missing() {
1036        let cfg: Config = toml::from_str("").unwrap();
1037        // 0 = blocking disabled by default
1038        assert_eq!(cfg.loop_detection.blocked_threshold, 0);
1039        assert_eq!(cfg.loop_detection.search_group_limit, 10);
1040    }
1041
1042    #[test]
1043    fn deserialization_from_toml() {
1044        let cfg: Config = toml::from_str(
1045            r"
1046            [loop_detection]
1047            normal_threshold = 1
1048            reduced_threshold = 3
1049            blocked_threshold = 5
1050            window_secs = 120
1051            search_group_limit = 8
1052            ",
1053        )
1054        .unwrap();
1055        assert_eq!(cfg.loop_detection.normal_threshold, 1);
1056        assert_eq!(cfg.loop_detection.reduced_threshold, 3);
1057        assert_eq!(cfg.loop_detection.blocked_threshold, 5);
1058        assert_eq!(cfg.loop_detection.window_secs, 120);
1059        assert_eq!(cfg.loop_detection.search_group_limit, 8);
1060    }
1061
1062    #[test]
1063    fn partial_override_keeps_defaults() {
1064        let cfg: Config = toml::from_str(
1065            r"
1066            [loop_detection]
1067            blocked_threshold = 10
1068            ",
1069        )
1070        .unwrap();
1071        assert_eq!(cfg.loop_detection.blocked_threshold, 10);
1072        assert_eq!(cfg.loop_detection.normal_threshold, 2);
1073        assert_eq!(cfg.loop_detection.search_group_limit, 10);
1074    }
1075}
1076
1077impl Config {
1078    /// Returns the path to the global config file (`~/.lean-ctx/config.toml`).
1079    pub fn path() -> Option<PathBuf> {
1080        crate::core::data_dir::lean_ctx_data_dir()
1081            .ok()
1082            .map(|d| d.join("config.toml"))
1083    }
1084
1085    /// Returns the path to the project-local config override file.
1086    pub fn local_path(project_root: &str) -> PathBuf {
1087        PathBuf::from(project_root).join(".lean-ctx.toml")
1088    }
1089
1090    fn find_project_root() -> Option<String> {
1091        static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
1092        ROOT_CACHE
1093            .get_or_init(Self::find_project_root_inner)
1094            .clone()
1095    }
1096
1097    fn find_project_root_inner() -> Option<String> {
1098        if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
1099            if !env_root.is_empty() {
1100                return Some(env_root);
1101            }
1102        }
1103
1104        let cwd = std::env::current_dir().ok();
1105
1106        if let Some(root) =
1107            crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
1108        {
1109            let root_path = std::path::Path::new(&root);
1110            let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
1111            let has_marker = root_path.join(".git").exists()
1112                || root_path.join("Cargo.toml").exists()
1113                || root_path.join("package.json").exists()
1114                || root_path.join("go.mod").exists()
1115                || root_path.join("pyproject.toml").exists()
1116                || root_path.join(".lean-ctx.toml").exists();
1117
1118            if cwd_is_under_root || has_marker {
1119                return Some(root);
1120            }
1121        }
1122
1123        if let Some(ref cwd) = cwd {
1124            let git_root = std::process::Command::new("git")
1125                .args(["rev-parse", "--show-toplevel"])
1126                .current_dir(cwd)
1127                .stdout(std::process::Stdio::piped())
1128                .stderr(std::process::Stdio::null())
1129                .output()
1130                .ok()
1131                .and_then(|o| {
1132                    if o.status.success() {
1133                        String::from_utf8(o.stdout)
1134                            .ok()
1135                            .map(|s| s.trim().to_string())
1136                    } else {
1137                        None
1138                    }
1139                });
1140            if let Some(root) = git_root {
1141                return Some(root);
1142            }
1143            return Some(cwd.to_string_lossy().to_string());
1144        }
1145        None
1146    }
1147
1148    /// Loads config from disk with caching, merging global + project-local overrides.
1149    pub fn load() -> Self {
1150        static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
1151
1152        let Some(path) = Self::path() else {
1153            return Self::default();
1154        };
1155
1156        let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
1157
1158        let mtime = std::fs::metadata(&path)
1159            .and_then(|m| m.modified())
1160            .unwrap_or(SystemTime::UNIX_EPOCH);
1161
1162        let local_mtime = local_path
1163            .as_ref()
1164            .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
1165
1166        if let Ok(guard) = CACHE.lock() {
1167            if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
1168                if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
1169                    return cfg.clone();
1170                }
1171            }
1172        }
1173
1174        let mut cfg: Config = match std::fs::read_to_string(&path) {
1175            Ok(content) => match toml::from_str(&content) {
1176                Ok(c) => c,
1177                Err(e) => {
1178                    tracing::warn!("config parse error in {}: {e}", path.display());
1179                    Self::default()
1180                }
1181            },
1182            Err(_) => Self::default(),
1183        };
1184
1185        if let Some(ref lp) = local_path {
1186            if let Ok(local_content) = std::fs::read_to_string(lp) {
1187                cfg.merge_local(&local_content);
1188            }
1189        }
1190
1191        if let Ok(mut guard) = CACHE.lock() {
1192            *guard = Some((cfg.clone(), mtime, local_mtime));
1193        }
1194
1195        cfg
1196    }
1197
1198    fn merge_local(&mut self, local_toml: &str) {
1199        let local: Config = match toml::from_str(local_toml) {
1200            Ok(c) => c,
1201            Err(e) => {
1202                tracing::warn!("local config parse error: {e}");
1203                return;
1204            }
1205        };
1206        if local.ultra_compact {
1207            self.ultra_compact = true;
1208        }
1209        if local.tee_mode != TeeMode::default() {
1210            self.tee_mode = local.tee_mode;
1211        }
1212        if local.output_density != OutputDensity::default() {
1213            self.output_density = local.output_density;
1214        }
1215        if local.checkpoint_interval != 15 {
1216            self.checkpoint_interval = local.checkpoint_interval;
1217        }
1218        if !local.excluded_commands.is_empty() {
1219            self.excluded_commands.extend(local.excluded_commands);
1220        }
1221        if !local.passthrough_urls.is_empty() {
1222            self.passthrough_urls.extend(local.passthrough_urls);
1223        }
1224        if !local.custom_aliases.is_empty() {
1225            self.custom_aliases.extend(local.custom_aliases);
1226        }
1227        if local.slow_command_threshold_ms != 5000 {
1228            self.slow_command_threshold_ms = local.slow_command_threshold_ms;
1229        }
1230        if local.theme != "default" {
1231            self.theme = local.theme;
1232        }
1233        if !local.buddy_enabled {
1234            self.buddy_enabled = false;
1235        }
1236        if !local.enable_wakeup_ctx {
1237            self.enable_wakeup_ctx = false;
1238        }
1239        if !local.redirect_exclude.is_empty() {
1240            self.redirect_exclude.extend(local.redirect_exclude);
1241        }
1242        if !local.disabled_tools.is_empty() {
1243            self.disabled_tools.extend(local.disabled_tools);
1244        }
1245        if !local.extra_ignore_patterns.is_empty() {
1246            self.extra_ignore_patterns
1247                .extend(local.extra_ignore_patterns);
1248        }
1249        if local.rules_scope.is_some() {
1250            self.rules_scope = local.rules_scope;
1251        }
1252        if local.proxy.anthropic_upstream.is_some() {
1253            self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
1254        }
1255        if local.proxy.openai_upstream.is_some() {
1256            self.proxy.openai_upstream = local.proxy.openai_upstream;
1257        }
1258        if local.proxy.gemini_upstream.is_some() {
1259            self.proxy.gemini_upstream = local.proxy.gemini_upstream;
1260        }
1261        if !local.autonomy.enabled {
1262            self.autonomy.enabled = false;
1263        }
1264        if !local.autonomy.auto_preload {
1265            self.autonomy.auto_preload = false;
1266        }
1267        if !local.autonomy.auto_dedup {
1268            self.autonomy.auto_dedup = false;
1269        }
1270        if !local.autonomy.auto_related {
1271            self.autonomy.auto_related = false;
1272        }
1273        if !local.autonomy.auto_consolidate {
1274            self.autonomy.auto_consolidate = false;
1275        }
1276        if local.autonomy.silent_preload {
1277            self.autonomy.silent_preload = true;
1278        }
1279        if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1280            self.autonomy.silent_preload = false;
1281        }
1282        if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1283            self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1284        }
1285        if local.autonomy.consolidate_every_calls
1286            != AutonomyConfig::default().consolidate_every_calls
1287        {
1288            self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1289        }
1290        if local.autonomy.consolidate_cooldown_secs
1291            != AutonomyConfig::default().consolidate_cooldown_secs
1292        {
1293            self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1294        }
1295        if !local.autonomy.cognition_loop_enabled {
1296            self.autonomy.cognition_loop_enabled = false;
1297        }
1298        if local.autonomy.cognition_loop_interval_secs
1299            != AutonomyConfig::default().cognition_loop_interval_secs
1300        {
1301            self.autonomy.cognition_loop_interval_secs =
1302                local.autonomy.cognition_loop_interval_secs;
1303        }
1304        if local.autonomy.cognition_loop_max_steps
1305            != AutonomyConfig::default().cognition_loop_max_steps
1306        {
1307            self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
1308        }
1309        if local_toml.contains("compression_level") {
1310            self.compression_level = local.compression_level;
1311        }
1312        if local_toml.contains("terse_agent") {
1313            self.terse_agent = local.terse_agent;
1314        }
1315        if !local.archive.enabled {
1316            self.archive.enabled = false;
1317        }
1318        if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1319            self.archive.threshold_chars = local.archive.threshold_chars;
1320        }
1321        if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1322            self.archive.max_age_hours = local.archive.max_age_hours;
1323        }
1324        if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1325            self.archive.max_disk_mb = local.archive.max_disk_mb;
1326        }
1327        let mem_def = MemoryPolicy::default();
1328        if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1329            self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1330        }
1331        if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1332            self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1333        }
1334        if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1335            self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1336        }
1337        if local.memory.knowledge.contradiction_threshold
1338            != mem_def.knowledge.contradiction_threshold
1339        {
1340            self.memory.knowledge.contradiction_threshold =
1341                local.memory.knowledge.contradiction_threshold;
1342        }
1343
1344        if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1345            self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1346        }
1347        if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1348        {
1349            self.memory.episodic.max_actions_per_episode =
1350                local.memory.episodic.max_actions_per_episode;
1351        }
1352        if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1353            self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1354        }
1355
1356        if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1357            self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1358        }
1359        if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1360            self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1361        }
1362        if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1363            self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1364        }
1365        if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1366            self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1367        }
1368
1369        if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1370            self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1371        }
1372        if local.memory.lifecycle.low_confidence_threshold
1373            != mem_def.lifecycle.low_confidence_threshold
1374        {
1375            self.memory.lifecycle.low_confidence_threshold =
1376                local.memory.lifecycle.low_confidence_threshold;
1377        }
1378        if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1379            self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1380        }
1381        if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1382            self.memory.lifecycle.similarity_threshold =
1383                local.memory.lifecycle.similarity_threshold;
1384        }
1385
1386        if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1387            self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1388        }
1389        if !local.allow_paths.is_empty() {
1390            self.allow_paths.extend(local.allow_paths);
1391        }
1392        if local.minimal_overhead {
1393            self.minimal_overhead = true;
1394        }
1395        if local.shell_hook_disabled {
1396            self.shell_hook_disabled = true;
1397        }
1398        if local.shell_activation != ShellActivation::default() {
1399            self.shell_activation = local.shell_activation.clone();
1400        }
1401        if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1402            self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1403        }
1404        if local.memory_profile != MemoryProfile::default() {
1405            self.memory_profile = local.memory_profile;
1406        }
1407        if local.memory_cleanup != MemoryCleanup::default() {
1408            self.memory_cleanup = local.memory_cleanup;
1409        }
1410        if !local.shell_allowlist.is_empty() {
1411            self.shell_allowlist = local.shell_allowlist;
1412        }
1413    }
1414
1415    /// Persists the current config to the global config file.
1416    pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1417        let path = Self::path().ok_or_else(|| {
1418            super::error::LeanCtxError::Config("cannot determine home directory".into())
1419        })?;
1420        if let Some(parent) = path.parent() {
1421            std::fs::create_dir_all(parent)?;
1422        }
1423        let content = toml::to_string_pretty(self)
1424            .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1425        std::fs::write(&path, content)?;
1426        Ok(())
1427    }
1428
1429    /// Formats the current config as a human-readable string with file paths.
1430    pub fn show(&self) -> String {
1431        let global_path = Self::path().map_or_else(
1432            || "~/.lean-ctx/config.toml".to_string(),
1433            |p| p.to_string_lossy().to_string(),
1434        );
1435        let content = toml::to_string_pretty(self).unwrap_or_default();
1436        let mut out = format!("Global config: {global_path}\n\n{content}");
1437
1438        if let Some(root) = Self::find_project_root() {
1439            let local = Self::local_path(&root);
1440            if local.exists() {
1441                out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1442            } else {
1443                out.push_str(&format!(
1444                    "\n\nLocal config: not found (create {} to override per-project)\n",
1445                    local.display()
1446                ));
1447            }
1448        }
1449        out
1450    }
1451}
1452
1453#[cfg(test)]
1454mod compression_level_tests {
1455    use super::*;
1456
1457    #[test]
1458    fn default_is_standard() {
1459        assert_eq!(CompressionLevel::default(), CompressionLevel::Standard);
1460    }
1461
1462    #[test]
1463    fn to_components_off() {
1464        let (ta, od, crp, tm) = CompressionLevel::Off.to_components();
1465        assert_eq!(ta, TerseAgent::Off);
1466        assert_eq!(od, OutputDensity::Normal);
1467        assert_eq!(crp, "off");
1468        assert!(!tm);
1469    }
1470
1471    #[test]
1472    fn to_components_lite() {
1473        let (ta, od, crp, tm) = CompressionLevel::Lite.to_components();
1474        assert_eq!(ta, TerseAgent::Lite);
1475        assert_eq!(od, OutputDensity::Terse);
1476        assert_eq!(crp, "off");
1477        assert!(tm);
1478    }
1479
1480    #[test]
1481    fn to_components_standard() {
1482        let (ta, od, crp, tm) = CompressionLevel::Standard.to_components();
1483        assert_eq!(ta, TerseAgent::Full);
1484        assert_eq!(od, OutputDensity::Terse);
1485        assert_eq!(crp, "compact");
1486        assert!(tm);
1487    }
1488
1489    #[test]
1490    fn to_components_max() {
1491        let (ta, od, crp, tm) = CompressionLevel::Max.to_components();
1492        assert_eq!(ta, TerseAgent::Ultra);
1493        assert_eq!(od, OutputDensity::Ultra);
1494        assert_eq!(crp, "tdd");
1495        assert!(tm);
1496    }
1497
1498    #[test]
1499    fn from_legacy_ultra_agent_maps_to_max() {
1500        assert_eq!(
1501            CompressionLevel::from_legacy(&TerseAgent::Ultra, &OutputDensity::Normal),
1502            CompressionLevel::Max
1503        );
1504    }
1505
1506    #[test]
1507    fn from_legacy_ultra_density_maps_to_max() {
1508        assert_eq!(
1509            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Ultra),
1510            CompressionLevel::Max
1511        );
1512    }
1513
1514    #[test]
1515    fn from_legacy_full_agent_maps_to_standard() {
1516        assert_eq!(
1517            CompressionLevel::from_legacy(&TerseAgent::Full, &OutputDensity::Normal),
1518            CompressionLevel::Standard
1519        );
1520    }
1521
1522    #[test]
1523    fn from_legacy_lite_agent_maps_to_lite() {
1524        assert_eq!(
1525            CompressionLevel::from_legacy(&TerseAgent::Lite, &OutputDensity::Normal),
1526            CompressionLevel::Lite
1527        );
1528    }
1529
1530    #[test]
1531    fn from_legacy_terse_density_maps_to_lite() {
1532        assert_eq!(
1533            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Terse),
1534            CompressionLevel::Lite
1535        );
1536    }
1537
1538    #[test]
1539    fn from_legacy_both_off_maps_to_off() {
1540        assert_eq!(
1541            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Normal),
1542            CompressionLevel::Off
1543        );
1544    }
1545
1546    #[test]
1547    fn labels_match() {
1548        assert_eq!(CompressionLevel::Off.label(), "off");
1549        assert_eq!(CompressionLevel::Lite.label(), "lite");
1550        assert_eq!(CompressionLevel::Standard.label(), "standard");
1551        assert_eq!(CompressionLevel::Max.label(), "max");
1552    }
1553
1554    #[test]
1555    fn is_active_false_for_off() {
1556        assert!(!CompressionLevel::Off.is_active());
1557    }
1558
1559    #[test]
1560    fn is_active_true_for_all_others() {
1561        assert!(CompressionLevel::Lite.is_active());
1562        assert!(CompressionLevel::Standard.is_active());
1563        assert!(CompressionLevel::Max.is_active());
1564    }
1565
1566    #[test]
1567    fn deserialization_defaults_to_standard() {
1568        let cfg: Config = toml::from_str("").unwrap();
1569        assert_eq!(cfg.compression_level, CompressionLevel::Standard);
1570    }
1571
1572    #[test]
1573    fn deserialization_from_toml() {
1574        let cfg: Config = toml::from_str(r#"compression_level = "standard""#).unwrap();
1575        assert_eq!(cfg.compression_level, CompressionLevel::Standard);
1576    }
1577
1578    #[test]
1579    fn roundtrip_all_levels() {
1580        for level in [
1581            CompressionLevel::Off,
1582            CompressionLevel::Lite,
1583            CompressionLevel::Standard,
1584            CompressionLevel::Max,
1585        ] {
1586            let (ta, od, crp, tm) = level.to_components();
1587            assert!(!crp.is_empty());
1588            if level == CompressionLevel::Off {
1589                assert!(!tm);
1590                assert_eq!(ta, TerseAgent::Off);
1591                assert_eq!(od, OutputDensity::Normal);
1592            } else {
1593                assert!(tm);
1594            }
1595        }
1596    }
1597}
1598
1599#[cfg(test)]
1600mod memory_cleanup_tests {
1601    use super::*;
1602
1603    #[test]
1604    fn default_is_aggressive() {
1605        assert_eq!(MemoryCleanup::default(), MemoryCleanup::Aggressive);
1606    }
1607
1608    #[test]
1609    fn aggressive_ttl_is_300() {
1610        assert_eq!(MemoryCleanup::Aggressive.idle_ttl_secs(), 300);
1611    }
1612
1613    #[test]
1614    fn shared_ttl_is_1800() {
1615        assert_eq!(MemoryCleanup::Shared.idle_ttl_secs(), 1800);
1616    }
1617
1618    #[test]
1619    fn index_retention_multiplier_values() {
1620        assert!(
1621            (MemoryCleanup::Aggressive.index_retention_multiplier() - 1.0).abs() < f64::EPSILON
1622        );
1623        assert!((MemoryCleanup::Shared.index_retention_multiplier() - 3.0).abs() < f64::EPSILON);
1624    }
1625
1626    #[test]
1627    fn deserialization_defaults_to_aggressive() {
1628        let cfg: Config = toml::from_str("").unwrap();
1629        assert_eq!(cfg.memory_cleanup, MemoryCleanup::Aggressive);
1630    }
1631
1632    #[test]
1633    fn deserialization_from_toml() {
1634        let cfg: Config = toml::from_str(r#"memory_cleanup = "shared""#).unwrap();
1635        assert_eq!(cfg.memory_cleanup, MemoryCleanup::Shared);
1636    }
1637
1638    #[test]
1639    fn effective_uses_config_when_no_env() {
1640        let cfg = Config {
1641            memory_cleanup: MemoryCleanup::Shared,
1642            ..Default::default()
1643        };
1644        let eff = MemoryCleanup::effective(&cfg);
1645        assert_eq!(eff, MemoryCleanup::Shared);
1646    }
1647}