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    /// Cross-project boundary policy.
423    /// Controls whether cross-project search/import is allowed and whether access is audited.
424    #[serde(default)]
425    pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
426    #[serde(default)]
427    pub secret_detection: SecretDetectionConfig,
428    /// Allow automatic project-root re-rooting when absolute paths outside the jail are seen.
429    /// When false (default), absolute paths outside the jail are rejected without re-rooting.
430    /// Override via LEAN_CTX_ALLOW_REROOT env var.
431    #[serde(default)]
432    pub allow_auto_reroot: bool,
433    /// Disable PathJail entirely. Set to false to allow all paths.
434    /// Useful in container/Docker environments. Override via LEAN_CTX_NO_JAIL=1.
435    #[serde(default)]
436    pub path_jail: Option<bool>,
437    /// Sandbox level for code execution (ctx_exec).
438    /// 0 = subprocess only (current), 1 = OS-level restriction (Seatbelt/Landlock).
439    /// Override via LEAN_CTX_SANDBOX_LEVEL env var.
440    #[serde(default)]
441    pub sandbox_level: u8,
442    /// When true, large tool outputs (>4000 chars) are stored as references
443    /// and a short URI is returned instead of the full content.
444    /// Override via LEAN_CTX_REFERENCE_RESULTS env var.
445    #[serde(default)]
446    pub reference_results: bool,
447    /// Default per-agent token budget. 0 means unlimited.
448    /// Override per-agent via ctx_session or programmatically.
449    #[serde(default)]
450    pub agent_token_budget: usize,
451    /// Optional shell command allowlist. When non-empty, only commands whose base binary
452    /// is in this list are permitted by ctx_shell. Empty = no allowlist (blocklist only).
453    /// Example: `shell_allowlist = ["git", "cargo", "npm", "ls", "cat", "grep", "find", "echo"]`
454    /// Override via LEAN_CTX_SHELL_ALLOWLIST env var (comma-separated).
455    #[serde(default)]
456    pub shell_allowlist: Vec<String>,
457}
458
459#[derive(Debug, Clone, Serialize, Deserialize)]
460#[serde(default)]
461pub struct SecretDetectionConfig {
462    pub enabled: bool,
463    pub redact: bool,
464    pub custom_patterns: Vec<String>,
465}
466
467impl Default for SecretDetectionConfig {
468    fn default() -> Self {
469        Self {
470            enabled: true,
471            redact: false,
472            custom_patterns: Vec::new(),
473        }
474    }
475}
476
477/// Settings for the zero-loss compression archive (large tool outputs saved to disk).
478#[derive(Debug, Clone, Serialize, Deserialize)]
479#[serde(default)]
480pub struct ArchiveConfig {
481    pub enabled: bool,
482    pub threshold_chars: usize,
483    pub max_age_hours: u64,
484    pub max_disk_mb: u64,
485}
486
487impl Default for ArchiveConfig {
488    fn default() -> Self {
489        Self {
490            enabled: true,
491            threshold_chars: 4096,
492            max_age_hours: 48,
493            max_disk_mb: 500,
494        }
495    }
496}
497
498/// Controls autonomous background behaviors (preload, dedup, consolidation).
499#[derive(Debug, Clone, Serialize, Deserialize)]
500#[serde(default)]
501pub struct AutonomyConfig {
502    pub enabled: bool,
503    pub auto_preload: bool,
504    pub auto_dedup: bool,
505    pub auto_related: bool,
506    pub auto_consolidate: bool,
507    pub silent_preload: bool,
508    pub dedup_threshold: usize,
509    pub consolidate_every_calls: u32,
510    pub consolidate_cooldown_secs: u64,
511    #[serde(default = "serde_defaults::default_true")]
512    pub cognition_loop_enabled: bool,
513    #[serde(default = "serde_defaults::default_cognition_loop_interval")]
514    pub cognition_loop_interval_secs: u64,
515    #[serde(default = "serde_defaults::default_cognition_loop_max_steps")]
516    pub cognition_loop_max_steps: u8,
517}
518
519impl Default for AutonomyConfig {
520    fn default() -> Self {
521        Self {
522            enabled: true,
523            auto_preload: true,
524            auto_dedup: true,
525            auto_related: true,
526            auto_consolidate: true,
527            silent_preload: true,
528            dedup_threshold: 8,
529            consolidate_every_calls: 25,
530            consolidate_cooldown_secs: 120,
531            cognition_loop_enabled: true,
532            cognition_loop_interval_secs: 3600,
533            cognition_loop_max_steps: 8,
534        }
535    }
536}
537
538/// Controls automatic update behavior. All defaults are OFF — auto-updates
539/// require explicit opt-in via `lean-ctx setup` or `lean-ctx update --schedule`.
540#[derive(Debug, Clone, Serialize, Deserialize)]
541#[serde(default)]
542pub struct UpdatesConfig {
543    pub auto_update: bool,
544    pub check_interval_hours: u64,
545    pub notify_only: bool,
546}
547
548impl Default for UpdatesConfig {
549    fn default() -> Self {
550        Self {
551            auto_update: false,
552            check_interval_hours: 6,
553            notify_only: false,
554        }
555    }
556}
557
558impl UpdatesConfig {
559    pub fn from_env() -> Self {
560        let mut cfg = Self::default();
561        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_UPDATE") {
562            cfg.auto_update = v == "1" || v.eq_ignore_ascii_case("true");
563        }
564        if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_INTERVAL_HOURS") {
565            if let Ok(h) = v.parse::<u64>() {
566                cfg.check_interval_hours = h.clamp(1, 168);
567            }
568        }
569        if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_NOTIFY_ONLY") {
570            cfg.notify_only = v == "1" || v.eq_ignore_ascii_case("true");
571        }
572        cfg
573    }
574}
575
576impl AutonomyConfig {
577    /// Creates an autonomy config from env vars, falling back to defaults.
578    pub fn from_env() -> Self {
579        let mut cfg = Self::default();
580        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
581            if v == "false" || v == "0" {
582                cfg.enabled = false;
583            }
584        }
585        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
586            cfg.auto_preload = v != "false" && v != "0";
587        }
588        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
589            cfg.auto_dedup = v != "false" && v != "0";
590        }
591        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
592            cfg.auto_related = v != "false" && v != "0";
593        }
594        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
595            cfg.auto_consolidate = v != "false" && v != "0";
596        }
597        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
598            cfg.silent_preload = v != "false" && v != "0";
599        }
600        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
601            if let Ok(n) = v.parse() {
602                cfg.dedup_threshold = n;
603            }
604        }
605        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
606            if let Ok(n) = v.parse() {
607                cfg.consolidate_every_calls = n;
608            }
609        }
610        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
611            if let Ok(n) = v.parse() {
612                cfg.consolidate_cooldown_secs = n;
613            }
614        }
615        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
616            cfg.cognition_loop_enabled = v != "false" && v != "0";
617        }
618        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
619            if let Ok(n) = v.parse() {
620                cfg.cognition_loop_interval_secs = n;
621            }
622        }
623        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
624            if let Ok(n) = v.parse() {
625                cfg.cognition_loop_max_steps = n;
626            }
627        }
628        cfg
629    }
630
631    /// Loads autonomy config from disk, with env var overrides applied.
632    pub fn load() -> Self {
633        let file_cfg = Config::load().autonomy;
634        let mut cfg = file_cfg;
635        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
636            if v == "false" || v == "0" {
637                cfg.enabled = false;
638            }
639        }
640        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
641            cfg.auto_preload = v != "false" && v != "0";
642        }
643        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
644            cfg.auto_dedup = v != "false" && v != "0";
645        }
646        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
647            cfg.auto_related = v != "false" && v != "0";
648        }
649        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
650            cfg.silent_preload = v != "false" && v != "0";
651        }
652        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
653            if let Ok(n) = v.parse() {
654                cfg.dedup_threshold = n;
655            }
656        }
657        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
658            cfg.cognition_loop_enabled = v != "false" && v != "0";
659        }
660        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
661            if let Ok(n) = v.parse() {
662                cfg.cognition_loop_interval_secs = n;
663            }
664        }
665        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
666            if let Ok(n) = v.parse() {
667                cfg.cognition_loop_max_steps = n;
668            }
669        }
670        cfg
671    }
672}
673
674/// Cloud sync and contribution settings (pattern sharing, model pulls).
675#[derive(Debug, Clone, Serialize, Deserialize, Default)]
676#[serde(default)]
677pub struct CloudConfig {
678    pub contribute_enabled: bool,
679    pub last_contribute: Option<String>,
680    pub last_sync: Option<String>,
681    pub last_gain_sync: Option<String>,
682    pub last_model_pull: Option<String>,
683}
684
685/// A user-defined command alias mapping for shell compression patterns.
686#[derive(Debug, Clone, Serialize, Deserialize)]
687pub struct AliasEntry {
688    pub command: String,
689    pub alias: String,
690}
691
692/// Thresholds for detecting and throttling repetitive agent tool call loops.
693#[derive(Debug, Clone, Serialize, Deserialize)]
694#[serde(default)]
695pub struct LoopDetectionConfig {
696    pub normal_threshold: u32,
697    pub reduced_threshold: u32,
698    pub blocked_threshold: u32,
699    pub window_secs: u64,
700    pub search_group_limit: u32,
701    pub tool_total_limits: HashMap<String, u32>,
702}
703
704impl Default for LoopDetectionConfig {
705    fn default() -> Self {
706        let mut tool_total_limits = HashMap::new();
707        tool_total_limits.insert("ctx_read".to_string(), 100);
708        tool_total_limits.insert("ctx_search".to_string(), 80);
709        tool_total_limits.insert("ctx_shell".to_string(), 50);
710        tool_total_limits.insert("ctx_semantic_search".to_string(), 60);
711        Self {
712            normal_threshold: 2,
713            reduced_threshold: 4,
714            blocked_threshold: 0,
715            window_secs: 300,
716            search_group_limit: 10,
717            tool_total_limits,
718        }
719    }
720}
721
722impl Default for Config {
723    fn default() -> Self {
724        Self {
725            ultra_compact: false,
726            tee_mode: TeeMode::default(),
727            output_density: OutputDensity::default(),
728            checkpoint_interval: 15,
729            excluded_commands: Vec::new(),
730            passthrough_urls: Vec::new(),
731            custom_aliases: Vec::new(),
732            slow_command_threshold_ms: 5000,
733            theme: serde_defaults::default_theme(),
734            cloud: CloudConfig::default(),
735            autonomy: AutonomyConfig::default(),
736            proxy: ProxyConfig::default(),
737            proxy_enabled: None,
738            buddy_enabled: serde_defaults::default_buddy_enabled(),
739            enable_wakeup_ctx: true,
740            redirect_exclude: Vec::new(),
741            disabled_tools: Vec::new(),
742            loop_detection: LoopDetectionConfig::default(),
743            rules_scope: None,
744            extra_ignore_patterns: Vec::new(),
745            terse_agent: TerseAgent::default(),
746            compression_level: CompressionLevel::default(),
747            archive: ArchiveConfig::default(),
748            memory: MemoryPolicy::default(),
749            allow_paths: Vec::new(),
750            content_defined_chunking: false,
751            minimal_overhead: false,
752            shell_hook_disabled: false,
753            shell_activation: ShellActivation::default(),
754            update_check_disabled: false,
755            updates: UpdatesConfig::default(),
756            graph_index_max_files: serde_defaults::default_graph_index_max_files(),
757            bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
758            memory_profile: MemoryProfile::default(),
759            memory_cleanup: MemoryCleanup::default(),
760            max_ram_percent: serde_defaults::default_max_ram_percent(),
761            savings_footer: SavingsFooter::default(),
762            project_root: None,
763            lsp: std::collections::HashMap::new(),
764            ide_paths: HashMap::new(),
765            model_context_windows: HashMap::new(),
766            response_verbosity: ResponseVerbosity::default(),
767            bypass_hints: None,
768            boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
769            secret_detection: SecretDetectionConfig::default(),
770            allow_auto_reroot: false,
771            path_jail: None,
772            sandbox_level: 0,
773            reference_results: false,
774            agent_token_budget: 0,
775            shell_allowlist: Vec::new(),
776        }
777    }
778}
779
780/// Where agent rule files are installed: global home dir, project-local, or both.
781#[derive(Debug, Clone, Copy, PartialEq, Eq)]
782pub enum RulesScope {
783    Both,
784    Global,
785    Project,
786}
787
788impl Config {
789    /// Returns the effective rules scope, preferring env var over config file.
790    pub fn rules_scope_effective(&self) -> RulesScope {
791        let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
792            .ok()
793            .or_else(|| self.rules_scope.clone())
794            .unwrap_or_default();
795        match raw.trim().to_lowercase().as_str() {
796            "global" => RulesScope::Global,
797            "project" => RulesScope::Project,
798            _ => RulesScope::Both,
799        }
800    }
801
802    fn parse_disabled_tools_env(val: &str) -> Vec<String> {
803        val.split(',')
804            .map(|s| s.trim().to_string())
805            .filter(|s| !s.is_empty())
806            .collect()
807    }
808
809    /// Returns the effective disabled tools list, preferring env var over config file.
810    pub fn disabled_tools_effective(&self) -> Vec<String> {
811        if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
812            Self::parse_disabled_tools_env(&val)
813        } else {
814            self.disabled_tools.clone()
815        }
816    }
817
818    /// Returns `true` if minimal overhead is enabled via env var or config.
819    pub fn minimal_overhead_effective(&self) -> bool {
820        std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
821    }
822
823    /// Returns `true` if minimal overhead should be enabled for this MCP client.
824    ///
825    /// This is a superset of `minimal_overhead_effective()`:
826    /// - `LEAN_CTX_OVERHEAD_MODE=minimal` forces minimal overhead
827    /// - `LEAN_CTX_OVERHEAD_MODE=full` disables client/model heuristics (still honors LEAN_CTX_MINIMAL / config)
828    /// - In auto mode (default), certain low-context clients/models are treated as minimal to prevent
829    ///   large metadata blocks from destabilizing smaller context windows (e.g. Hermes + MiniMax).
830    pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
831        if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
832            match raw.trim().to_lowercase().as_str() {
833                "minimal" => return true,
834                "full" => return self.minimal_overhead_effective(),
835                _ => {}
836            }
837        }
838
839        if self.minimal_overhead_effective() {
840            return true;
841        }
842
843        let client_lower = client_name.trim().to_lowercase();
844        if !client_lower.is_empty() {
845            if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
846                for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
847                    if !needle.is_empty() && client_lower.contains(&needle) {
848                        return true;
849                    }
850                }
851            } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
852                return true;
853            }
854        }
855
856        let model = std::env::var("LEAN_CTX_MODEL")
857            .or_else(|_| std::env::var("LCTX_MODEL"))
858            .unwrap_or_default();
859        let model = model.trim().to_lowercase();
860        if !model.is_empty() {
861            let m = model.replace(['_', ' '], "-");
862            if m.contains("minimax")
863                || m.contains("mini-max")
864                || m.contains("m2.7")
865                || m.contains("m2-7")
866            {
867                return true;
868            }
869        }
870
871        false
872    }
873
874    /// Returns `true` if shell hook injection is disabled via env var or config.
875    pub fn shell_hook_disabled_effective(&self) -> bool {
876        std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
877    }
878
879    /// Returns the effective shell activation mode (env var > config > default).
880    pub fn shell_activation_effective(&self) -> ShellActivation {
881        ShellActivation::effective(self)
882    }
883
884    /// Returns `true` if the daily update check is disabled via env var or config.
885    pub fn update_check_disabled_effective(&self) -> bool {
886        std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
887    }
888
889    pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
890        let mut policy = self.memory.clone();
891        policy.apply_env_overrides();
892        policy.validate()?;
893        Ok(policy)
894    }
895}
896
897#[cfg(test)]
898mod disabled_tools_tests {
899    use super::*;
900
901    #[test]
902    fn config_field_default_is_empty() {
903        let cfg = Config::default();
904        assert!(cfg.disabled_tools.is_empty());
905    }
906
907    #[test]
908    fn effective_returns_config_field_when_no_env_var() {
909        // Only meaningful when LEAN_CTX_DISABLED_TOOLS is unset; skip otherwise.
910        if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
911            return;
912        }
913        let cfg = Config {
914            disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
915            ..Default::default()
916        };
917        assert_eq!(
918            cfg.disabled_tools_effective(),
919            vec!["ctx_graph", "ctx_agent"]
920        );
921    }
922
923    #[test]
924    fn parse_env_basic() {
925        let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
926        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
927    }
928
929    #[test]
930    fn parse_env_trims_whitespace_and_skips_empty() {
931        let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
932        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
933    }
934
935    #[test]
936    fn parse_env_single_entry() {
937        let result = Config::parse_disabled_tools_env("ctx_graph");
938        assert_eq!(result, vec!["ctx_graph"]);
939    }
940
941    #[test]
942    fn parse_env_empty_string_returns_empty() {
943        let result = Config::parse_disabled_tools_env("");
944        assert!(result.is_empty());
945    }
946
947    #[test]
948    fn disabled_tools_deserialization_defaults_to_empty() {
949        let cfg: Config = toml::from_str("").unwrap();
950        assert!(cfg.disabled_tools.is_empty());
951    }
952
953    #[test]
954    fn disabled_tools_deserialization_from_toml() {
955        let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
956        assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
957    }
958}
959
960#[cfg(test)]
961mod rules_scope_tests {
962    use super::*;
963
964    #[test]
965    fn default_is_both() {
966        let cfg = Config::default();
967        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
968    }
969
970    #[test]
971    fn config_global() {
972        let cfg = Config {
973            rules_scope: Some("global".to_string()),
974            ..Default::default()
975        };
976        assert_eq!(cfg.rules_scope_effective(), RulesScope::Global);
977    }
978
979    #[test]
980    fn config_project() {
981        let cfg = Config {
982            rules_scope: Some("project".to_string()),
983            ..Default::default()
984        };
985        assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
986    }
987
988    #[test]
989    fn unknown_value_falls_back_to_both() {
990        let cfg = Config {
991            rules_scope: Some("nonsense".to_string()),
992            ..Default::default()
993        };
994        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
995    }
996
997    #[test]
998    fn deserialization_none_by_default() {
999        let cfg: Config = toml::from_str("").unwrap();
1000        assert!(cfg.rules_scope.is_none());
1001        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1002    }
1003
1004    #[test]
1005    fn deserialization_from_toml() {
1006        let cfg: Config = toml::from_str(r#"rules_scope = "project""#).unwrap();
1007        assert_eq!(cfg.rules_scope.as_deref(), Some("project"));
1008        assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1009    }
1010}
1011
1012#[cfg(test)]
1013mod loop_detection_config_tests {
1014    use super::*;
1015
1016    #[test]
1017    fn defaults_are_reasonable() {
1018        let cfg = LoopDetectionConfig::default();
1019        assert_eq!(cfg.normal_threshold, 2);
1020        assert_eq!(cfg.reduced_threshold, 4);
1021        // 0 = blocking disabled by default (LeanCTX philosophy: always help, never block)
1022        assert_eq!(cfg.blocked_threshold, 0);
1023        assert_eq!(cfg.window_secs, 300);
1024        assert_eq!(cfg.search_group_limit, 10);
1025    }
1026
1027    #[test]
1028    fn deserialization_defaults_when_missing() {
1029        let cfg: Config = toml::from_str("").unwrap();
1030        // 0 = blocking disabled by default
1031        assert_eq!(cfg.loop_detection.blocked_threshold, 0);
1032        assert_eq!(cfg.loop_detection.search_group_limit, 10);
1033    }
1034
1035    #[test]
1036    fn deserialization_from_toml() {
1037        let cfg: Config = toml::from_str(
1038            r"
1039            [loop_detection]
1040            normal_threshold = 1
1041            reduced_threshold = 3
1042            blocked_threshold = 5
1043            window_secs = 120
1044            search_group_limit = 8
1045            ",
1046        )
1047        .unwrap();
1048        assert_eq!(cfg.loop_detection.normal_threshold, 1);
1049        assert_eq!(cfg.loop_detection.reduced_threshold, 3);
1050        assert_eq!(cfg.loop_detection.blocked_threshold, 5);
1051        assert_eq!(cfg.loop_detection.window_secs, 120);
1052        assert_eq!(cfg.loop_detection.search_group_limit, 8);
1053    }
1054
1055    #[test]
1056    fn partial_override_keeps_defaults() {
1057        let cfg: Config = toml::from_str(
1058            r"
1059            [loop_detection]
1060            blocked_threshold = 10
1061            ",
1062        )
1063        .unwrap();
1064        assert_eq!(cfg.loop_detection.blocked_threshold, 10);
1065        assert_eq!(cfg.loop_detection.normal_threshold, 2);
1066        assert_eq!(cfg.loop_detection.search_group_limit, 10);
1067    }
1068}
1069
1070impl Config {
1071    /// Returns the path to the global config file (`~/.lean-ctx/config.toml`).
1072    pub fn path() -> Option<PathBuf> {
1073        crate::core::data_dir::lean_ctx_data_dir()
1074            .ok()
1075            .map(|d| d.join("config.toml"))
1076    }
1077
1078    /// Returns the path to the project-local config override file.
1079    pub fn local_path(project_root: &str) -> PathBuf {
1080        PathBuf::from(project_root).join(".lean-ctx.toml")
1081    }
1082
1083    fn find_project_root() -> Option<String> {
1084        if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
1085            if !env_root.is_empty() {
1086                return Some(env_root);
1087            }
1088        }
1089
1090        let cwd = std::env::current_dir().ok();
1091
1092        if let Some(root) =
1093            crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
1094        {
1095            let root_path = std::path::Path::new(&root);
1096            let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
1097            let has_marker = root_path.join(".git").exists()
1098                || root_path.join("Cargo.toml").exists()
1099                || root_path.join("package.json").exists()
1100                || root_path.join("go.mod").exists()
1101                || root_path.join("pyproject.toml").exists()
1102                || root_path.join(".lean-ctx.toml").exists();
1103
1104            if cwd_is_under_root || has_marker {
1105                return Some(root);
1106            }
1107        }
1108
1109        if let Some(ref cwd) = cwd {
1110            let git_root = std::process::Command::new("git")
1111                .args(["rev-parse", "--show-toplevel"])
1112                .current_dir(cwd)
1113                .stdout(std::process::Stdio::piped())
1114                .stderr(std::process::Stdio::null())
1115                .output()
1116                .ok()
1117                .and_then(|o| {
1118                    if o.status.success() {
1119                        String::from_utf8(o.stdout)
1120                            .ok()
1121                            .map(|s| s.trim().to_string())
1122                    } else {
1123                        None
1124                    }
1125                });
1126            if let Some(root) = git_root {
1127                return Some(root);
1128            }
1129            return Some(cwd.to_string_lossy().to_string());
1130        }
1131        None
1132    }
1133
1134    /// Loads config from disk with caching, merging global + project-local overrides.
1135    pub fn load() -> Self {
1136        static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
1137
1138        let Some(path) = Self::path() else {
1139            return Self::default();
1140        };
1141
1142        let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
1143
1144        let mtime = std::fs::metadata(&path)
1145            .and_then(|m| m.modified())
1146            .unwrap_or(SystemTime::UNIX_EPOCH);
1147
1148        let local_mtime = local_path
1149            .as_ref()
1150            .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
1151
1152        if let Ok(guard) = CACHE.lock() {
1153            if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
1154                if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
1155                    return cfg.clone();
1156                }
1157            }
1158        }
1159
1160        let mut cfg: Config = match std::fs::read_to_string(&path) {
1161            Ok(content) => match toml::from_str(&content) {
1162                Ok(c) => c,
1163                Err(e) => {
1164                    tracing::warn!("config parse error in {}: {e}", path.display());
1165                    Self::default()
1166                }
1167            },
1168            Err(_) => Self::default(),
1169        };
1170
1171        if let Some(ref lp) = local_path {
1172            if let Ok(local_content) = std::fs::read_to_string(lp) {
1173                cfg.merge_local(&local_content);
1174            }
1175        }
1176
1177        if let Ok(mut guard) = CACHE.lock() {
1178            *guard = Some((cfg.clone(), mtime, local_mtime));
1179        }
1180
1181        cfg
1182    }
1183
1184    fn merge_local(&mut self, local_toml: &str) {
1185        let local: Config = match toml::from_str(local_toml) {
1186            Ok(c) => c,
1187            Err(e) => {
1188                tracing::warn!("local config parse error: {e}");
1189                return;
1190            }
1191        };
1192        if local.ultra_compact {
1193            self.ultra_compact = true;
1194        }
1195        if local.tee_mode != TeeMode::default() {
1196            self.tee_mode = local.tee_mode;
1197        }
1198        if local.output_density != OutputDensity::default() {
1199            self.output_density = local.output_density;
1200        }
1201        if local.checkpoint_interval != 15 {
1202            self.checkpoint_interval = local.checkpoint_interval;
1203        }
1204        if !local.excluded_commands.is_empty() {
1205            self.excluded_commands.extend(local.excluded_commands);
1206        }
1207        if !local.passthrough_urls.is_empty() {
1208            self.passthrough_urls.extend(local.passthrough_urls);
1209        }
1210        if !local.custom_aliases.is_empty() {
1211            self.custom_aliases.extend(local.custom_aliases);
1212        }
1213        if local.slow_command_threshold_ms != 5000 {
1214            self.slow_command_threshold_ms = local.slow_command_threshold_ms;
1215        }
1216        if local.theme != "default" {
1217            self.theme = local.theme;
1218        }
1219        if !local.buddy_enabled {
1220            self.buddy_enabled = false;
1221        }
1222        if !local.enable_wakeup_ctx {
1223            self.enable_wakeup_ctx = false;
1224        }
1225        if !local.redirect_exclude.is_empty() {
1226            self.redirect_exclude.extend(local.redirect_exclude);
1227        }
1228        if !local.disabled_tools.is_empty() {
1229            self.disabled_tools.extend(local.disabled_tools);
1230        }
1231        if !local.extra_ignore_patterns.is_empty() {
1232            self.extra_ignore_patterns
1233                .extend(local.extra_ignore_patterns);
1234        }
1235        if local.rules_scope.is_some() {
1236            self.rules_scope = local.rules_scope;
1237        }
1238        if local.proxy.anthropic_upstream.is_some() {
1239            self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
1240        }
1241        if local.proxy.openai_upstream.is_some() {
1242            self.proxy.openai_upstream = local.proxy.openai_upstream;
1243        }
1244        if local.proxy.gemini_upstream.is_some() {
1245            self.proxy.gemini_upstream = local.proxy.gemini_upstream;
1246        }
1247        if !local.autonomy.enabled {
1248            self.autonomy.enabled = false;
1249        }
1250        if !local.autonomy.auto_preload {
1251            self.autonomy.auto_preload = false;
1252        }
1253        if !local.autonomy.auto_dedup {
1254            self.autonomy.auto_dedup = false;
1255        }
1256        if !local.autonomy.auto_related {
1257            self.autonomy.auto_related = false;
1258        }
1259        if !local.autonomy.auto_consolidate {
1260            self.autonomy.auto_consolidate = false;
1261        }
1262        if local.autonomy.silent_preload {
1263            self.autonomy.silent_preload = true;
1264        }
1265        if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1266            self.autonomy.silent_preload = false;
1267        }
1268        if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1269            self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1270        }
1271        if local.autonomy.consolidate_every_calls
1272            != AutonomyConfig::default().consolidate_every_calls
1273        {
1274            self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1275        }
1276        if local.autonomy.consolidate_cooldown_secs
1277            != AutonomyConfig::default().consolidate_cooldown_secs
1278        {
1279            self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1280        }
1281        if !local.autonomy.cognition_loop_enabled {
1282            self.autonomy.cognition_loop_enabled = false;
1283        }
1284        if local.autonomy.cognition_loop_interval_secs
1285            != AutonomyConfig::default().cognition_loop_interval_secs
1286        {
1287            self.autonomy.cognition_loop_interval_secs =
1288                local.autonomy.cognition_loop_interval_secs;
1289        }
1290        if local.autonomy.cognition_loop_max_steps
1291            != AutonomyConfig::default().cognition_loop_max_steps
1292        {
1293            self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
1294        }
1295        if local_toml.contains("compression_level") {
1296            self.compression_level = local.compression_level;
1297        }
1298        if local_toml.contains("terse_agent") {
1299            self.terse_agent = local.terse_agent;
1300        }
1301        if !local.archive.enabled {
1302            self.archive.enabled = false;
1303        }
1304        if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1305            self.archive.threshold_chars = local.archive.threshold_chars;
1306        }
1307        if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1308            self.archive.max_age_hours = local.archive.max_age_hours;
1309        }
1310        if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1311            self.archive.max_disk_mb = local.archive.max_disk_mb;
1312        }
1313        let mem_def = MemoryPolicy::default();
1314        if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1315            self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1316        }
1317        if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1318            self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1319        }
1320        if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1321            self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1322        }
1323        if local.memory.knowledge.contradiction_threshold
1324            != mem_def.knowledge.contradiction_threshold
1325        {
1326            self.memory.knowledge.contradiction_threshold =
1327                local.memory.knowledge.contradiction_threshold;
1328        }
1329
1330        if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1331            self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1332        }
1333        if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1334        {
1335            self.memory.episodic.max_actions_per_episode =
1336                local.memory.episodic.max_actions_per_episode;
1337        }
1338        if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1339            self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1340        }
1341
1342        if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1343            self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1344        }
1345        if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1346            self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1347        }
1348        if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1349            self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1350        }
1351        if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1352            self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1353        }
1354
1355        if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1356            self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1357        }
1358        if local.memory.lifecycle.low_confidence_threshold
1359            != mem_def.lifecycle.low_confidence_threshold
1360        {
1361            self.memory.lifecycle.low_confidence_threshold =
1362                local.memory.lifecycle.low_confidence_threshold;
1363        }
1364        if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1365            self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1366        }
1367        if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1368            self.memory.lifecycle.similarity_threshold =
1369                local.memory.lifecycle.similarity_threshold;
1370        }
1371
1372        if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1373            self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1374        }
1375        if !local.allow_paths.is_empty() {
1376            self.allow_paths.extend(local.allow_paths);
1377        }
1378        if local.minimal_overhead {
1379            self.minimal_overhead = true;
1380        }
1381        if local.shell_hook_disabled {
1382            self.shell_hook_disabled = true;
1383        }
1384        if local.shell_activation != ShellActivation::default() {
1385            self.shell_activation = local.shell_activation.clone();
1386        }
1387        if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1388            self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1389        }
1390        if local.memory_profile != MemoryProfile::default() {
1391            self.memory_profile = local.memory_profile;
1392        }
1393        if local.memory_cleanup != MemoryCleanup::default() {
1394            self.memory_cleanup = local.memory_cleanup;
1395        }
1396        if !local.shell_allowlist.is_empty() {
1397            self.shell_allowlist = local.shell_allowlist;
1398        }
1399    }
1400
1401    /// Persists the current config to the global config file.
1402    pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1403        let path = Self::path().ok_or_else(|| {
1404            super::error::LeanCtxError::Config("cannot determine home directory".into())
1405        })?;
1406        if let Some(parent) = path.parent() {
1407            std::fs::create_dir_all(parent)?;
1408        }
1409        let content = toml::to_string_pretty(self)
1410            .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1411        std::fs::write(&path, content)?;
1412        Ok(())
1413    }
1414
1415    /// Formats the current config as a human-readable string with file paths.
1416    pub fn show(&self) -> String {
1417        let global_path = Self::path().map_or_else(
1418            || "~/.lean-ctx/config.toml".to_string(),
1419            |p| p.to_string_lossy().to_string(),
1420        );
1421        let content = toml::to_string_pretty(self).unwrap_or_default();
1422        let mut out = format!("Global config: {global_path}\n\n{content}");
1423
1424        if let Some(root) = Self::find_project_root() {
1425            let local = Self::local_path(&root);
1426            if local.exists() {
1427                out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1428            } else {
1429                out.push_str(&format!(
1430                    "\n\nLocal config: not found (create {} to override per-project)\n",
1431                    local.display()
1432                ));
1433            }
1434        }
1435        out
1436    }
1437}
1438
1439#[cfg(test)]
1440mod compression_level_tests {
1441    use super::*;
1442
1443    #[test]
1444    fn default_is_standard() {
1445        assert_eq!(CompressionLevel::default(), CompressionLevel::Standard);
1446    }
1447
1448    #[test]
1449    fn to_components_off() {
1450        let (ta, od, crp, tm) = CompressionLevel::Off.to_components();
1451        assert_eq!(ta, TerseAgent::Off);
1452        assert_eq!(od, OutputDensity::Normal);
1453        assert_eq!(crp, "off");
1454        assert!(!tm);
1455    }
1456
1457    #[test]
1458    fn to_components_lite() {
1459        let (ta, od, crp, tm) = CompressionLevel::Lite.to_components();
1460        assert_eq!(ta, TerseAgent::Lite);
1461        assert_eq!(od, OutputDensity::Terse);
1462        assert_eq!(crp, "off");
1463        assert!(tm);
1464    }
1465
1466    #[test]
1467    fn to_components_standard() {
1468        let (ta, od, crp, tm) = CompressionLevel::Standard.to_components();
1469        assert_eq!(ta, TerseAgent::Full);
1470        assert_eq!(od, OutputDensity::Terse);
1471        assert_eq!(crp, "compact");
1472        assert!(tm);
1473    }
1474
1475    #[test]
1476    fn to_components_max() {
1477        let (ta, od, crp, tm) = CompressionLevel::Max.to_components();
1478        assert_eq!(ta, TerseAgent::Ultra);
1479        assert_eq!(od, OutputDensity::Ultra);
1480        assert_eq!(crp, "tdd");
1481        assert!(tm);
1482    }
1483
1484    #[test]
1485    fn from_legacy_ultra_agent_maps_to_max() {
1486        assert_eq!(
1487            CompressionLevel::from_legacy(&TerseAgent::Ultra, &OutputDensity::Normal),
1488            CompressionLevel::Max
1489        );
1490    }
1491
1492    #[test]
1493    fn from_legacy_ultra_density_maps_to_max() {
1494        assert_eq!(
1495            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Ultra),
1496            CompressionLevel::Max
1497        );
1498    }
1499
1500    #[test]
1501    fn from_legacy_full_agent_maps_to_standard() {
1502        assert_eq!(
1503            CompressionLevel::from_legacy(&TerseAgent::Full, &OutputDensity::Normal),
1504            CompressionLevel::Standard
1505        );
1506    }
1507
1508    #[test]
1509    fn from_legacy_lite_agent_maps_to_lite() {
1510        assert_eq!(
1511            CompressionLevel::from_legacy(&TerseAgent::Lite, &OutputDensity::Normal),
1512            CompressionLevel::Lite
1513        );
1514    }
1515
1516    #[test]
1517    fn from_legacy_terse_density_maps_to_lite() {
1518        assert_eq!(
1519            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Terse),
1520            CompressionLevel::Lite
1521        );
1522    }
1523
1524    #[test]
1525    fn from_legacy_both_off_maps_to_off() {
1526        assert_eq!(
1527            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Normal),
1528            CompressionLevel::Off
1529        );
1530    }
1531
1532    #[test]
1533    fn labels_match() {
1534        assert_eq!(CompressionLevel::Off.label(), "off");
1535        assert_eq!(CompressionLevel::Lite.label(), "lite");
1536        assert_eq!(CompressionLevel::Standard.label(), "standard");
1537        assert_eq!(CompressionLevel::Max.label(), "max");
1538    }
1539
1540    #[test]
1541    fn is_active_false_for_off() {
1542        assert!(!CompressionLevel::Off.is_active());
1543    }
1544
1545    #[test]
1546    fn is_active_true_for_all_others() {
1547        assert!(CompressionLevel::Lite.is_active());
1548        assert!(CompressionLevel::Standard.is_active());
1549        assert!(CompressionLevel::Max.is_active());
1550    }
1551
1552    #[test]
1553    fn deserialization_defaults_to_standard() {
1554        let cfg: Config = toml::from_str("").unwrap();
1555        assert_eq!(cfg.compression_level, CompressionLevel::Standard);
1556    }
1557
1558    #[test]
1559    fn deserialization_from_toml() {
1560        let cfg: Config = toml::from_str(r#"compression_level = "standard""#).unwrap();
1561        assert_eq!(cfg.compression_level, CompressionLevel::Standard);
1562    }
1563
1564    #[test]
1565    fn roundtrip_all_levels() {
1566        for level in [
1567            CompressionLevel::Off,
1568            CompressionLevel::Lite,
1569            CompressionLevel::Standard,
1570            CompressionLevel::Max,
1571        ] {
1572            let (ta, od, crp, tm) = level.to_components();
1573            assert!(!crp.is_empty());
1574            if level == CompressionLevel::Off {
1575                assert!(!tm);
1576                assert_eq!(ta, TerseAgent::Off);
1577                assert_eq!(od, OutputDensity::Normal);
1578            } else {
1579                assert!(tm);
1580            }
1581        }
1582    }
1583}
1584
1585#[cfg(test)]
1586mod memory_cleanup_tests {
1587    use super::*;
1588
1589    #[test]
1590    fn default_is_aggressive() {
1591        assert_eq!(MemoryCleanup::default(), MemoryCleanup::Aggressive);
1592    }
1593
1594    #[test]
1595    fn aggressive_ttl_is_300() {
1596        assert_eq!(MemoryCleanup::Aggressive.idle_ttl_secs(), 300);
1597    }
1598
1599    #[test]
1600    fn shared_ttl_is_1800() {
1601        assert_eq!(MemoryCleanup::Shared.idle_ttl_secs(), 1800);
1602    }
1603
1604    #[test]
1605    fn index_retention_multiplier_values() {
1606        assert!(
1607            (MemoryCleanup::Aggressive.index_retention_multiplier() - 1.0).abs() < f64::EPSILON
1608        );
1609        assert!((MemoryCleanup::Shared.index_retention_multiplier() - 3.0).abs() < f64::EPSILON);
1610    }
1611
1612    #[test]
1613    fn deserialization_defaults_to_aggressive() {
1614        let cfg: Config = toml::from_str("").unwrap();
1615        assert_eq!(cfg.memory_cleanup, MemoryCleanup::Aggressive);
1616    }
1617
1618    #[test]
1619    fn deserialization_from_toml() {
1620        let cfg: Config = toml::from_str(r#"memory_cleanup = "shared""#).unwrap();
1621        assert_eq!(cfg.memory_cleanup, MemoryCleanup::Shared);
1622    }
1623
1624    #[test]
1625    fn effective_uses_config_when_no_env() {
1626        let cfg = Config {
1627            memory_cleanup: MemoryCleanup::Shared,
1628            ..Default::default()
1629        };
1630        let eff = MemoryCleanup::effective(&cfg);
1631        assert_eq!(eff, MemoryCleanup::Shared);
1632    }
1633}