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 providers: ProvidersConfig,
289    #[serde(default)]
290    pub proxy: ProxyConfig,
291    /// Whether the API proxy is enabled. Tri-state:
292    /// - None: undecided (fresh install, will prompt on interactive setup)
293    /// - Some(true): user opted in, proxy managed by lean-ctx
294    /// - Some(false): user opted out, never touch proxy or endpoints
295    #[serde(default)]
296    pub proxy_enabled: Option<bool>,
297    #[serde(default)]
298    pub proxy_port: Option<u16>,
299    #[serde(default = "serde_defaults::default_buddy_enabled")]
300    pub buddy_enabled: bool,
301    #[serde(default = "serde_defaults::default_true")]
302    pub enable_wakeup_ctx: bool,
303    #[serde(default)]
304    pub redirect_exclude: Vec<String>,
305    /// Tools to exclude from the MCP tool list returned by list_tools.
306    /// Accepts exact tool names (e.g. `["ctx_graph", "ctx_agent"]`).
307    /// Empty by default — all tools listed, no behaviour change.
308    #[serde(default)]
309    pub disabled_tools: Vec<String>,
310    #[serde(default)]
311    pub loop_detection: LoopDetectionConfig,
312    /// Controls where lean-ctx installs agent rule files.
313    /// Values: "both" (default), "global" (home-dir only), "project" (repo-local only).
314    /// Override via LEAN_CTX_RULES_SCOPE env var.
315    #[serde(default)]
316    pub rules_scope: Option<String>,
317    /// Extra glob patterns to ignore in graph/overview/preload (repo-local).
318    /// Example: `["externals/**", "target/**", "temp/**"]`
319    #[serde(default)]
320    pub extra_ignore_patterns: Vec<String>,
321    /// Controls agent output verbosity via instructions injection.
322    /// Values: "off" (default), "lite", "full", "ultra".
323    /// Override via LEAN_CTX_TERSE_AGENT env var.
324    #[serde(default)]
325    pub terse_agent: TerseAgent,
326    /// Unified compression level (replaces separate terse_agent + output_density).
327    /// Values: "off" (default), "lite", "standard", "max".
328    /// Override via LEAN_CTX_COMPRESSION env var.
329    #[serde(default)]
330    pub compression_level: CompressionLevel,
331    /// Archive configuration for zero-loss compression.
332    #[serde(default)]
333    pub archive: ArchiveConfig,
334    /// Memory policy (knowledge/episodic/procedural/lifecycle budgets & thresholds).
335    #[serde(default)]
336    pub memory: MemoryPolicy,
337    /// Additional paths allowed by PathJail (absolute).
338    /// Useful for multi-project workspaces where the jail root is a parent directory.
339    /// Override via LEAN_CTX_ALLOW_PATH env var (path-list separator).
340    #[serde(default)]
341    pub allow_paths: Vec<String>,
342    /// Enable content-defined chunking (Rabin-Karp) for cache-optimal output ordering.
343    /// Stable chunks are emitted first to maximize prompt cache hits.
344    #[serde(default)]
345    pub content_defined_chunking: bool,
346    /// Skip session/knowledge/gotcha blocks in MCP instructions to minimize token overhead.
347    /// Override via LEAN_CTX_MINIMAL env var.
348    #[serde(default)]
349    pub minimal_overhead: bool,
350    /// Disable shell hook injection (the _lc() function that wraps CLI commands).
351    /// Override via LEAN_CTX_NO_HOOK env var.
352    #[serde(default)]
353    pub shell_hook_disabled: bool,
354    /// Controls when the shell hook auto-activates aliases.
355    /// - `always`: (Default) Aliases active in every interactive shell.
356    /// - `agents-only`: Aliases only active when an AI agent env var is detected.
357    /// - `off`: Aliases never auto-activate (user must call `lean-ctx-on` manually).
358    ///
359    /// Override via `LEAN_CTX_SHELL_ACTIVATION` env var.
360    #[serde(default)]
361    pub shell_activation: ShellActivation,
362    /// Disable the daily version check against leanctx.com/version.txt.
363    /// Override via LEAN_CTX_NO_UPDATE_CHECK env var.
364    #[serde(default)]
365    pub update_check_disabled: bool,
366    #[serde(default)]
367    pub updates: UpdatesConfig,
368    /// Maximum BM25 cache file size in MB. Indexes exceeding this are quarantined on load
369    /// and refused on save. Override via LEAN_CTX_BM25_MAX_CACHE_MB env var.
370    #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
371    pub bm25_max_cache_mb: u64,
372    /// Maximum number of files scanned by the lightweight JSON graph index.
373    /// Increase for large monorepos. Default: 5000.
374    #[serde(default = "serde_defaults::default_graph_index_max_files")]
375    pub graph_index_max_files: u64,
376    /// Controls RAM vs feature trade-off. Values: "low", "balanced" (default), "performance".
377    /// Override via LEAN_CTX_MEMORY_PROFILE env var.
378    #[serde(default)]
379    pub memory_profile: MemoryProfile,
380    /// Controls how aggressively memory is freed when idle.
381    /// Values: "aggressive" (default, 5 min TTL), "shared" (30 min TTL for multi-IDE use).
382    /// Override via LEAN_CTX_MEMORY_CLEANUP env var.
383    #[serde(default)]
384    pub memory_cleanup: MemoryCleanup,
385    /// Maximum percentage of system RAM that lean-ctx may use (default: 5).
386    /// Override via LEAN_CTX_MAX_RAM_PERCENT env var.
387    #[serde(default = "serde_defaults::default_max_ram_percent")]
388    pub max_ram_percent: u8,
389    /// Controls visibility of token savings footers in tool output.
390    /// Values: "never" (default, suppress everywhere), "always", "auto" (legacy compatibility).
391    /// Override via LEAN_CTX_SAVINGS_FOOTER env var.
392    #[serde(default)]
393    pub savings_footer: SavingsFooter,
394    /// Explicit project root override. When set, lean-ctx uses this instead of auto-detection.
395    /// This prevents accidental home-directory scans when running from $HOME.
396    /// Override via LEAN_CTX_PROJECT_ROOT env var.
397    #[serde(default)]
398    pub project_root: Option<String>,
399    /// LSP server overrides. Map language name to custom binary path.
400    /// Example: `[lsp]\nrust = "/opt/rust-analyzer"\npython = "~/.venvs/main/bin/pylsp"`
401    #[serde(default)]
402    pub lsp: std::collections::HashMap<String, String>,
403    /// Per-IDE allowed paths. Restricts which directories lean-ctx will scan/index for each IDE.
404    /// Example: `[ide_paths]\ncursor = ["/home/user/projects/app1"]\ncodex = ["/home/user/codex"]`
405    /// When set, only these paths are indexed for the matching agent. Global `allow_paths` still applies.
406    #[serde(default)]
407    pub ide_paths: HashMap<String, Vec<String>>,
408    /// Custom model context window overrides.
409    /// Example: `[model_context_windows]\n"my-custom-model" = 500000`
410    #[serde(default)]
411    pub model_context_windows: HashMap<String, usize>,
412    /// Controls how much detail tool responses include.
413    ///
414    /// - `full` (default): complete compressed output
415    /// - `headers_only`: metadata line only (path, mode, token count)
416    ///
417    /// Override via `LEAN_CTX_RESPONSE_VERBOSITY` env var.
418    #[serde(default)]
419    pub response_verbosity: ResponseVerbosity,
420    /// Bypass hint mode. When agents use native Read/Grep instead of lean-ctx tools,
421    /// a hint is appended to the next tool response.
422    /// Values: "on" (default), "off", "aggressive" (hint on every call, no cooldown).
423    /// Override via LEAN_CTX_BYPASS_HINTS env var.
424    #[serde(default)]
425    pub bypass_hints: Option<String>,
426    /// Cache policy for ctx_read. Controls behavior on cache hits.
427    /// Values: "aggressive" (default, 13-tok stubs + compaction-aware reset),
428    /// "safe" (delivers map instead of stub), "off" (no caching, always disk read).
429    /// Override via LEAN_CTX_CACHE_POLICY env var.
430    #[serde(default)]
431    pub cache_policy: Option<String>,
432    /// Cross-project boundary policy.
433    /// Controls whether cross-project search/import is allowed and whether access is audited.
434    #[serde(default)]
435    pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
436    #[serde(default)]
437    pub secret_detection: SecretDetectionConfig,
438    /// Allow automatic project-root re-rooting when absolute paths outside the jail are seen.
439    /// When false (default), absolute paths outside the jail are rejected without re-rooting.
440    /// Override via LEAN_CTX_ALLOW_REROOT env var.
441    #[serde(default)]
442    pub allow_auto_reroot: bool,
443    /// Disable PathJail entirely. Set to false to allow all paths.
444    /// Useful in container/Docker environments. Override via LEAN_CTX_NO_JAIL=1.
445    #[serde(default)]
446    pub path_jail: Option<bool>,
447    /// Sandbox level for code execution (ctx_exec).
448    /// 0 = subprocess only (current), 1 = OS-level restriction (Seatbelt/Landlock).
449    /// Override via LEAN_CTX_SANDBOX_LEVEL env var.
450    #[serde(default)]
451    pub sandbox_level: u8,
452    /// When true, large tool outputs (>4000 chars) are stored as references
453    /// and a short URI is returned instead of the full content.
454    /// Override via LEAN_CTX_REFERENCE_RESULTS env var.
455    #[serde(default)]
456    pub reference_results: bool,
457    /// Default per-agent token budget. 0 means unlimited.
458    /// Override per-agent via ctx_session or programmatically.
459    #[serde(default)]
460    pub agent_token_budget: usize,
461    /// Optional shell command allowlist. When non-empty, only commands whose base binary
462    /// is in this list are permitted by ctx_shell. Empty = disable allowlist (allow all).
463    /// Default includes common dev tools. Set to `[]` to disable.
464    /// Override via LEAN_CTX_SHELL_ALLOWLIST env var (comma-separated).
465    #[serde(default = "default_shell_allowlist")]
466    pub shell_allowlist: Vec<String>,
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize)]
470#[serde(default)]
471pub struct SecretDetectionConfig {
472    pub enabled: bool,
473    pub redact: bool,
474    pub custom_patterns: Vec<String>,
475}
476
477impl Default for SecretDetectionConfig {
478    fn default() -> Self {
479        Self {
480            enabled: true,
481            redact: false,
482            custom_patterns: Vec::new(),
483        }
484    }
485}
486
487/// Settings for the zero-loss compression archive (large tool outputs saved to disk).
488#[derive(Debug, Clone, Serialize, Deserialize)]
489#[serde(default)]
490pub struct ArchiveConfig {
491    pub enabled: bool,
492    pub threshold_chars: usize,
493    pub max_age_hours: u64,
494    pub max_disk_mb: u64,
495}
496
497impl Default for ArchiveConfig {
498    fn default() -> Self {
499        Self {
500            enabled: true,
501            threshold_chars: 4096,
502            max_age_hours: 48,
503            max_disk_mb: 500,
504        }
505    }
506}
507
508/// Configuration for external context providers (GitHub, GitLab, Jira, etc.).
509/// Each provider can be enabled/disabled and configured with auth tokens.
510/// Override individual tokens via env vars (GITHUB_TOKEN, GITLAB_TOKEN, etc.).
511#[derive(Debug, Clone, Serialize, Deserialize)]
512#[serde(default)]
513pub struct ProvidersConfig {
514    /// Master switch for the provider subsystem.
515    pub enabled: bool,
516    /// GitHub provider configuration.
517    pub github: ProviderEntryConfig,
518    /// GitLab provider configuration.
519    pub gitlab: ProviderEntryConfig,
520    /// Auto-ingest provider results into BM25/embedding indexes.
521    pub auto_index: bool,
522    /// Default cache TTL for provider results (seconds).
523    pub cache_ttl_secs: u64,
524}
525
526impl Default for ProvidersConfig {
527    fn default() -> Self {
528        Self {
529            enabled: true,
530            github: ProviderEntryConfig::default(),
531            gitlab: ProviderEntryConfig::default(),
532            auto_index: false,
533            cache_ttl_secs: 120,
534        }
535    }
536}
537
538/// Per-provider configuration entry.
539#[derive(Debug, Clone, Serialize, Deserialize)]
540#[serde(default)]
541pub struct ProviderEntryConfig {
542    /// Whether this specific provider is enabled.
543    pub enabled: bool,
544    /// Auth token (prefer env var; only use this for project-local overrides).
545    pub token: Option<String>,
546    /// API base URL override (for GitHub Enterprise, self-hosted GitLab, etc.).
547    pub api_url: Option<String>,
548    /// Default project/repo for this provider (auto-detected from git remote if empty).
549    pub project: Option<String>,
550}
551
552impl Default for ProviderEntryConfig {
553    fn default() -> Self {
554        Self {
555            enabled: true,
556            token: None,
557            api_url: None,
558            project: None,
559        }
560    }
561}
562
563/// Controls autonomous background behaviors (preload, dedup, consolidation).
564#[derive(Debug, Clone, Serialize, Deserialize)]
565#[serde(default)]
566pub struct AutonomyConfig {
567    pub enabled: bool,
568    pub auto_preload: bool,
569    pub auto_dedup: bool,
570    pub auto_related: bool,
571    pub auto_consolidate: bool,
572    pub silent_preload: bool,
573    pub dedup_threshold: usize,
574    pub consolidate_every_calls: u32,
575    pub consolidate_cooldown_secs: u64,
576    #[serde(default = "serde_defaults::default_true")]
577    pub cognition_loop_enabled: bool,
578    #[serde(default = "serde_defaults::default_cognition_loop_interval")]
579    pub cognition_loop_interval_secs: u64,
580    #[serde(default = "serde_defaults::default_cognition_loop_max_steps")]
581    pub cognition_loop_max_steps: u8,
582}
583
584impl Default for AutonomyConfig {
585    fn default() -> Self {
586        Self {
587            enabled: true,
588            auto_preload: true,
589            auto_dedup: true,
590            auto_related: true,
591            auto_consolidate: true,
592            silent_preload: true,
593            dedup_threshold: 8,
594            consolidate_every_calls: 25,
595            consolidate_cooldown_secs: 120,
596            cognition_loop_enabled: true,
597            cognition_loop_interval_secs: 3600,
598            cognition_loop_max_steps: 8,
599        }
600    }
601}
602
603/// Controls automatic update behavior. All defaults are OFF — auto-updates
604/// require explicit opt-in via `lean-ctx setup` or `lean-ctx update --schedule`.
605#[derive(Debug, Clone, Serialize, Deserialize)]
606#[serde(default)]
607pub struct UpdatesConfig {
608    pub auto_update: bool,
609    pub check_interval_hours: u64,
610    pub notify_only: bool,
611}
612
613impl Default for UpdatesConfig {
614    fn default() -> Self {
615        Self {
616            auto_update: false,
617            check_interval_hours: 6,
618            notify_only: false,
619        }
620    }
621}
622
623impl UpdatesConfig {
624    pub fn from_env() -> Self {
625        let mut cfg = Self::default();
626        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_UPDATE") {
627            cfg.auto_update = v == "1" || v.eq_ignore_ascii_case("true");
628        }
629        if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_INTERVAL_HOURS") {
630            if let Ok(h) = v.parse::<u64>() {
631                cfg.check_interval_hours = h.clamp(1, 168);
632            }
633        }
634        if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_NOTIFY_ONLY") {
635            cfg.notify_only = v == "1" || v.eq_ignore_ascii_case("true");
636        }
637        cfg
638    }
639}
640
641impl AutonomyConfig {
642    /// Creates an autonomy config from env vars, falling back to defaults.
643    pub fn from_env() -> Self {
644        let mut cfg = Self::default();
645        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
646            if v == "false" || v == "0" {
647                cfg.enabled = false;
648            }
649        }
650        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
651            cfg.auto_preload = v != "false" && v != "0";
652        }
653        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
654            cfg.auto_dedup = v != "false" && v != "0";
655        }
656        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
657            cfg.auto_related = v != "false" && v != "0";
658        }
659        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
660            cfg.auto_consolidate = v != "false" && v != "0";
661        }
662        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
663            cfg.silent_preload = v != "false" && v != "0";
664        }
665        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
666            if let Ok(n) = v.parse() {
667                cfg.dedup_threshold = n;
668            }
669        }
670        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
671            if let Ok(n) = v.parse() {
672                cfg.consolidate_every_calls = n;
673            }
674        }
675        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
676            if let Ok(n) = v.parse() {
677                cfg.consolidate_cooldown_secs = n;
678            }
679        }
680        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
681            cfg.cognition_loop_enabled = v != "false" && v != "0";
682        }
683        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
684            if let Ok(n) = v.parse() {
685                cfg.cognition_loop_interval_secs = n;
686            }
687        }
688        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
689            if let Ok(n) = v.parse() {
690                cfg.cognition_loop_max_steps = n;
691            }
692        }
693        cfg
694    }
695
696    /// Loads autonomy config from disk, with env var overrides applied.
697    pub fn load() -> Self {
698        let file_cfg = Config::load().autonomy;
699        let mut cfg = file_cfg;
700        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
701            if v == "false" || v == "0" {
702                cfg.enabled = false;
703            }
704        }
705        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
706            cfg.auto_preload = v != "false" && v != "0";
707        }
708        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
709            cfg.auto_dedup = v != "false" && v != "0";
710        }
711        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
712            cfg.auto_related = v != "false" && v != "0";
713        }
714        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
715            cfg.silent_preload = v != "false" && v != "0";
716        }
717        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
718            if let Ok(n) = v.parse() {
719                cfg.dedup_threshold = n;
720            }
721        }
722        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
723            cfg.cognition_loop_enabled = v != "false" && v != "0";
724        }
725        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
726            if let Ok(n) = v.parse() {
727                cfg.cognition_loop_interval_secs = n;
728            }
729        }
730        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
731            if let Ok(n) = v.parse() {
732                cfg.cognition_loop_max_steps = n;
733            }
734        }
735        cfg
736    }
737}
738
739/// Cloud sync and contribution settings (pattern sharing, model pulls).
740#[derive(Debug, Clone, Serialize, Deserialize, Default)]
741#[serde(default)]
742pub struct CloudConfig {
743    pub contribute_enabled: bool,
744    pub last_contribute: Option<String>,
745    pub last_sync: Option<String>,
746    pub last_gain_sync: Option<String>,
747    pub last_model_pull: Option<String>,
748}
749
750/// A user-defined command alias mapping for shell compression patterns.
751#[derive(Debug, Clone, Serialize, Deserialize)]
752pub struct AliasEntry {
753    pub command: String,
754    pub alias: String,
755}
756
757/// Thresholds for detecting and throttling repetitive agent tool call loops.
758#[derive(Debug, Clone, Serialize, Deserialize)]
759#[serde(default)]
760pub struct LoopDetectionConfig {
761    pub normal_threshold: u32,
762    pub reduced_threshold: u32,
763    pub blocked_threshold: u32,
764    pub window_secs: u64,
765    pub search_group_limit: u32,
766    pub tool_total_limits: HashMap<String, u32>,
767}
768
769impl Default for LoopDetectionConfig {
770    fn default() -> Self {
771        let mut tool_total_limits = HashMap::new();
772        tool_total_limits.insert("ctx_read".to_string(), 100);
773        tool_total_limits.insert("ctx_search".to_string(), 80);
774        tool_total_limits.insert("ctx_shell".to_string(), 50);
775        tool_total_limits.insert("ctx_semantic_search".to_string(), 60);
776        Self {
777            normal_threshold: 2,
778            reduced_threshold: 4,
779            blocked_threshold: 0,
780            window_secs: 300,
781            search_group_limit: 10,
782            tool_total_limits,
783        }
784    }
785}
786
787impl Default for Config {
788    fn default() -> Self {
789        Self {
790            ultra_compact: false,
791            tee_mode: TeeMode::default(),
792            output_density: OutputDensity::default(),
793            checkpoint_interval: 15,
794            excluded_commands: Vec::new(),
795            passthrough_urls: Vec::new(),
796            custom_aliases: Vec::new(),
797            slow_command_threshold_ms: 5000,
798            theme: serde_defaults::default_theme(),
799            cloud: CloudConfig::default(),
800            autonomy: AutonomyConfig::default(),
801            providers: ProvidersConfig::default(),
802            proxy: ProxyConfig::default(),
803            proxy_enabled: None,
804            proxy_port: None,
805            buddy_enabled: serde_defaults::default_buddy_enabled(),
806            enable_wakeup_ctx: true,
807            redirect_exclude: Vec::new(),
808            disabled_tools: Vec::new(),
809            loop_detection: LoopDetectionConfig::default(),
810            rules_scope: None,
811            extra_ignore_patterns: Vec::new(),
812            terse_agent: TerseAgent::default(),
813            compression_level: CompressionLevel::default(),
814            archive: ArchiveConfig::default(),
815            memory: MemoryPolicy::default(),
816            allow_paths: Vec::new(),
817            content_defined_chunking: false,
818            minimal_overhead: false,
819            shell_hook_disabled: false,
820            shell_activation: ShellActivation::default(),
821            update_check_disabled: false,
822            updates: UpdatesConfig::default(),
823            graph_index_max_files: serde_defaults::default_graph_index_max_files(),
824            bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
825            memory_profile: MemoryProfile::default(),
826            memory_cleanup: MemoryCleanup::default(),
827            max_ram_percent: serde_defaults::default_max_ram_percent(),
828            savings_footer: SavingsFooter::default(),
829            project_root: None,
830            lsp: std::collections::HashMap::new(),
831            ide_paths: HashMap::new(),
832            model_context_windows: HashMap::new(),
833            response_verbosity: ResponseVerbosity::default(),
834            bypass_hints: None,
835            cache_policy: None,
836            boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
837            secret_detection: SecretDetectionConfig::default(),
838            allow_auto_reroot: false,
839            path_jail: None,
840            sandbox_level: 0,
841            reference_results: false,
842            agent_token_budget: 0,
843            shell_allowlist: default_shell_allowlist(),
844        }
845    }
846}
847
848fn default_shell_allowlist() -> Vec<String> {
849    [
850        // VCS
851        "git",
852        "gh",
853        "svn",
854        // Build tools
855        "cargo",
856        "npm",
857        "npx",
858        "yarn",
859        "pnpm",
860        "bun",
861        "make",
862        "cmake",
863        "pip",
864        "pip3",
865        "poetry",
866        "uv",
867        "go",
868        "mvn",
869        "gradle",
870        "mix",
871        "dotnet",
872        "swift",
873        "zig",
874        "rustup",
875        "rustc",
876        // Common CLI
877        "ls",
878        "cat",
879        "head",
880        "tail",
881        "wc",
882        "sort",
883        "uniq",
884        "tr",
885        "cut",
886        "grep",
887        "rg",
888        "find",
889        "fd",
890        "ag",
891        "ack",
892        "sed",
893        "awk",
894        "echo",
895        "printf",
896        "true",
897        "false",
898        "test",
899        "expr",
900        "cd",
901        "pwd",
902        "basename",
903        "dirname",
904        "realpath",
905        "readlink",
906        "cp",
907        "mv",
908        "mkdir",
909        "rm",
910        "rmdir",
911        "touch",
912        "ln",
913        "chmod",
914        "diff",
915        "patch",
916        "tar",
917        "zip",
918        "unzip",
919        "gzip",
920        "gunzip",
921        "zstd",
922        "curl",
923        "wget",
924        // Dev tools
925        "docker",
926        "docker-compose",
927        "podman",
928        "node",
929        "python",
930        "python3",
931        "ruby",
932        "perl",
933        "java",
934        "javac",
935        "tsc",
936        "eslint",
937        "prettier",
938        "black",
939        "ruff",
940        "clippy",
941        "jq",
942        "yq",
943        "xargs",
944        "env",
945        "which",
946        "type",
947        "file",
948        "stat",
949        "date",
950        "sleep",
951        "timeout",
952        "nice",
953        "ionice",
954        // lean-ctx itself
955        "lean-ctx",
956    ]
957    .iter()
958    .map(|s| (*s).to_string())
959    .collect()
960}
961
962/// Where agent rule files are installed: global home dir, project-local, or both.
963#[derive(Debug, Clone, Copy, PartialEq, Eq)]
964pub enum RulesScope {
965    Both,
966    Global,
967    Project,
968}
969
970impl Config {
971    /// Returns the effective rules scope, preferring env var over config file.
972    pub fn rules_scope_effective(&self) -> RulesScope {
973        let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
974            .ok()
975            .or_else(|| self.rules_scope.clone())
976            .unwrap_or_default();
977        match raw.trim().to_lowercase().as_str() {
978            "global" => RulesScope::Global,
979            "project" => RulesScope::Project,
980            _ => RulesScope::Both,
981        }
982    }
983
984    fn parse_disabled_tools_env(val: &str) -> Vec<String> {
985        val.split(',')
986            .map(|s| s.trim().to_string())
987            .filter(|s| !s.is_empty())
988            .collect()
989    }
990
991    /// Returns the effective disabled tools list, preferring env var over config file.
992    pub fn disabled_tools_effective(&self) -> Vec<String> {
993        if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
994            Self::parse_disabled_tools_env(&val)
995        } else {
996            self.disabled_tools.clone()
997        }
998    }
999
1000    /// Returns `true` if minimal overhead is enabled via env var or config.
1001    pub fn minimal_overhead_effective(&self) -> bool {
1002        std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
1003    }
1004
1005    /// Returns `true` if minimal overhead should be enabled for this MCP client.
1006    ///
1007    /// This is a superset of `minimal_overhead_effective()`:
1008    /// - `LEAN_CTX_OVERHEAD_MODE=minimal` forces minimal overhead
1009    /// - `LEAN_CTX_OVERHEAD_MODE=full` disables client/model heuristics (still honors LEAN_CTX_MINIMAL / config)
1010    /// - In auto mode (default), certain low-context clients/models are treated as minimal to prevent
1011    ///   large metadata blocks from destabilizing smaller context windows (e.g. Hermes + MiniMax).
1012    pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
1013        if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
1014            match raw.trim().to_lowercase().as_str() {
1015                "minimal" => return true,
1016                "full" => return self.minimal_overhead_effective(),
1017                _ => {}
1018            }
1019        }
1020
1021        if self.minimal_overhead_effective() {
1022            return true;
1023        }
1024
1025        let client_lower = client_name.trim().to_lowercase();
1026        if !client_lower.is_empty() {
1027            if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
1028                for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
1029                    if !needle.is_empty() && client_lower.contains(&needle) {
1030                        return true;
1031                    }
1032                }
1033            } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
1034                return true;
1035            }
1036        }
1037
1038        let model = std::env::var("LEAN_CTX_MODEL")
1039            .or_else(|_| std::env::var("LCTX_MODEL"))
1040            .unwrap_or_default();
1041        let model = model.trim().to_lowercase();
1042        if !model.is_empty() {
1043            let m = model.replace(['_', ' '], "-");
1044            if m.contains("minimax")
1045                || m.contains("mini-max")
1046                || m.contains("m2.7")
1047                || m.contains("m2-7")
1048            {
1049                return true;
1050            }
1051        }
1052
1053        false
1054    }
1055
1056    /// Returns `true` if shell hook injection is disabled via env var or config.
1057    pub fn shell_hook_disabled_effective(&self) -> bool {
1058        std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
1059    }
1060
1061    /// Returns the effective shell activation mode (env var > config > default).
1062    pub fn shell_activation_effective(&self) -> ShellActivation {
1063        ShellActivation::effective(self)
1064    }
1065
1066    /// Returns `true` if the daily update check is disabled via env var or config.
1067    pub fn update_check_disabled_effective(&self) -> bool {
1068        std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
1069    }
1070
1071    pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
1072        let mut policy = self.memory.clone();
1073        policy.apply_env_overrides();
1074        policy.validate()?;
1075        Ok(policy)
1076    }
1077}
1078
1079#[cfg(test)]
1080mod disabled_tools_tests {
1081    use super::*;
1082
1083    #[test]
1084    fn config_field_default_is_empty() {
1085        let cfg = Config::default();
1086        assert!(cfg.disabled_tools.is_empty());
1087    }
1088
1089    #[test]
1090    fn effective_returns_config_field_when_no_env_var() {
1091        // Only meaningful when LEAN_CTX_DISABLED_TOOLS is unset; skip otherwise.
1092        if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
1093            return;
1094        }
1095        let cfg = Config {
1096            disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
1097            ..Default::default()
1098        };
1099        assert_eq!(
1100            cfg.disabled_tools_effective(),
1101            vec!["ctx_graph", "ctx_agent"]
1102        );
1103    }
1104
1105    #[test]
1106    fn parse_env_basic() {
1107        let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
1108        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
1109    }
1110
1111    #[test]
1112    fn parse_env_trims_whitespace_and_skips_empty() {
1113        let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
1114        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
1115    }
1116
1117    #[test]
1118    fn parse_env_single_entry() {
1119        let result = Config::parse_disabled_tools_env("ctx_graph");
1120        assert_eq!(result, vec!["ctx_graph"]);
1121    }
1122
1123    #[test]
1124    fn parse_env_empty_string_returns_empty() {
1125        let result = Config::parse_disabled_tools_env("");
1126        assert!(result.is_empty());
1127    }
1128
1129    #[test]
1130    fn disabled_tools_deserialization_defaults_to_empty() {
1131        let cfg: Config = toml::from_str("").unwrap();
1132        assert!(cfg.disabled_tools.is_empty());
1133    }
1134
1135    #[test]
1136    fn disabled_tools_deserialization_from_toml() {
1137        let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
1138        assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
1139    }
1140}
1141
1142#[cfg(test)]
1143mod rules_scope_tests {
1144    use super::*;
1145
1146    #[test]
1147    fn default_is_both() {
1148        let cfg = Config::default();
1149        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1150    }
1151
1152    #[test]
1153    fn config_global() {
1154        let cfg = Config {
1155            rules_scope: Some("global".to_string()),
1156            ..Default::default()
1157        };
1158        assert_eq!(cfg.rules_scope_effective(), RulesScope::Global);
1159    }
1160
1161    #[test]
1162    fn config_project() {
1163        let cfg = Config {
1164            rules_scope: Some("project".to_string()),
1165            ..Default::default()
1166        };
1167        assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1168    }
1169
1170    #[test]
1171    fn unknown_value_falls_back_to_both() {
1172        let cfg = Config {
1173            rules_scope: Some("nonsense".to_string()),
1174            ..Default::default()
1175        };
1176        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1177    }
1178
1179    #[test]
1180    fn deserialization_none_by_default() {
1181        let cfg: Config = toml::from_str("").unwrap();
1182        assert!(cfg.rules_scope.is_none());
1183        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1184    }
1185
1186    #[test]
1187    fn deserialization_from_toml() {
1188        let cfg: Config = toml::from_str(r#"rules_scope = "project""#).unwrap();
1189        assert_eq!(cfg.rules_scope.as_deref(), Some("project"));
1190        assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1191    }
1192}
1193
1194#[cfg(test)]
1195mod loop_detection_config_tests {
1196    use super::*;
1197
1198    #[test]
1199    fn defaults_are_reasonable() {
1200        let cfg = LoopDetectionConfig::default();
1201        assert_eq!(cfg.normal_threshold, 2);
1202        assert_eq!(cfg.reduced_threshold, 4);
1203        // 0 = blocking disabled by default (LeanCTX philosophy: always help, never block)
1204        assert_eq!(cfg.blocked_threshold, 0);
1205        assert_eq!(cfg.window_secs, 300);
1206        assert_eq!(cfg.search_group_limit, 10);
1207    }
1208
1209    #[test]
1210    fn deserialization_defaults_when_missing() {
1211        let cfg: Config = toml::from_str("").unwrap();
1212        // 0 = blocking disabled by default
1213        assert_eq!(cfg.loop_detection.blocked_threshold, 0);
1214        assert_eq!(cfg.loop_detection.search_group_limit, 10);
1215    }
1216
1217    #[test]
1218    fn deserialization_from_toml() {
1219        let cfg: Config = toml::from_str(
1220            r"
1221            [loop_detection]
1222            normal_threshold = 1
1223            reduced_threshold = 3
1224            blocked_threshold = 5
1225            window_secs = 120
1226            search_group_limit = 8
1227            ",
1228        )
1229        .unwrap();
1230        assert_eq!(cfg.loop_detection.normal_threshold, 1);
1231        assert_eq!(cfg.loop_detection.reduced_threshold, 3);
1232        assert_eq!(cfg.loop_detection.blocked_threshold, 5);
1233        assert_eq!(cfg.loop_detection.window_secs, 120);
1234        assert_eq!(cfg.loop_detection.search_group_limit, 8);
1235    }
1236
1237    #[test]
1238    fn partial_override_keeps_defaults() {
1239        let cfg: Config = toml::from_str(
1240            r"
1241            [loop_detection]
1242            blocked_threshold = 10
1243            ",
1244        )
1245        .unwrap();
1246        assert_eq!(cfg.loop_detection.blocked_threshold, 10);
1247        assert_eq!(cfg.loop_detection.normal_threshold, 2);
1248        assert_eq!(cfg.loop_detection.search_group_limit, 10);
1249    }
1250}
1251
1252impl Config {
1253    /// Returns the path to the global config file (`~/.lean-ctx/config.toml`).
1254    pub fn path() -> Option<PathBuf> {
1255        crate::core::data_dir::lean_ctx_data_dir()
1256            .ok()
1257            .map(|d| d.join("config.toml"))
1258    }
1259
1260    /// Returns the path to the project-local config override file.
1261    pub fn local_path(project_root: &str) -> PathBuf {
1262        PathBuf::from(project_root).join(".lean-ctx.toml")
1263    }
1264
1265    fn find_project_root() -> Option<String> {
1266        static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
1267        ROOT_CACHE
1268            .get_or_init(Self::find_project_root_inner)
1269            .clone()
1270    }
1271
1272    fn find_project_root_inner() -> Option<String> {
1273        if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
1274            if !env_root.is_empty() {
1275                return Some(env_root);
1276            }
1277        }
1278
1279        let cwd = std::env::current_dir().ok();
1280
1281        if let Some(root) =
1282            crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
1283        {
1284            let root_path = std::path::Path::new(&root);
1285            let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
1286            let has_marker = root_path.join(".git").exists()
1287                || root_path.join("Cargo.toml").exists()
1288                || root_path.join("package.json").exists()
1289                || root_path.join("go.mod").exists()
1290                || root_path.join("pyproject.toml").exists()
1291                || root_path.join(".lean-ctx.toml").exists();
1292
1293            if cwd_is_under_root || has_marker {
1294                return Some(root);
1295            }
1296        }
1297
1298        if let Some(ref cwd) = cwd {
1299            let git_root = std::process::Command::new("git")
1300                .args(["rev-parse", "--show-toplevel"])
1301                .current_dir(cwd)
1302                .stdout(std::process::Stdio::piped())
1303                .stderr(std::process::Stdio::null())
1304                .output()
1305                .ok()
1306                .and_then(|o| {
1307                    if o.status.success() {
1308                        String::from_utf8(o.stdout)
1309                            .ok()
1310                            .map(|s| s.trim().to_string())
1311                    } else {
1312                        None
1313                    }
1314                });
1315            if let Some(root) = git_root {
1316                return Some(root);
1317            }
1318            if !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
1319                return Some(cwd.to_string_lossy().to_string());
1320            }
1321        }
1322        None
1323    }
1324
1325    /// Loads config from disk with caching, merging global + project-local overrides.
1326    pub fn load() -> Self {
1327        static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
1328
1329        let Some(path) = Self::path() else {
1330            return Self::default();
1331        };
1332
1333        let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
1334
1335        let mtime = std::fs::metadata(&path)
1336            .and_then(|m| m.modified())
1337            .unwrap_or(SystemTime::UNIX_EPOCH);
1338
1339        let local_mtime = local_path
1340            .as_ref()
1341            .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
1342
1343        if let Ok(guard) = CACHE.lock() {
1344            if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
1345                if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
1346                    return cfg.clone();
1347                }
1348            }
1349        }
1350
1351        let mut cfg: Config = match std::fs::read_to_string(&path) {
1352            Ok(content) => match toml::from_str(&content) {
1353                Ok(c) => c,
1354                Err(e) => {
1355                    tracing::warn!("config parse error in {}: {e}", path.display());
1356                    eprintln!(
1357                        "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n  \
1358                         Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
1359                        path.display()
1360                    );
1361                    Self::default()
1362                }
1363            },
1364            Err(_) => Self::default(),
1365        };
1366
1367        if let Some(ref lp) = local_path {
1368            if let Ok(local_content) = std::fs::read_to_string(lp) {
1369                cfg.merge_local(&local_content);
1370            }
1371        }
1372
1373        if let Ok(mut guard) = CACHE.lock() {
1374            *guard = Some((cfg.clone(), mtime, local_mtime));
1375        }
1376
1377        cfg
1378    }
1379
1380    fn merge_local(&mut self, local_toml: &str) {
1381        let local: Config = match toml::from_str(local_toml) {
1382            Ok(c) => c,
1383            Err(e) => {
1384                tracing::warn!("local config parse error: {e}");
1385                eprintln!(
1386                    "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n  \
1387                     Local overrides skipped.\x1b[0m"
1388                );
1389                return;
1390            }
1391        };
1392        if local.ultra_compact {
1393            self.ultra_compact = true;
1394        }
1395        if local.tee_mode != TeeMode::default() {
1396            self.tee_mode = local.tee_mode;
1397        }
1398        if local.output_density != OutputDensity::default() {
1399            self.output_density = local.output_density;
1400        }
1401        if local.checkpoint_interval != 15 {
1402            self.checkpoint_interval = local.checkpoint_interval;
1403        }
1404        if !local.excluded_commands.is_empty() {
1405            self.excluded_commands.extend(local.excluded_commands);
1406        }
1407        if !local.passthrough_urls.is_empty() {
1408            self.passthrough_urls.extend(local.passthrough_urls);
1409        }
1410        if !local.custom_aliases.is_empty() {
1411            self.custom_aliases.extend(local.custom_aliases);
1412        }
1413        if local.slow_command_threshold_ms != 5000 {
1414            self.slow_command_threshold_ms = local.slow_command_threshold_ms;
1415        }
1416        if local.theme != "default" {
1417            self.theme = local.theme;
1418        }
1419        if !local.buddy_enabled {
1420            self.buddy_enabled = false;
1421        }
1422        if !local.enable_wakeup_ctx {
1423            self.enable_wakeup_ctx = false;
1424        }
1425        if !local.redirect_exclude.is_empty() {
1426            self.redirect_exclude.extend(local.redirect_exclude);
1427        }
1428        if !local.disabled_tools.is_empty() {
1429            self.disabled_tools.extend(local.disabled_tools);
1430        }
1431        if !local.extra_ignore_patterns.is_empty() {
1432            self.extra_ignore_patterns
1433                .extend(local.extra_ignore_patterns);
1434        }
1435        if local.rules_scope.is_some() {
1436            self.rules_scope = local.rules_scope;
1437        }
1438        if local.proxy.anthropic_upstream.is_some() {
1439            self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
1440        }
1441        if local.proxy.openai_upstream.is_some() {
1442            self.proxy.openai_upstream = local.proxy.openai_upstream;
1443        }
1444        if local.proxy.gemini_upstream.is_some() {
1445            self.proxy.gemini_upstream = local.proxy.gemini_upstream;
1446        }
1447        if !local.autonomy.enabled {
1448            self.autonomy.enabled = false;
1449        }
1450        if !local.autonomy.auto_preload {
1451            self.autonomy.auto_preload = false;
1452        }
1453        if !local.autonomy.auto_dedup {
1454            self.autonomy.auto_dedup = false;
1455        }
1456        if !local.autonomy.auto_related {
1457            self.autonomy.auto_related = false;
1458        }
1459        if !local.autonomy.auto_consolidate {
1460            self.autonomy.auto_consolidate = false;
1461        }
1462        if local.autonomy.silent_preload {
1463            self.autonomy.silent_preload = true;
1464        }
1465        if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1466            self.autonomy.silent_preload = false;
1467        }
1468        if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1469            self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1470        }
1471        if local.autonomy.consolidate_every_calls
1472            != AutonomyConfig::default().consolidate_every_calls
1473        {
1474            self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1475        }
1476        if local.autonomy.consolidate_cooldown_secs
1477            != AutonomyConfig::default().consolidate_cooldown_secs
1478        {
1479            self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1480        }
1481        if !local.autonomy.cognition_loop_enabled {
1482            self.autonomy.cognition_loop_enabled = false;
1483        }
1484        if local.autonomy.cognition_loop_interval_secs
1485            != AutonomyConfig::default().cognition_loop_interval_secs
1486        {
1487            self.autonomy.cognition_loop_interval_secs =
1488                local.autonomy.cognition_loop_interval_secs;
1489        }
1490        if local.autonomy.cognition_loop_max_steps
1491            != AutonomyConfig::default().cognition_loop_max_steps
1492        {
1493            self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
1494        }
1495        if local_toml.contains("compression_level") {
1496            self.compression_level = local.compression_level;
1497        }
1498        if local_toml.contains("terse_agent") {
1499            self.terse_agent = local.terse_agent;
1500        }
1501        if !local.archive.enabled {
1502            self.archive.enabled = false;
1503        }
1504        if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1505            self.archive.threshold_chars = local.archive.threshold_chars;
1506        }
1507        if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1508            self.archive.max_age_hours = local.archive.max_age_hours;
1509        }
1510        if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1511            self.archive.max_disk_mb = local.archive.max_disk_mb;
1512        }
1513        let mem_def = MemoryPolicy::default();
1514        if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1515            self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1516        }
1517        if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1518            self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1519        }
1520        if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1521            self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1522        }
1523        if local.memory.knowledge.contradiction_threshold
1524            != mem_def.knowledge.contradiction_threshold
1525        {
1526            self.memory.knowledge.contradiction_threshold =
1527                local.memory.knowledge.contradiction_threshold;
1528        }
1529
1530        if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1531            self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1532        }
1533        if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1534        {
1535            self.memory.episodic.max_actions_per_episode =
1536                local.memory.episodic.max_actions_per_episode;
1537        }
1538        if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1539            self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1540        }
1541
1542        if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1543            self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1544        }
1545        if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1546            self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1547        }
1548        if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1549            self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1550        }
1551        if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1552            self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1553        }
1554
1555        if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1556            self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1557        }
1558        if local.memory.lifecycle.low_confidence_threshold
1559            != mem_def.lifecycle.low_confidence_threshold
1560        {
1561            self.memory.lifecycle.low_confidence_threshold =
1562                local.memory.lifecycle.low_confidence_threshold;
1563        }
1564        if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1565            self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1566        }
1567        if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1568            self.memory.lifecycle.similarity_threshold =
1569                local.memory.lifecycle.similarity_threshold;
1570        }
1571
1572        if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1573            self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1574        }
1575        if !local.allow_paths.is_empty() {
1576            self.allow_paths.extend(local.allow_paths);
1577        }
1578        if local.minimal_overhead {
1579            self.minimal_overhead = true;
1580        }
1581        if local.shell_hook_disabled {
1582            self.shell_hook_disabled = true;
1583        }
1584        if local.shell_activation != ShellActivation::default() {
1585            self.shell_activation = local.shell_activation.clone();
1586        }
1587        if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1588            self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1589        }
1590        if local.memory_profile != MemoryProfile::default() {
1591            self.memory_profile = local.memory_profile;
1592        }
1593        if local.memory_cleanup != MemoryCleanup::default() {
1594            self.memory_cleanup = local.memory_cleanup;
1595        }
1596        if !local.shell_allowlist.is_empty() {
1597            self.shell_allowlist = local.shell_allowlist;
1598        }
1599    }
1600
1601    /// Persists the current config to the global config file.
1602    pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1603        let path = Self::path().ok_or_else(|| {
1604            super::error::LeanCtxError::Config("cannot determine home directory".into())
1605        })?;
1606        if let Some(parent) = path.parent() {
1607            std::fs::create_dir_all(parent)?;
1608        }
1609        let content = toml::to_string_pretty(self)
1610            .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1611        std::fs::write(&path, content)?;
1612        Ok(())
1613    }
1614
1615    /// Formats the current config as a human-readable string with file paths.
1616    pub fn show(&self) -> String {
1617        let global_path = Self::path().map_or_else(
1618            || "~/.lean-ctx/config.toml".to_string(),
1619            |p| p.to_string_lossy().to_string(),
1620        );
1621        let content = toml::to_string_pretty(self).unwrap_or_default();
1622        let mut out = format!("Global config: {global_path}\n\n{content}");
1623
1624        if let Some(root) = Self::find_project_root() {
1625            let local = Self::local_path(&root);
1626            if local.exists() {
1627                out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1628            } else {
1629                out.push_str(&format!(
1630                    "\n\nLocal config: not found (create {} to override per-project)\n",
1631                    local.display()
1632                ));
1633            }
1634        }
1635        out
1636    }
1637}
1638
1639#[cfg(test)]
1640mod compression_level_tests {
1641    use super::*;
1642
1643    #[test]
1644    fn default_is_standard() {
1645        assert_eq!(CompressionLevel::default(), CompressionLevel::Standard);
1646    }
1647
1648    #[test]
1649    fn to_components_off() {
1650        let (ta, od, crp, tm) = CompressionLevel::Off.to_components();
1651        assert_eq!(ta, TerseAgent::Off);
1652        assert_eq!(od, OutputDensity::Normal);
1653        assert_eq!(crp, "off");
1654        assert!(!tm);
1655    }
1656
1657    #[test]
1658    fn to_components_lite() {
1659        let (ta, od, crp, tm) = CompressionLevel::Lite.to_components();
1660        assert_eq!(ta, TerseAgent::Lite);
1661        assert_eq!(od, OutputDensity::Terse);
1662        assert_eq!(crp, "off");
1663        assert!(tm);
1664    }
1665
1666    #[test]
1667    fn to_components_standard() {
1668        let (ta, od, crp, tm) = CompressionLevel::Standard.to_components();
1669        assert_eq!(ta, TerseAgent::Full);
1670        assert_eq!(od, OutputDensity::Terse);
1671        assert_eq!(crp, "compact");
1672        assert!(tm);
1673    }
1674
1675    #[test]
1676    fn to_components_max() {
1677        let (ta, od, crp, tm) = CompressionLevel::Max.to_components();
1678        assert_eq!(ta, TerseAgent::Ultra);
1679        assert_eq!(od, OutputDensity::Ultra);
1680        assert_eq!(crp, "tdd");
1681        assert!(tm);
1682    }
1683
1684    #[test]
1685    fn from_legacy_ultra_agent_maps_to_max() {
1686        assert_eq!(
1687            CompressionLevel::from_legacy(&TerseAgent::Ultra, &OutputDensity::Normal),
1688            CompressionLevel::Max
1689        );
1690    }
1691
1692    #[test]
1693    fn from_legacy_ultra_density_maps_to_max() {
1694        assert_eq!(
1695            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Ultra),
1696            CompressionLevel::Max
1697        );
1698    }
1699
1700    #[test]
1701    fn from_legacy_full_agent_maps_to_standard() {
1702        assert_eq!(
1703            CompressionLevel::from_legacy(&TerseAgent::Full, &OutputDensity::Normal),
1704            CompressionLevel::Standard
1705        );
1706    }
1707
1708    #[test]
1709    fn from_legacy_lite_agent_maps_to_lite() {
1710        assert_eq!(
1711            CompressionLevel::from_legacy(&TerseAgent::Lite, &OutputDensity::Normal),
1712            CompressionLevel::Lite
1713        );
1714    }
1715
1716    #[test]
1717    fn from_legacy_terse_density_maps_to_lite() {
1718        assert_eq!(
1719            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Terse),
1720            CompressionLevel::Lite
1721        );
1722    }
1723
1724    #[test]
1725    fn from_legacy_both_off_maps_to_off() {
1726        assert_eq!(
1727            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Normal),
1728            CompressionLevel::Off
1729        );
1730    }
1731
1732    #[test]
1733    fn labels_match() {
1734        assert_eq!(CompressionLevel::Off.label(), "off");
1735        assert_eq!(CompressionLevel::Lite.label(), "lite");
1736        assert_eq!(CompressionLevel::Standard.label(), "standard");
1737        assert_eq!(CompressionLevel::Max.label(), "max");
1738    }
1739
1740    #[test]
1741    fn is_active_false_for_off() {
1742        assert!(!CompressionLevel::Off.is_active());
1743    }
1744
1745    #[test]
1746    fn is_active_true_for_all_others() {
1747        assert!(CompressionLevel::Lite.is_active());
1748        assert!(CompressionLevel::Standard.is_active());
1749        assert!(CompressionLevel::Max.is_active());
1750    }
1751
1752    #[test]
1753    fn deserialization_defaults_to_standard() {
1754        let cfg: Config = toml::from_str("").unwrap();
1755        assert_eq!(cfg.compression_level, CompressionLevel::Standard);
1756    }
1757
1758    #[test]
1759    fn deserialization_from_toml() {
1760        let cfg: Config = toml::from_str(r#"compression_level = "standard""#).unwrap();
1761        assert_eq!(cfg.compression_level, CompressionLevel::Standard);
1762    }
1763
1764    #[test]
1765    fn roundtrip_all_levels() {
1766        for level in [
1767            CompressionLevel::Off,
1768            CompressionLevel::Lite,
1769            CompressionLevel::Standard,
1770            CompressionLevel::Max,
1771        ] {
1772            let (ta, od, crp, tm) = level.to_components();
1773            assert!(!crp.is_empty());
1774            if level == CompressionLevel::Off {
1775                assert!(!tm);
1776                assert_eq!(ta, TerseAgent::Off);
1777                assert_eq!(od, OutputDensity::Normal);
1778            } else {
1779                assert!(tm);
1780            }
1781        }
1782    }
1783}
1784
1785#[cfg(test)]
1786mod memory_cleanup_tests {
1787    use super::*;
1788
1789    #[test]
1790    fn default_is_aggressive() {
1791        assert_eq!(MemoryCleanup::default(), MemoryCleanup::Aggressive);
1792    }
1793
1794    #[test]
1795    fn aggressive_ttl_is_300() {
1796        assert_eq!(MemoryCleanup::Aggressive.idle_ttl_secs(), 300);
1797    }
1798
1799    #[test]
1800    fn shared_ttl_is_1800() {
1801        assert_eq!(MemoryCleanup::Shared.idle_ttl_secs(), 1800);
1802    }
1803
1804    #[test]
1805    fn index_retention_multiplier_values() {
1806        assert!(
1807            (MemoryCleanup::Aggressive.index_retention_multiplier() - 1.0).abs() < f64::EPSILON
1808        );
1809        assert!((MemoryCleanup::Shared.index_retention_multiplier() - 3.0).abs() < f64::EPSILON);
1810    }
1811
1812    #[test]
1813    fn deserialization_defaults_to_aggressive() {
1814        let cfg: Config = toml::from_str("").unwrap();
1815        assert_eq!(cfg.memory_cleanup, MemoryCleanup::Aggressive);
1816    }
1817
1818    #[test]
1819    fn deserialization_from_toml() {
1820        let cfg: Config = toml::from_str(r#"memory_cleanup = "shared""#).unwrap();
1821        assert_eq!(cfg.memory_cleanup, MemoryCleanup::Shared);
1822    }
1823
1824    #[test]
1825    fn effective_uses_config_when_no_env() {
1826        let cfg = Config {
1827            memory_cleanup: MemoryCleanup::Shared,
1828            ..Default::default()
1829        };
1830        let eff = MemoryCleanup::effective(&cfg);
1831        assert_eq!(eff, MemoryCleanup::Shared);
1832    }
1833}