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