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    /// MCP Bridge providers: `{ "name" = { url = "...", description = "..." } }`.
525    #[serde(default)]
526    pub mcp_bridges: std::collections::HashMap<String, McpBridgeEntry>,
527}
528
529impl Default for ProvidersConfig {
530    fn default() -> Self {
531        Self {
532            enabled: true,
533            github: ProviderEntryConfig::default(),
534            gitlab: ProviderEntryConfig::default(),
535            auto_index: true,
536            cache_ttl_secs: 120,
537            mcp_bridges: std::collections::HashMap::new(),
538        }
539    }
540}
541
542#[derive(Debug, Clone, Serialize, Deserialize)]
543pub struct McpBridgeEntry {
544    /// HTTP/SSE URL for remote MCP servers.
545    #[serde(default)]
546    pub url: Option<String>,
547    /// Command to spawn a local MCP server (stdio transport).
548    #[serde(default)]
549    pub command: Option<String>,
550    /// Arguments for the command.
551    #[serde(default)]
552    pub args: Vec<String>,
553    /// Human-readable description.
554    #[serde(default)]
555    pub description: Option<String>,
556    /// Environment variable name containing an auth token.
557    #[serde(default)]
558    pub auth_env: Option<String>,
559}
560
561/// Per-provider configuration entry.
562#[derive(Debug, Clone, Serialize, Deserialize)]
563#[serde(default)]
564pub struct ProviderEntryConfig {
565    /// Whether this specific provider is enabled.
566    pub enabled: bool,
567    /// Auth token (prefer env var; only use this for project-local overrides).
568    pub token: Option<String>,
569    /// API base URL override (for GitHub Enterprise, self-hosted GitLab, etc.).
570    pub api_url: Option<String>,
571    /// Default project/repo for this provider (auto-detected from git remote if empty).
572    pub project: Option<String>,
573}
574
575impl Default for ProviderEntryConfig {
576    fn default() -> Self {
577        Self {
578            enabled: true,
579            token: None,
580            api_url: None,
581            project: None,
582        }
583    }
584}
585
586/// Controls autonomous background behaviors (preload, dedup, consolidation).
587#[derive(Debug, Clone, Serialize, Deserialize)]
588#[serde(default)]
589pub struct AutonomyConfig {
590    pub enabled: bool,
591    pub auto_preload: bool,
592    pub auto_dedup: bool,
593    pub auto_related: bool,
594    pub auto_consolidate: bool,
595    pub silent_preload: bool,
596    pub dedup_threshold: usize,
597    pub consolidate_every_calls: u32,
598    pub consolidate_cooldown_secs: u64,
599    #[serde(default = "serde_defaults::default_true")]
600    pub cognition_loop_enabled: bool,
601    #[serde(default = "serde_defaults::default_cognition_loop_interval")]
602    pub cognition_loop_interval_secs: u64,
603    #[serde(default = "serde_defaults::default_cognition_loop_max_steps")]
604    pub cognition_loop_max_steps: u8,
605}
606
607impl Default for AutonomyConfig {
608    fn default() -> Self {
609        Self {
610            enabled: true,
611            auto_preload: true,
612            auto_dedup: true,
613            auto_related: true,
614            auto_consolidate: true,
615            silent_preload: true,
616            dedup_threshold: 8,
617            consolidate_every_calls: 25,
618            consolidate_cooldown_secs: 120,
619            cognition_loop_enabled: true,
620            cognition_loop_interval_secs: 3600,
621            cognition_loop_max_steps: 8,
622        }
623    }
624}
625
626/// Controls automatic update behavior. All defaults are OFF — auto-updates
627/// require explicit opt-in via `lean-ctx setup` or `lean-ctx update --schedule`.
628#[derive(Debug, Clone, Serialize, Deserialize)]
629#[serde(default)]
630pub struct UpdatesConfig {
631    pub auto_update: bool,
632    pub check_interval_hours: u64,
633    pub notify_only: bool,
634}
635
636impl Default for UpdatesConfig {
637    fn default() -> Self {
638        Self {
639            auto_update: false,
640            check_interval_hours: 6,
641            notify_only: false,
642        }
643    }
644}
645
646impl UpdatesConfig {
647    pub fn from_env() -> Self {
648        let mut cfg = Self::default();
649        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_UPDATE") {
650            cfg.auto_update = v == "1" || v.eq_ignore_ascii_case("true");
651        }
652        if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_INTERVAL_HOURS") {
653            if let Ok(h) = v.parse::<u64>() {
654                cfg.check_interval_hours = h.clamp(1, 168);
655            }
656        }
657        if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_NOTIFY_ONLY") {
658            cfg.notify_only = v == "1" || v.eq_ignore_ascii_case("true");
659        }
660        cfg
661    }
662}
663
664impl AutonomyConfig {
665    /// Creates an autonomy config from env vars, falling back to defaults.
666    pub fn from_env() -> Self {
667        let mut cfg = Self::default();
668        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
669            if v == "false" || v == "0" {
670                cfg.enabled = false;
671            }
672        }
673        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
674            cfg.auto_preload = v != "false" && v != "0";
675        }
676        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
677            cfg.auto_dedup = v != "false" && v != "0";
678        }
679        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
680            cfg.auto_related = v != "false" && v != "0";
681        }
682        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
683            cfg.auto_consolidate = v != "false" && v != "0";
684        }
685        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
686            cfg.silent_preload = v != "false" && v != "0";
687        }
688        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
689            if let Ok(n) = v.parse() {
690                cfg.dedup_threshold = n;
691            }
692        }
693        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
694            if let Ok(n) = v.parse() {
695                cfg.consolidate_every_calls = n;
696            }
697        }
698        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
699            if let Ok(n) = v.parse() {
700                cfg.consolidate_cooldown_secs = n;
701            }
702        }
703        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
704            cfg.cognition_loop_enabled = v != "false" && v != "0";
705        }
706        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
707            if let Ok(n) = v.parse() {
708                cfg.cognition_loop_interval_secs = n;
709            }
710        }
711        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
712            if let Ok(n) = v.parse() {
713                cfg.cognition_loop_max_steps = n;
714            }
715        }
716        cfg
717    }
718
719    /// Loads autonomy config from disk, with env var overrides applied.
720    pub fn load() -> Self {
721        let file_cfg = Config::load().autonomy;
722        let mut cfg = file_cfg;
723        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
724            if v == "false" || v == "0" {
725                cfg.enabled = false;
726            }
727        }
728        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
729            cfg.auto_preload = v != "false" && v != "0";
730        }
731        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
732            cfg.auto_dedup = v != "false" && v != "0";
733        }
734        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
735            cfg.auto_related = v != "false" && v != "0";
736        }
737        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
738            cfg.silent_preload = v != "false" && v != "0";
739        }
740        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
741            if let Ok(n) = v.parse() {
742                cfg.dedup_threshold = n;
743            }
744        }
745        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
746            cfg.cognition_loop_enabled = v != "false" && v != "0";
747        }
748        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
749            if let Ok(n) = v.parse() {
750                cfg.cognition_loop_interval_secs = n;
751            }
752        }
753        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
754            if let Ok(n) = v.parse() {
755                cfg.cognition_loop_max_steps = n;
756            }
757        }
758        cfg
759    }
760}
761
762/// Cloud sync and contribution settings (pattern sharing, model pulls).
763#[derive(Debug, Clone, Serialize, Deserialize, Default)]
764#[serde(default)]
765pub struct CloudConfig {
766    pub contribute_enabled: bool,
767    pub last_contribute: Option<String>,
768    pub last_sync: Option<String>,
769    pub last_gain_sync: Option<String>,
770    pub last_model_pull: Option<String>,
771}
772
773/// A user-defined command alias mapping for shell compression patterns.
774#[derive(Debug, Clone, Serialize, Deserialize)]
775pub struct AliasEntry {
776    pub command: String,
777    pub alias: String,
778}
779
780/// Thresholds for detecting and throttling repetitive agent tool call loops.
781#[derive(Debug, Clone, Serialize, Deserialize)]
782#[serde(default)]
783pub struct LoopDetectionConfig {
784    pub normal_threshold: u32,
785    pub reduced_threshold: u32,
786    pub blocked_threshold: u32,
787    pub window_secs: u64,
788    pub search_group_limit: u32,
789    pub tool_total_limits: HashMap<String, u32>,
790}
791
792impl Default for LoopDetectionConfig {
793    fn default() -> Self {
794        let mut tool_total_limits = HashMap::new();
795        tool_total_limits.insert("ctx_read".to_string(), 100);
796        tool_total_limits.insert("ctx_search".to_string(), 80);
797        tool_total_limits.insert("ctx_shell".to_string(), 50);
798        tool_total_limits.insert("ctx_semantic_search".to_string(), 60);
799        Self {
800            normal_threshold: 2,
801            reduced_threshold: 4,
802            blocked_threshold: 0,
803            window_secs: 300,
804            search_group_limit: 10,
805            tool_total_limits,
806        }
807    }
808}
809
810impl Default for Config {
811    fn default() -> Self {
812        Self {
813            ultra_compact: false,
814            tee_mode: TeeMode::default(),
815            output_density: OutputDensity::default(),
816            checkpoint_interval: 15,
817            excluded_commands: Vec::new(),
818            passthrough_urls: Vec::new(),
819            custom_aliases: Vec::new(),
820            slow_command_threshold_ms: 5000,
821            theme: serde_defaults::default_theme(),
822            cloud: CloudConfig::default(),
823            autonomy: AutonomyConfig::default(),
824            providers: ProvidersConfig::default(),
825            proxy: ProxyConfig::default(),
826            proxy_enabled: None,
827            proxy_port: None,
828            buddy_enabled: serde_defaults::default_buddy_enabled(),
829            enable_wakeup_ctx: true,
830            redirect_exclude: Vec::new(),
831            disabled_tools: Vec::new(),
832            loop_detection: LoopDetectionConfig::default(),
833            rules_scope: None,
834            extra_ignore_patterns: Vec::new(),
835            terse_agent: TerseAgent::default(),
836            compression_level: CompressionLevel::default(),
837            archive: ArchiveConfig::default(),
838            memory: MemoryPolicy::default(),
839            allow_paths: Vec::new(),
840            content_defined_chunking: false,
841            minimal_overhead: false,
842            shell_hook_disabled: false,
843            shell_activation: ShellActivation::default(),
844            update_check_disabled: false,
845            updates: UpdatesConfig::default(),
846            graph_index_max_files: serde_defaults::default_graph_index_max_files(),
847            bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
848            memory_profile: MemoryProfile::default(),
849            memory_cleanup: MemoryCleanup::default(),
850            max_ram_percent: serde_defaults::default_max_ram_percent(),
851            savings_footer: SavingsFooter::default(),
852            project_root: None,
853            lsp: std::collections::HashMap::new(),
854            ide_paths: HashMap::new(),
855            model_context_windows: HashMap::new(),
856            response_verbosity: ResponseVerbosity::default(),
857            bypass_hints: None,
858            cache_policy: None,
859            boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
860            secret_detection: SecretDetectionConfig::default(),
861            allow_auto_reroot: false,
862            path_jail: None,
863            sandbox_level: 0,
864            reference_results: false,
865            agent_token_budget: 0,
866            shell_allowlist: default_shell_allowlist(),
867        }
868    }
869}
870
871fn default_shell_allowlist() -> Vec<String> {
872    [
873        // VCS
874        "git",
875        "gh",
876        "svn",
877        // Build tools
878        "cargo",
879        "npm",
880        "npx",
881        "yarn",
882        "pnpm",
883        "bun",
884        "make",
885        "cmake",
886        "pip",
887        "pip3",
888        "poetry",
889        "uv",
890        "go",
891        "mvn",
892        "gradle",
893        "mix",
894        "dotnet",
895        "swift",
896        "zig",
897        "rustup",
898        "rustc",
899        // Common CLI
900        "ls",
901        "cat",
902        "head",
903        "tail",
904        "wc",
905        "sort",
906        "uniq",
907        "tr",
908        "cut",
909        "grep",
910        "rg",
911        "find",
912        "fd",
913        "ag",
914        "ack",
915        "sed",
916        "awk",
917        "echo",
918        "printf",
919        "true",
920        "false",
921        "test",
922        "expr",
923        "cd",
924        "pwd",
925        "basename",
926        "dirname",
927        "realpath",
928        "readlink",
929        "cp",
930        "mv",
931        "mkdir",
932        "rm",
933        "rmdir",
934        "touch",
935        "ln",
936        "chmod",
937        "diff",
938        "patch",
939        "tar",
940        "zip",
941        "unzip",
942        "gzip",
943        "gunzip",
944        "zstd",
945        "curl",
946        "wget",
947        // Dev tools
948        "docker",
949        "docker-compose",
950        "podman",
951        "node",
952        "python",
953        "python3",
954        "ruby",
955        "perl",
956        "java",
957        "javac",
958        "tsc",
959        "eslint",
960        "prettier",
961        "black",
962        "ruff",
963        "clippy",
964        "jq",
965        "yq",
966        "xargs",
967        "env",
968        "which",
969        "type",
970        "file",
971        "stat",
972        "date",
973        "sleep",
974        "timeout",
975        "nice",
976        "ionice",
977        // lean-ctx itself
978        "lean-ctx",
979    ]
980    .iter()
981    .map(|s| (*s).to_string())
982    .collect()
983}
984
985/// Where agent rule files are installed: global home dir, project-local, or both.
986#[derive(Debug, Clone, Copy, PartialEq, Eq)]
987pub enum RulesScope {
988    Both,
989    Global,
990    Project,
991}
992
993impl Config {
994    /// Returns the effective rules scope, preferring env var over config file.
995    pub fn rules_scope_effective(&self) -> RulesScope {
996        let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
997            .ok()
998            .or_else(|| self.rules_scope.clone())
999            .unwrap_or_default();
1000        match raw.trim().to_lowercase().as_str() {
1001            "global" => RulesScope::Global,
1002            "project" => RulesScope::Project,
1003            _ => RulesScope::Both,
1004        }
1005    }
1006
1007    fn parse_disabled_tools_env(val: &str) -> Vec<String> {
1008        val.split(',')
1009            .map(|s| s.trim().to_string())
1010            .filter(|s| !s.is_empty())
1011            .collect()
1012    }
1013
1014    /// Returns the effective disabled tools list, preferring env var over config file.
1015    pub fn disabled_tools_effective(&self) -> Vec<String> {
1016        if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
1017            Self::parse_disabled_tools_env(&val)
1018        } else {
1019            self.disabled_tools.clone()
1020        }
1021    }
1022
1023    /// Returns `true` if minimal overhead is enabled via env var or config.
1024    pub fn minimal_overhead_effective(&self) -> bool {
1025        std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
1026    }
1027
1028    /// Returns `true` if minimal overhead should be enabled for this MCP client.
1029    ///
1030    /// This is a superset of `minimal_overhead_effective()`:
1031    /// - `LEAN_CTX_OVERHEAD_MODE=minimal` forces minimal overhead
1032    /// - `LEAN_CTX_OVERHEAD_MODE=full` disables client/model heuristics (still honors LEAN_CTX_MINIMAL / config)
1033    /// - In auto mode (default), certain low-context clients/models are treated as minimal to prevent
1034    ///   large metadata blocks from destabilizing smaller context windows (e.g. Hermes + MiniMax).
1035    pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
1036        if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
1037            match raw.trim().to_lowercase().as_str() {
1038                "minimal" => return true,
1039                "full" => return self.minimal_overhead_effective(),
1040                _ => {}
1041            }
1042        }
1043
1044        if self.minimal_overhead_effective() {
1045            return true;
1046        }
1047
1048        let client_lower = client_name.trim().to_lowercase();
1049        if !client_lower.is_empty() {
1050            if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
1051                for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
1052                    if !needle.is_empty() && client_lower.contains(&needle) {
1053                        return true;
1054                    }
1055                }
1056            } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
1057                return true;
1058            }
1059        }
1060
1061        let model = std::env::var("LEAN_CTX_MODEL")
1062            .or_else(|_| std::env::var("LCTX_MODEL"))
1063            .unwrap_or_default();
1064        let model = model.trim().to_lowercase();
1065        if !model.is_empty() {
1066            let m = model.replace(['_', ' '], "-");
1067            if m.contains("minimax")
1068                || m.contains("mini-max")
1069                || m.contains("m2.7")
1070                || m.contains("m2-7")
1071            {
1072                return true;
1073            }
1074        }
1075
1076        false
1077    }
1078
1079    /// Returns `true` if shell hook injection is disabled via env var or config.
1080    pub fn shell_hook_disabled_effective(&self) -> bool {
1081        std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
1082    }
1083
1084    /// Returns the effective shell activation mode (env var > config > default).
1085    pub fn shell_activation_effective(&self) -> ShellActivation {
1086        ShellActivation::effective(self)
1087    }
1088
1089    /// Returns `true` if the daily update check is disabled via env var or config.
1090    pub fn update_check_disabled_effective(&self) -> bool {
1091        std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
1092    }
1093
1094    pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
1095        let mut policy = self.memory.clone();
1096        policy.apply_env_overrides();
1097        policy.validate()?;
1098        Ok(policy)
1099    }
1100}
1101
1102#[cfg(test)]
1103mod disabled_tools_tests {
1104    use super::*;
1105
1106    #[test]
1107    fn config_field_default_is_empty() {
1108        let cfg = Config::default();
1109        assert!(cfg.disabled_tools.is_empty());
1110    }
1111
1112    #[test]
1113    fn effective_returns_config_field_when_no_env_var() {
1114        // Only meaningful when LEAN_CTX_DISABLED_TOOLS is unset; skip otherwise.
1115        if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
1116            return;
1117        }
1118        let cfg = Config {
1119            disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
1120            ..Default::default()
1121        };
1122        assert_eq!(
1123            cfg.disabled_tools_effective(),
1124            vec!["ctx_graph", "ctx_agent"]
1125        );
1126    }
1127
1128    #[test]
1129    fn parse_env_basic() {
1130        let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
1131        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
1132    }
1133
1134    #[test]
1135    fn parse_env_trims_whitespace_and_skips_empty() {
1136        let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
1137        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
1138    }
1139
1140    #[test]
1141    fn parse_env_single_entry() {
1142        let result = Config::parse_disabled_tools_env("ctx_graph");
1143        assert_eq!(result, vec!["ctx_graph"]);
1144    }
1145
1146    #[test]
1147    fn parse_env_empty_string_returns_empty() {
1148        let result = Config::parse_disabled_tools_env("");
1149        assert!(result.is_empty());
1150    }
1151
1152    #[test]
1153    fn disabled_tools_deserialization_defaults_to_empty() {
1154        let cfg: Config = toml::from_str("").unwrap();
1155        assert!(cfg.disabled_tools.is_empty());
1156    }
1157
1158    #[test]
1159    fn disabled_tools_deserialization_from_toml() {
1160        let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
1161        assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
1162    }
1163}
1164
1165#[cfg(test)]
1166mod rules_scope_tests {
1167    use super::*;
1168
1169    #[test]
1170    fn default_is_both() {
1171        let cfg = Config::default();
1172        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1173    }
1174
1175    #[test]
1176    fn config_global() {
1177        let cfg = Config {
1178            rules_scope: Some("global".to_string()),
1179            ..Default::default()
1180        };
1181        assert_eq!(cfg.rules_scope_effective(), RulesScope::Global);
1182    }
1183
1184    #[test]
1185    fn config_project() {
1186        let cfg = Config {
1187            rules_scope: Some("project".to_string()),
1188            ..Default::default()
1189        };
1190        assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1191    }
1192
1193    #[test]
1194    fn unknown_value_falls_back_to_both() {
1195        let cfg = Config {
1196            rules_scope: Some("nonsense".to_string()),
1197            ..Default::default()
1198        };
1199        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1200    }
1201
1202    #[test]
1203    fn deserialization_none_by_default() {
1204        let cfg: Config = toml::from_str("").unwrap();
1205        assert!(cfg.rules_scope.is_none());
1206        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1207    }
1208
1209    #[test]
1210    fn deserialization_from_toml() {
1211        let cfg: Config = toml::from_str(r#"rules_scope = "project""#).unwrap();
1212        assert_eq!(cfg.rules_scope.as_deref(), Some("project"));
1213        assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1214    }
1215}
1216
1217#[cfg(test)]
1218mod loop_detection_config_tests {
1219    use super::*;
1220
1221    #[test]
1222    fn defaults_are_reasonable() {
1223        let cfg = LoopDetectionConfig::default();
1224        assert_eq!(cfg.normal_threshold, 2);
1225        assert_eq!(cfg.reduced_threshold, 4);
1226        // 0 = blocking disabled by default (LeanCTX philosophy: always help, never block)
1227        assert_eq!(cfg.blocked_threshold, 0);
1228        assert_eq!(cfg.window_secs, 300);
1229        assert_eq!(cfg.search_group_limit, 10);
1230    }
1231
1232    #[test]
1233    fn deserialization_defaults_when_missing() {
1234        let cfg: Config = toml::from_str("").unwrap();
1235        // 0 = blocking disabled by default
1236        assert_eq!(cfg.loop_detection.blocked_threshold, 0);
1237        assert_eq!(cfg.loop_detection.search_group_limit, 10);
1238    }
1239
1240    #[test]
1241    fn deserialization_from_toml() {
1242        let cfg: Config = toml::from_str(
1243            r"
1244            [loop_detection]
1245            normal_threshold = 1
1246            reduced_threshold = 3
1247            blocked_threshold = 5
1248            window_secs = 120
1249            search_group_limit = 8
1250            ",
1251        )
1252        .unwrap();
1253        assert_eq!(cfg.loop_detection.normal_threshold, 1);
1254        assert_eq!(cfg.loop_detection.reduced_threshold, 3);
1255        assert_eq!(cfg.loop_detection.blocked_threshold, 5);
1256        assert_eq!(cfg.loop_detection.window_secs, 120);
1257        assert_eq!(cfg.loop_detection.search_group_limit, 8);
1258    }
1259
1260    #[test]
1261    fn partial_override_keeps_defaults() {
1262        let cfg: Config = toml::from_str(
1263            r"
1264            [loop_detection]
1265            blocked_threshold = 10
1266            ",
1267        )
1268        .unwrap();
1269        assert_eq!(cfg.loop_detection.blocked_threshold, 10);
1270        assert_eq!(cfg.loop_detection.normal_threshold, 2);
1271        assert_eq!(cfg.loop_detection.search_group_limit, 10);
1272    }
1273}
1274
1275impl Config {
1276    /// Returns the path to the global config file (`~/.lean-ctx/config.toml`).
1277    pub fn path() -> Option<PathBuf> {
1278        crate::core::data_dir::lean_ctx_data_dir()
1279            .ok()
1280            .map(|d| d.join("config.toml"))
1281    }
1282
1283    /// Returns the path to the project-local config override file.
1284    pub fn local_path(project_root: &str) -> PathBuf {
1285        PathBuf::from(project_root).join(".lean-ctx.toml")
1286    }
1287
1288    fn find_project_root() -> Option<String> {
1289        static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
1290        ROOT_CACHE
1291            .get_or_init(Self::find_project_root_inner)
1292            .clone()
1293    }
1294
1295    fn find_project_root_inner() -> Option<String> {
1296        if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
1297            if !env_root.is_empty() {
1298                return Some(env_root);
1299            }
1300        }
1301
1302        let cwd = std::env::current_dir().ok();
1303
1304        if let Some(root) =
1305            crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
1306        {
1307            let root_path = std::path::Path::new(&root);
1308            let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
1309            let has_marker = root_path.join(".git").exists()
1310                || root_path.join("Cargo.toml").exists()
1311                || root_path.join("package.json").exists()
1312                || root_path.join("go.mod").exists()
1313                || root_path.join("pyproject.toml").exists()
1314                || root_path.join(".lean-ctx.toml").exists();
1315
1316            if cwd_is_under_root || has_marker {
1317                return Some(root);
1318            }
1319        }
1320
1321        if let Some(ref cwd) = cwd {
1322            let git_root = std::process::Command::new("git")
1323                .args(["rev-parse", "--show-toplevel"])
1324                .current_dir(cwd)
1325                .stdout(std::process::Stdio::piped())
1326                .stderr(std::process::Stdio::null())
1327                .output()
1328                .ok()
1329                .and_then(|o| {
1330                    if o.status.success() {
1331                        String::from_utf8(o.stdout)
1332                            .ok()
1333                            .map(|s| s.trim().to_string())
1334                    } else {
1335                        None
1336                    }
1337                });
1338            if let Some(root) = git_root {
1339                return Some(root);
1340            }
1341            if !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
1342                return Some(cwd.to_string_lossy().to_string());
1343            }
1344        }
1345        None
1346    }
1347
1348    /// Loads config from disk with caching, merging global + project-local overrides.
1349    pub fn load() -> Self {
1350        static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
1351
1352        let Some(path) = Self::path() else {
1353            return Self::default();
1354        };
1355
1356        let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
1357
1358        let mtime = std::fs::metadata(&path)
1359            .and_then(|m| m.modified())
1360            .unwrap_or(SystemTime::UNIX_EPOCH);
1361
1362        let local_mtime = local_path
1363            .as_ref()
1364            .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
1365
1366        if let Ok(guard) = CACHE.lock() {
1367            if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
1368                if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
1369                    return cfg.clone();
1370                }
1371            }
1372        }
1373
1374        let mut cfg: Config = match std::fs::read_to_string(&path) {
1375            Ok(content) => match toml::from_str(&content) {
1376                Ok(c) => c,
1377                Err(e) => {
1378                    tracing::warn!("config parse error in {}: {e}", path.display());
1379                    eprintln!(
1380                        "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n  \
1381                         Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
1382                        path.display()
1383                    );
1384                    Self::default()
1385                }
1386            },
1387            Err(_) => Self::default(),
1388        };
1389
1390        if let Some(ref lp) = local_path {
1391            if let Ok(local_content) = std::fs::read_to_string(lp) {
1392                cfg.merge_local(&local_content);
1393            }
1394        }
1395
1396        if let Ok(mut guard) = CACHE.lock() {
1397            *guard = Some((cfg.clone(), mtime, local_mtime));
1398        }
1399
1400        cfg
1401    }
1402
1403    fn merge_local(&mut self, local_toml: &str) {
1404        let local: Config = match toml::from_str(local_toml) {
1405            Ok(c) => c,
1406            Err(e) => {
1407                tracing::warn!("local config parse error: {e}");
1408                eprintln!(
1409                    "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n  \
1410                     Local overrides skipped.\x1b[0m"
1411                );
1412                return;
1413            }
1414        };
1415        if local.ultra_compact {
1416            self.ultra_compact = true;
1417        }
1418        if local.tee_mode != TeeMode::default() {
1419            self.tee_mode = local.tee_mode;
1420        }
1421        if local.output_density != OutputDensity::default() {
1422            self.output_density = local.output_density;
1423        }
1424        if local.checkpoint_interval != 15 {
1425            self.checkpoint_interval = local.checkpoint_interval;
1426        }
1427        if !local.excluded_commands.is_empty() {
1428            self.excluded_commands.extend(local.excluded_commands);
1429        }
1430        if !local.passthrough_urls.is_empty() {
1431            self.passthrough_urls.extend(local.passthrough_urls);
1432        }
1433        if !local.custom_aliases.is_empty() {
1434            self.custom_aliases.extend(local.custom_aliases);
1435        }
1436        if local.slow_command_threshold_ms != 5000 {
1437            self.slow_command_threshold_ms = local.slow_command_threshold_ms;
1438        }
1439        if local.theme != "default" {
1440            self.theme = local.theme;
1441        }
1442        if !local.buddy_enabled {
1443            self.buddy_enabled = false;
1444        }
1445        if !local.enable_wakeup_ctx {
1446            self.enable_wakeup_ctx = false;
1447        }
1448        if !local.redirect_exclude.is_empty() {
1449            self.redirect_exclude.extend(local.redirect_exclude);
1450        }
1451        if !local.disabled_tools.is_empty() {
1452            self.disabled_tools.extend(local.disabled_tools);
1453        }
1454        if !local.extra_ignore_patterns.is_empty() {
1455            self.extra_ignore_patterns
1456                .extend(local.extra_ignore_patterns);
1457        }
1458        if local.rules_scope.is_some() {
1459            self.rules_scope = local.rules_scope;
1460        }
1461        if local.proxy.anthropic_upstream.is_some() {
1462            self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
1463        }
1464        if local.proxy.openai_upstream.is_some() {
1465            self.proxy.openai_upstream = local.proxy.openai_upstream;
1466        }
1467        if local.proxy.gemini_upstream.is_some() {
1468            self.proxy.gemini_upstream = local.proxy.gemini_upstream;
1469        }
1470        if !local.autonomy.enabled {
1471            self.autonomy.enabled = false;
1472        }
1473        if !local.autonomy.auto_preload {
1474            self.autonomy.auto_preload = false;
1475        }
1476        if !local.autonomy.auto_dedup {
1477            self.autonomy.auto_dedup = false;
1478        }
1479        if !local.autonomy.auto_related {
1480            self.autonomy.auto_related = false;
1481        }
1482        if !local.autonomy.auto_consolidate {
1483            self.autonomy.auto_consolidate = false;
1484        }
1485        if local.autonomy.silent_preload {
1486            self.autonomy.silent_preload = true;
1487        }
1488        if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1489            self.autonomy.silent_preload = false;
1490        }
1491        if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1492            self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1493        }
1494        if local.autonomy.consolidate_every_calls
1495            != AutonomyConfig::default().consolidate_every_calls
1496        {
1497            self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1498        }
1499        if local.autonomy.consolidate_cooldown_secs
1500            != AutonomyConfig::default().consolidate_cooldown_secs
1501        {
1502            self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1503        }
1504        if !local.autonomy.cognition_loop_enabled {
1505            self.autonomy.cognition_loop_enabled = false;
1506        }
1507        if local.autonomy.cognition_loop_interval_secs
1508            != AutonomyConfig::default().cognition_loop_interval_secs
1509        {
1510            self.autonomy.cognition_loop_interval_secs =
1511                local.autonomy.cognition_loop_interval_secs;
1512        }
1513        if local.autonomy.cognition_loop_max_steps
1514            != AutonomyConfig::default().cognition_loop_max_steps
1515        {
1516            self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
1517        }
1518        if local_toml.contains("compression_level") {
1519            self.compression_level = local.compression_level;
1520        }
1521        if local_toml.contains("terse_agent") {
1522            self.terse_agent = local.terse_agent;
1523        }
1524        if !local.archive.enabled {
1525            self.archive.enabled = false;
1526        }
1527        if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1528            self.archive.threshold_chars = local.archive.threshold_chars;
1529        }
1530        if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1531            self.archive.max_age_hours = local.archive.max_age_hours;
1532        }
1533        if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1534            self.archive.max_disk_mb = local.archive.max_disk_mb;
1535        }
1536        let mem_def = MemoryPolicy::default();
1537        if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1538            self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1539        }
1540        if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1541            self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1542        }
1543        if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1544            self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1545        }
1546        if local.memory.knowledge.contradiction_threshold
1547            != mem_def.knowledge.contradiction_threshold
1548        {
1549            self.memory.knowledge.contradiction_threshold =
1550                local.memory.knowledge.contradiction_threshold;
1551        }
1552
1553        if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1554            self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1555        }
1556        if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1557        {
1558            self.memory.episodic.max_actions_per_episode =
1559                local.memory.episodic.max_actions_per_episode;
1560        }
1561        if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1562            self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1563        }
1564
1565        if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1566            self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1567        }
1568        if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1569            self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1570        }
1571        if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1572            self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1573        }
1574        if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1575            self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1576        }
1577
1578        if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1579            self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1580        }
1581        if local.memory.lifecycle.low_confidence_threshold
1582            != mem_def.lifecycle.low_confidence_threshold
1583        {
1584            self.memory.lifecycle.low_confidence_threshold =
1585                local.memory.lifecycle.low_confidence_threshold;
1586        }
1587        if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1588            self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1589        }
1590        if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1591            self.memory.lifecycle.similarity_threshold =
1592                local.memory.lifecycle.similarity_threshold;
1593        }
1594
1595        if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1596            self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1597        }
1598        if !local.allow_paths.is_empty() {
1599            self.allow_paths.extend(local.allow_paths);
1600        }
1601        if local.minimal_overhead {
1602            self.minimal_overhead = true;
1603        }
1604        if local.shell_hook_disabled {
1605            self.shell_hook_disabled = true;
1606        }
1607        if local.shell_activation != ShellActivation::default() {
1608            self.shell_activation = local.shell_activation.clone();
1609        }
1610        if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1611            self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1612        }
1613        if local.memory_profile != MemoryProfile::default() {
1614            self.memory_profile = local.memory_profile;
1615        }
1616        if local.memory_cleanup != MemoryCleanup::default() {
1617            self.memory_cleanup = local.memory_cleanup;
1618        }
1619        if !local.shell_allowlist.is_empty() {
1620            self.shell_allowlist = local.shell_allowlist;
1621        }
1622    }
1623
1624    /// Persists the current config to the global config file.
1625    pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1626        let path = Self::path().ok_or_else(|| {
1627            super::error::LeanCtxError::Config("cannot determine home directory".into())
1628        })?;
1629        if let Some(parent) = path.parent() {
1630            std::fs::create_dir_all(parent)?;
1631        }
1632        let content = toml::to_string_pretty(self)
1633            .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1634        std::fs::write(&path, content)?;
1635        Ok(())
1636    }
1637
1638    /// Formats the current config as a human-readable string with file paths.
1639    pub fn show(&self) -> String {
1640        let global_path = Self::path().map_or_else(
1641            || "~/.lean-ctx/config.toml".to_string(),
1642            |p| p.to_string_lossy().to_string(),
1643        );
1644        let content = toml::to_string_pretty(self).unwrap_or_default();
1645        let mut out = format!("Global config: {global_path}\n\n{content}");
1646
1647        if let Some(root) = Self::find_project_root() {
1648            let local = Self::local_path(&root);
1649            if local.exists() {
1650                out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1651            } else {
1652                out.push_str(&format!(
1653                    "\n\nLocal config: not found (create {} to override per-project)\n",
1654                    local.display()
1655                ));
1656            }
1657        }
1658        out
1659    }
1660}
1661
1662#[cfg(test)]
1663mod compression_level_tests {
1664    use super::*;
1665
1666    #[test]
1667    fn default_is_standard() {
1668        assert_eq!(CompressionLevel::default(), CompressionLevel::Standard);
1669    }
1670
1671    #[test]
1672    fn to_components_off() {
1673        let (ta, od, crp, tm) = CompressionLevel::Off.to_components();
1674        assert_eq!(ta, TerseAgent::Off);
1675        assert_eq!(od, OutputDensity::Normal);
1676        assert_eq!(crp, "off");
1677        assert!(!tm);
1678    }
1679
1680    #[test]
1681    fn to_components_lite() {
1682        let (ta, od, crp, tm) = CompressionLevel::Lite.to_components();
1683        assert_eq!(ta, TerseAgent::Lite);
1684        assert_eq!(od, OutputDensity::Terse);
1685        assert_eq!(crp, "off");
1686        assert!(tm);
1687    }
1688
1689    #[test]
1690    fn to_components_standard() {
1691        let (ta, od, crp, tm) = CompressionLevel::Standard.to_components();
1692        assert_eq!(ta, TerseAgent::Full);
1693        assert_eq!(od, OutputDensity::Terse);
1694        assert_eq!(crp, "compact");
1695        assert!(tm);
1696    }
1697
1698    #[test]
1699    fn to_components_max() {
1700        let (ta, od, crp, tm) = CompressionLevel::Max.to_components();
1701        assert_eq!(ta, TerseAgent::Ultra);
1702        assert_eq!(od, OutputDensity::Ultra);
1703        assert_eq!(crp, "tdd");
1704        assert!(tm);
1705    }
1706
1707    #[test]
1708    fn from_legacy_ultra_agent_maps_to_max() {
1709        assert_eq!(
1710            CompressionLevel::from_legacy(&TerseAgent::Ultra, &OutputDensity::Normal),
1711            CompressionLevel::Max
1712        );
1713    }
1714
1715    #[test]
1716    fn from_legacy_ultra_density_maps_to_max() {
1717        assert_eq!(
1718            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Ultra),
1719            CompressionLevel::Max
1720        );
1721    }
1722
1723    #[test]
1724    fn from_legacy_full_agent_maps_to_standard() {
1725        assert_eq!(
1726            CompressionLevel::from_legacy(&TerseAgent::Full, &OutputDensity::Normal),
1727            CompressionLevel::Standard
1728        );
1729    }
1730
1731    #[test]
1732    fn from_legacy_lite_agent_maps_to_lite() {
1733        assert_eq!(
1734            CompressionLevel::from_legacy(&TerseAgent::Lite, &OutputDensity::Normal),
1735            CompressionLevel::Lite
1736        );
1737    }
1738
1739    #[test]
1740    fn from_legacy_terse_density_maps_to_lite() {
1741        assert_eq!(
1742            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Terse),
1743            CompressionLevel::Lite
1744        );
1745    }
1746
1747    #[test]
1748    fn from_legacy_both_off_maps_to_off() {
1749        assert_eq!(
1750            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Normal),
1751            CompressionLevel::Off
1752        );
1753    }
1754
1755    #[test]
1756    fn labels_match() {
1757        assert_eq!(CompressionLevel::Off.label(), "off");
1758        assert_eq!(CompressionLevel::Lite.label(), "lite");
1759        assert_eq!(CompressionLevel::Standard.label(), "standard");
1760        assert_eq!(CompressionLevel::Max.label(), "max");
1761    }
1762
1763    #[test]
1764    fn is_active_false_for_off() {
1765        assert!(!CompressionLevel::Off.is_active());
1766    }
1767
1768    #[test]
1769    fn is_active_true_for_all_others() {
1770        assert!(CompressionLevel::Lite.is_active());
1771        assert!(CompressionLevel::Standard.is_active());
1772        assert!(CompressionLevel::Max.is_active());
1773    }
1774
1775    #[test]
1776    fn deserialization_defaults_to_standard() {
1777        let cfg: Config = toml::from_str("").unwrap();
1778        assert_eq!(cfg.compression_level, CompressionLevel::Standard);
1779    }
1780
1781    #[test]
1782    fn deserialization_from_toml() {
1783        let cfg: Config = toml::from_str(r#"compression_level = "standard""#).unwrap();
1784        assert_eq!(cfg.compression_level, CompressionLevel::Standard);
1785    }
1786
1787    #[test]
1788    fn roundtrip_all_levels() {
1789        for level in [
1790            CompressionLevel::Off,
1791            CompressionLevel::Lite,
1792            CompressionLevel::Standard,
1793            CompressionLevel::Max,
1794        ] {
1795            let (ta, od, crp, tm) = level.to_components();
1796            assert!(!crp.is_empty());
1797            if level == CompressionLevel::Off {
1798                assert!(!tm);
1799                assert_eq!(ta, TerseAgent::Off);
1800                assert_eq!(od, OutputDensity::Normal);
1801            } else {
1802                assert!(tm);
1803            }
1804        }
1805    }
1806}
1807
1808#[cfg(test)]
1809mod memory_cleanup_tests {
1810    use super::*;
1811
1812    #[test]
1813    fn default_is_aggressive() {
1814        assert_eq!(MemoryCleanup::default(), MemoryCleanup::Aggressive);
1815    }
1816
1817    #[test]
1818    fn aggressive_ttl_is_300() {
1819        assert_eq!(MemoryCleanup::Aggressive.idle_ttl_secs(), 300);
1820    }
1821
1822    #[test]
1823    fn shared_ttl_is_1800() {
1824        assert_eq!(MemoryCleanup::Shared.idle_ttl_secs(), 1800);
1825    }
1826
1827    #[test]
1828    fn index_retention_multiplier_values() {
1829        assert!(
1830            (MemoryCleanup::Aggressive.index_retention_multiplier() - 1.0).abs() < f64::EPSILON
1831        );
1832        assert!((MemoryCleanup::Shared.index_retention_multiplier() - 3.0).abs() < f64::EPSILON);
1833    }
1834
1835    #[test]
1836    fn deserialization_defaults_to_aggressive() {
1837        let cfg: Config = toml::from_str("").unwrap();
1838        assert_eq!(cfg.memory_cleanup, MemoryCleanup::Aggressive);
1839    }
1840
1841    #[test]
1842    fn deserialization_from_toml() {
1843        let cfg: Config = toml::from_str(r#"memory_cleanup = "shared""#).unwrap();
1844        assert_eq!(cfg.memory_cleanup, MemoryCleanup::Shared);
1845    }
1846
1847    #[test]
1848    fn effective_uses_config_when_no_env() {
1849        let cfg = Config {
1850            memory_cleanup: MemoryCleanup::Shared,
1851            ..Default::default()
1852        };
1853        let eff = MemoryCleanup::effective(&cfg);
1854        assert_eq!(eff, MemoryCleanup::Shared);
1855    }
1856}