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 enums;
10mod memory;
11mod proxy;
12pub mod schema;
13mod serde_defaults;
14pub mod setter;
15mod shell_activation;
16
17pub use enums::{
18    CompressionLevel, OutputDensity, ResponseVerbosity, RulesScope, TeeMode, TerseAgent,
19};
20pub use memory::{MemoryCleanup, MemoryGuardConfig, MemoryProfile, SavingsFooter};
21pub use proxy::{is_local_proxy_url, normalize_url, normalize_url_opt, ProxyConfig, ProxyProvider};
22pub use shell_activation::ShellActivation;
23
24/// Default BM25 cache cap from config (also used by `bm25_index` heuristics).
25pub fn default_bm25_max_cache_mb() -> u64 {
26    serde_defaults::default_bm25_max_cache_mb()
27}
28
29/// Global lean-ctx configuration loaded from `config.toml`, merged with project-local overrides.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(default)]
32pub struct Config {
33    pub ultra_compact: bool,
34    #[serde(default, deserialize_with = "serde_defaults::deserialize_tee_mode")]
35    pub tee_mode: TeeMode,
36    #[serde(default)]
37    pub output_density: OutputDensity,
38    pub checkpoint_interval: u32,
39    pub excluded_commands: Vec<String>,
40    pub passthrough_urls: Vec<String>,
41    pub custom_aliases: Vec<AliasEntry>,
42    /// Commands taking longer than this threshold (ms) are recorded in the slow log.
43    /// Set to 0 to disable slow logging.
44    pub slow_command_threshold_ms: u64,
45    #[serde(default = "serde_defaults::default_theme")]
46    pub theme: String,
47    #[serde(default)]
48    pub cloud: CloudConfig,
49    #[serde(default)]
50    pub autonomy: AutonomyConfig,
51    #[serde(default)]
52    pub providers: ProvidersConfig,
53    #[serde(default)]
54    pub proxy: ProxyConfig,
55    /// Whether the API proxy is enabled. Tri-state:
56    /// - None: undecided (fresh install, will prompt on interactive setup)
57    /// - Some(true): user opted in, proxy managed by lean-ctx
58    /// - Some(false): user opted out, never touch proxy or endpoints
59    #[serde(default)]
60    pub proxy_enabled: Option<bool>,
61    #[serde(default)]
62    pub proxy_port: Option<u16>,
63    /// Proxy reachability timeout in milliseconds. Default: 200.
64    /// Override via LEAN_CTX_PROXY_TIMEOUT_MS env var.
65    #[serde(default)]
66    pub proxy_timeout_ms: Option<u64>,
67    #[serde(default = "serde_defaults::default_buddy_enabled")]
68    pub buddy_enabled: bool,
69    #[serde(default = "serde_defaults::default_true")]
70    pub enable_wakeup_ctx: bool,
71    #[serde(default)]
72    pub redirect_exclude: Vec<String>,
73    /// Tools to exclude from the MCP tool list returned by list_tools.
74    /// Accepts exact tool names (e.g. `["ctx_graph", "ctx_agent"]`).
75    /// Empty by default — all tools listed, no behaviour change.
76    #[serde(default)]
77    pub disabled_tools: Vec<String>,
78    /// Tool categories to activate by default for dynamic-tool-capable clients.
79    /// Values: "core" (always on), "arch", "debug", "memory", "metrics", "session".
80    /// Example: `default_tool_categories = ["core", "arch", "memory"]`
81    /// Override via LCTX_DEFAULT_CATEGORIES env var (comma-separated).
82    /// Empty = lean-ctx default (core + session).
83    #[serde(default)]
84    pub default_tool_categories: Vec<String>,
85    /// Disable all automatic read-mode degradation (auto_degrade + context_gate pressure).
86    /// When true, lean-ctx never downgrades requested read modes regardless of pressure.
87    /// Override via LCTX_NO_DEGRADE=1 env var.
88    #[serde(default)]
89    pub no_degrade: bool,
90    /// Persistent profile name. Checked after LEAN_CTX_PROFILE env var.
91    /// Set via `lean-ctx config set profile passthrough` or editing config.toml.
92    #[serde(default)]
93    pub profile: Option<String>,
94    #[serde(default)]
95    pub loop_detection: LoopDetectionConfig,
96    /// Controls where lean-ctx installs agent rule files.
97    /// Values: "both" (default), "global" (home-dir only), "project" (repo-local only).
98    /// Override via LEAN_CTX_RULES_SCOPE env var.
99    #[serde(default)]
100    pub rules_scope: Option<String>,
101    /// Extra glob patterns to ignore in graph/overview/preload (repo-local).
102    /// Example: `["externals/**", "target/**", "temp/**"]`
103    #[serde(default)]
104    pub extra_ignore_patterns: Vec<String>,
105    /// Controls agent output verbosity via instructions injection.
106    /// Values: "off" (default), "lite", "full", "ultra".
107    /// Override via LEAN_CTX_TERSE_AGENT env var.
108    #[serde(default)]
109    pub terse_agent: TerseAgent,
110    /// Unified compression level (replaces separate terse_agent + output_density).
111    /// Values: "off" (default), "lite", "standard", "max".
112    /// Override via LEAN_CTX_COMPRESSION env var.
113    #[serde(default)]
114    pub compression_level: CompressionLevel,
115    /// Archive configuration for zero-loss compression.
116    #[serde(default)]
117    pub archive: ArchiveConfig,
118    /// Memory policy (knowledge/episodic/procedural/lifecycle budgets & thresholds).
119    #[serde(default)]
120    pub memory: MemoryPolicy,
121    /// Additional paths allowed by PathJail (absolute).
122    /// Useful for multi-project workspaces where the jail root is a parent directory.
123    /// Override via LEAN_CTX_ALLOW_PATH env var (path-list separator).
124    #[serde(default)]
125    pub allow_paths: Vec<String>,
126    /// Extra project roots for multi-root workspaces.
127    /// Tools like ctx_tree and ctx_search can scan across all roots in a single call.
128    /// These paths are automatically added to PathJail's allow-list.
129    /// Override via LEAN_CTX_EXTRA_ROOTS env var (path-list separator).
130    #[serde(default)]
131    pub extra_roots: Vec<String>,
132    /// Enable content-defined chunking (Rabin-Karp) for cache-optimal output ordering.
133    /// Stable chunks are emitted first to maximize prompt cache hits.
134    #[serde(default)]
135    pub content_defined_chunking: bool,
136    /// Skip session/knowledge/gotcha blocks in MCP instructions to minimize token overhead.
137    /// Override via LEAN_CTX_MINIMAL env var.
138    #[serde(default)]
139    pub minimal_overhead: bool,
140    /// Auto-enable SymbolMap for projects with >50 source files.
141    #[serde(default = "serde_defaults::default_true")]
142    pub symbol_map_auto: bool,
143    /// Enable human-readable activity journal (~/.lean-ctx/journal.md).
144    #[serde(default)]
145    pub journal_enabled: bool,
146    /// Opt-in: auto-persist interesting findings as knowledge facts.
147    #[serde(default)]
148    pub auto_capture: bool,
149    /// Hybrid search weights (BM25/dense/candidates).
150    #[serde(default)]
151    pub search: crate::core::hybrid_search::HybridConfig,
152    /// Optional LLM enhancement (query expansion, contradiction explanation).
153    #[serde(default)]
154    pub llm: crate::core::llm_enhance::LlmConfig,
155    /// Disable shell hook injection (the _lc() function that wraps CLI commands).
156    /// Override via LEAN_CTX_NO_HOOK env var.
157    #[serde(default)]
158    pub shell_hook_disabled: bool,
159    /// Controls when the shell hook auto-activates aliases.
160    /// - `always`: (Default) Aliases active in every interactive shell.
161    /// - `agents-only`: Aliases only active when an AI agent env var is detected.
162    /// - `off`: Aliases never auto-activate (user must call `lean-ctx-on` manually).
163    ///
164    /// Override via `LEAN_CTX_SHELL_ACTIVATION` env var.
165    #[serde(default)]
166    pub shell_activation: ShellActivation,
167    /// Disable the daily version check against leanctx.com/version.txt.
168    /// Override via LEAN_CTX_NO_UPDATE_CHECK env var.
169    #[serde(default)]
170    pub update_check_disabled: bool,
171    #[serde(default)]
172    pub updates: UpdatesConfig,
173    /// Maximum BM25 cache file size in MB. Indexes exceeding this are quarantined on load
174    /// and refused on save. Override via LEAN_CTX_BM25_MAX_CACHE_MB env var.
175    #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
176    pub bm25_max_cache_mb: u64,
177    /// Maximum number of files scanned by the lightweight JSON graph index.
178    /// Increase for large monorepos. Default: 5000.
179    #[serde(default = "serde_defaults::default_graph_index_max_files")]
180    pub graph_index_max_files: u64,
181    /// Controls RAM vs feature trade-off. Values: "low", "balanced" (default), "performance".
182    /// Override via LEAN_CTX_MEMORY_PROFILE env var.
183    #[serde(default)]
184    pub memory_profile: MemoryProfile,
185    /// Controls how aggressively memory is freed when idle.
186    /// Values: "aggressive" (default, 5 min TTL), "shared" (30 min TTL for multi-IDE use).
187    /// Override via LEAN_CTX_MEMORY_CLEANUP env var.
188    #[serde(default)]
189    pub memory_cleanup: MemoryCleanup,
190    /// Maximum percentage of system RAM that lean-ctx may use (default: 5).
191    /// Override via LEAN_CTX_MAX_RAM_PERCENT env var.
192    #[serde(default = "serde_defaults::default_max_ram_percent")]
193    pub max_ram_percent: u8,
194    /// Simplified disk budget (MB). When set and detail values are at defaults,
195    /// distributes proportionally: archive=25%, bm25=10%, remainder for stores.
196    /// 0 = disabled (use individual settings). Override via LEAN_CTX_MAX_DISK_MB.
197    #[serde(default)]
198    pub max_disk_mb: u64,
199    /// Auto-purge data older than this many days. 0 = disabled.
200    /// Flows into archive.max_age_hours and lifecycle idle TTL.
201    #[serde(default)]
202    pub max_staleness_days: u32,
203    /// Controls visibility of token savings footers in tool output.
204    /// Values: "never" (default, suppress everywhere), "always", "auto" (legacy compatibility).
205    /// Override via LEAN_CTX_SAVINGS_FOOTER env var.
206    #[serde(default)]
207    pub savings_footer: SavingsFooter,
208    /// Explicit project root override. When set, lean-ctx uses this instead of auto-detection.
209    /// This prevents accidental home-directory scans when running from $HOME.
210    /// Override via LEAN_CTX_PROJECT_ROOT env var.
211    #[serde(default)]
212    pub project_root: Option<String>,
213    /// LSP server overrides. Map language name to custom binary path.
214    /// Example: `[lsp]\nrust = "/opt/rust-analyzer"\npython = "~/.venvs/main/bin/pylsp"`
215    #[serde(default)]
216    pub lsp: std::collections::HashMap<String, String>,
217    /// Per-IDE allowed paths. Restricts which directories lean-ctx will scan/index for each IDE.
218    /// Example: `[ide_paths]\ncursor = ["/home/user/projects/app1"]\ncodex = ["/home/user/codex"]`
219    /// When set, only these paths are indexed for the matching agent. Global `allow_paths` still applies.
220    #[serde(default)]
221    pub ide_paths: HashMap<String, Vec<String>>,
222    /// Custom model context window overrides.
223    /// Example: `[model_context_windows]\n"my-custom-model" = 500000`
224    #[serde(default)]
225    pub model_context_windows: HashMap<String, usize>,
226    /// Controls how much detail tool responses include.
227    ///
228    /// - `full` (default): complete compressed output
229    /// - `headers_only`: metadata line only (path, mode, token count)
230    ///
231    /// Override via `LEAN_CTX_RESPONSE_VERBOSITY` env var.
232    #[serde(default)]
233    pub response_verbosity: ResponseVerbosity,
234    /// Bypass hint mode. When agents use native Read/Grep instead of lean-ctx tools,
235    /// a hint is appended to the next tool response.
236    /// Values: "on" (default), "off", "aggressive" (hint on every call, no cooldown).
237    /// Override via LEAN_CTX_BYPASS_HINTS env var.
238    #[serde(default)]
239    pub bypass_hints: Option<String>,
240    /// Cache policy for ctx_read. Controls behavior on cache hits.
241    /// Values: "aggressive" (default, 13-tok stubs + compaction-aware reset),
242    /// "safe" (delivers map instead of stub), "off" (no caching, always disk read).
243    /// Override via LEAN_CTX_CACHE_POLICY env var.
244    #[serde(default)]
245    pub cache_policy: Option<String>,
246    /// Cross-project boundary policy.
247    /// Controls whether cross-project search/import is allowed and whether access is audited.
248    #[serde(default)]
249    pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
250    #[serde(default)]
251    pub secret_detection: SecretDetectionConfig,
252    /// Allow automatic project-root re-rooting when absolute paths outside the jail are seen.
253    /// When false (default), absolute paths outside the jail are rejected without re-rooting.
254    /// Override via LEAN_CTX_ALLOW_REROOT env var.
255    #[serde(default)]
256    pub allow_auto_reroot: bool,
257    /// Disable PathJail entirely. Set to false to allow all paths.
258    /// Useful in container/Docker environments. Override via LEAN_CTX_NO_JAIL=1.
259    #[serde(default)]
260    pub path_jail: Option<bool>,
261    /// Sandbox level for code execution (ctx_exec).
262    /// 0 = subprocess only (current), 1 = OS-level restriction (Seatbelt/Landlock).
263    /// Override via LEAN_CTX_SANDBOX_LEVEL env var.
264    #[serde(default)]
265    pub sandbox_level: u8,
266    /// When true, large tool outputs (>4000 chars) are stored as references
267    /// and a short URI is returned instead of the full content.
268    /// Override via LEAN_CTX_REFERENCE_RESULTS env var.
269    #[serde(default)]
270    pub reference_results: bool,
271    /// Default per-agent token budget. 0 means unlimited.
272    /// Override per-agent via ctx_session or programmatically.
273    #[serde(default)]
274    pub agent_token_budget: usize,
275    /// Optional shell command allowlist. When non-empty, only commands whose base binary
276    /// is in this list are permitted by ctx_shell. Empty = disable allowlist (allow all).
277    /// Default includes common dev tools. Set to `[]` to disable.
278    /// Override via LEAN_CTX_SHELL_ALLOWLIST env var (comma-separated).
279    #[serde(default = "default_shell_allowlist")]
280    pub shell_allowlist: Vec<String>,
281
282    /// When true, block command substitution ($(), backticks) and process substitution
283    /// (<(), >()) in shell arguments. When false (default), only warn via tracing.
284    /// Default false preserves backward compatibility — set true for maximum security.
285    #[serde(default)]
286    pub shell_strict_mode: bool,
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
290#[serde(default)]
291pub struct SecretDetectionConfig {
292    pub enabled: bool,
293    pub redact: bool,
294    pub custom_patterns: Vec<String>,
295}
296
297impl Default for SecretDetectionConfig {
298    fn default() -> Self {
299        Self {
300            enabled: true,
301            redact: true,
302            custom_patterns: Vec::new(),
303        }
304    }
305}
306
307/// Settings for the zero-loss compression archive (large tool outputs saved to disk).
308#[derive(Debug, Clone, Serialize, Deserialize)]
309#[serde(default)]
310pub struct ArchiveConfig {
311    pub enabled: bool,
312    pub threshold_chars: usize,
313    pub max_age_hours: u64,
314    pub max_disk_mb: u64,
315    pub ephemeral: bool,
316}
317
318impl Default for ArchiveConfig {
319    fn default() -> Self {
320        Self {
321            enabled: true,
322            threshold_chars: 800,
323            max_age_hours: 48,
324            max_disk_mb: 500,
325            ephemeral: true,
326        }
327    }
328}
329
330impl ArchiveConfig {
331    pub fn ephemeral_effective(&self) -> bool {
332        if let Ok(v) = std::env::var("LEAN_CTX_EPHEMERAL") {
333            return !matches!(v.trim(), "0" | "false" | "off");
334        }
335        self.ephemeral && self.enabled
336    }
337}
338
339/// Configuration for external context providers (GitHub, GitLab, Jira, etc.).
340/// Each provider can be enabled/disabled and configured with auth tokens.
341/// Override individual tokens via env vars (GITHUB_TOKEN, GITLAB_TOKEN, etc.).
342#[derive(Debug, Clone, Serialize, Deserialize)]
343#[serde(default)]
344pub struct ProvidersConfig {
345    /// Master switch for the provider subsystem.
346    pub enabled: bool,
347    /// GitHub provider configuration.
348    pub github: ProviderEntryConfig,
349    /// GitLab provider configuration.
350    pub gitlab: ProviderEntryConfig,
351    /// Auto-ingest provider results into BM25/embedding indexes.
352    pub auto_index: bool,
353    /// Default cache TTL for provider results (seconds).
354    pub cache_ttl_secs: u64,
355    /// MCP Bridge providers: `{ "name" = { url = "...", description = "..." } }`.
356    #[serde(default)]
357    pub mcp_bridges: std::collections::HashMap<String, McpBridgeEntry>,
358}
359
360impl Default for ProvidersConfig {
361    fn default() -> Self {
362        Self {
363            enabled: true,
364            github: ProviderEntryConfig::default(),
365            gitlab: ProviderEntryConfig::default(),
366            auto_index: true,
367            cache_ttl_secs: 120,
368            mcp_bridges: std::collections::HashMap::new(),
369        }
370    }
371}
372
373#[derive(Debug, Clone, Serialize, Deserialize)]
374pub struct McpBridgeEntry {
375    /// HTTP/SSE URL for remote MCP servers.
376    #[serde(default)]
377    pub url: Option<String>,
378    /// Command to spawn a local MCP server (stdio transport).
379    #[serde(default)]
380    pub command: Option<String>,
381    /// Arguments for the command.
382    #[serde(default)]
383    pub args: Vec<String>,
384    /// Human-readable description.
385    #[serde(default)]
386    pub description: Option<String>,
387    /// Environment variable name containing an auth token.
388    #[serde(default)]
389    pub auth_env: Option<String>,
390}
391
392/// Per-provider configuration entry.
393#[derive(Debug, Clone, Serialize, Deserialize)]
394#[serde(default)]
395pub struct ProviderEntryConfig {
396    /// Whether this specific provider is enabled.
397    pub enabled: bool,
398    /// Auth token (prefer env var; only use this for project-local overrides).
399    pub token: Option<String>,
400    /// API base URL override (for GitHub Enterprise, self-hosted GitLab, etc.).
401    pub api_url: Option<String>,
402    /// Default project/repo for this provider (auto-detected from git remote if empty).
403    pub project: Option<String>,
404}
405
406impl Default for ProviderEntryConfig {
407    fn default() -> Self {
408        Self {
409            enabled: true,
410            token: None,
411            api_url: None,
412            project: None,
413        }
414    }
415}
416
417/// Controls autonomous background behaviors (preload, dedup, consolidation).
418#[derive(Debug, Clone, Serialize, Deserialize)]
419#[serde(default)]
420pub struct AutonomyConfig {
421    pub enabled: bool,
422    pub auto_preload: bool,
423    pub auto_dedup: bool,
424    pub auto_related: bool,
425    pub auto_consolidate: bool,
426    pub silent_preload: bool,
427    pub dedup_threshold: usize,
428    pub consolidate_every_calls: u32,
429    pub consolidate_cooldown_secs: u64,
430    #[serde(default = "serde_defaults::default_true")]
431    pub cognition_loop_enabled: bool,
432    #[serde(default = "serde_defaults::default_cognition_loop_interval")]
433    pub cognition_loop_interval_secs: u64,
434    #[serde(default = "serde_defaults::default_cognition_loop_max_steps")]
435    pub cognition_loop_max_steps: u8,
436}
437
438impl Default for AutonomyConfig {
439    fn default() -> Self {
440        Self {
441            enabled: true,
442            auto_preload: true,
443            auto_dedup: true,
444            auto_related: true,
445            auto_consolidate: true,
446            silent_preload: true,
447            dedup_threshold: 8,
448            consolidate_every_calls: 25,
449            consolidate_cooldown_secs: 120,
450            cognition_loop_enabled: true,
451            cognition_loop_interval_secs: 3600,
452            cognition_loop_max_steps: 8,
453        }
454    }
455}
456
457/// Controls automatic update behavior. All defaults are OFF — auto-updates
458/// require explicit opt-in via `lean-ctx setup` or `lean-ctx update --schedule`.
459#[derive(Debug, Clone, Serialize, Deserialize)]
460#[serde(default)]
461pub struct UpdatesConfig {
462    pub auto_update: bool,
463    pub check_interval_hours: u64,
464    pub notify_only: bool,
465}
466
467impl Default for UpdatesConfig {
468    fn default() -> Self {
469        Self {
470            auto_update: false,
471            check_interval_hours: 6,
472            notify_only: false,
473        }
474    }
475}
476
477impl UpdatesConfig {
478    pub fn from_env() -> Self {
479        let mut cfg = Self::default();
480        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_UPDATE") {
481            cfg.auto_update = v == "1" || v.eq_ignore_ascii_case("true");
482        }
483        if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_INTERVAL_HOURS") {
484            if let Ok(h) = v.parse::<u64>() {
485                cfg.check_interval_hours = h.clamp(1, 168);
486            }
487        }
488        if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_NOTIFY_ONLY") {
489            cfg.notify_only = v == "1" || v.eq_ignore_ascii_case("true");
490        }
491        cfg
492    }
493}
494
495impl AutonomyConfig {
496    /// Creates an autonomy config from env vars, falling back to defaults.
497    pub fn from_env() -> Self {
498        let mut cfg = Self::default();
499        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
500            if v == "false" || v == "0" {
501                cfg.enabled = false;
502            }
503        }
504        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
505            cfg.auto_preload = v != "false" && v != "0";
506        }
507        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
508            cfg.auto_dedup = v != "false" && v != "0";
509        }
510        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
511            cfg.auto_related = v != "false" && v != "0";
512        }
513        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
514            cfg.auto_consolidate = v != "false" && v != "0";
515        }
516        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
517            cfg.silent_preload = v != "false" && v != "0";
518        }
519        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
520            if let Ok(n) = v.parse() {
521                cfg.dedup_threshold = n;
522            }
523        }
524        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
525            if let Ok(n) = v.parse() {
526                cfg.consolidate_every_calls = n;
527            }
528        }
529        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
530            if let Ok(n) = v.parse() {
531                cfg.consolidate_cooldown_secs = n;
532            }
533        }
534        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
535            cfg.cognition_loop_enabled = v != "false" && v != "0";
536        }
537        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
538            if let Ok(n) = v.parse() {
539                cfg.cognition_loop_interval_secs = n;
540            }
541        }
542        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
543            if let Ok(n) = v.parse() {
544                cfg.cognition_loop_max_steps = n;
545            }
546        }
547        cfg
548    }
549
550    /// Loads autonomy config from disk, with env var overrides applied.
551    pub fn load() -> Self {
552        let file_cfg = Config::load().autonomy;
553        let mut cfg = file_cfg;
554        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
555            if v == "false" || v == "0" {
556                cfg.enabled = false;
557            }
558        }
559        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
560            cfg.auto_preload = v != "false" && v != "0";
561        }
562        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
563            cfg.auto_dedup = v != "false" && v != "0";
564        }
565        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
566            cfg.auto_related = v != "false" && v != "0";
567        }
568        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
569            cfg.silent_preload = v != "false" && v != "0";
570        }
571        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
572            if let Ok(n) = v.parse() {
573                cfg.dedup_threshold = n;
574            }
575        }
576        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
577            cfg.cognition_loop_enabled = v != "false" && v != "0";
578        }
579        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
580            if let Ok(n) = v.parse() {
581                cfg.cognition_loop_interval_secs = n;
582            }
583        }
584        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
585            if let Ok(n) = v.parse() {
586                cfg.cognition_loop_max_steps = n;
587            }
588        }
589        cfg
590    }
591}
592
593/// Cloud sync and contribution settings (pattern sharing, model pulls).
594#[derive(Debug, Clone, Serialize, Deserialize, Default)]
595#[serde(default)]
596pub struct CloudConfig {
597    pub contribute_enabled: bool,
598    pub last_contribute: Option<String>,
599    pub last_sync: Option<String>,
600    pub last_gain_sync: Option<String>,
601    pub last_model_pull: Option<String>,
602}
603
604/// A user-defined command alias mapping for shell compression patterns.
605#[derive(Debug, Clone, Serialize, Deserialize)]
606pub struct AliasEntry {
607    pub command: String,
608    pub alias: String,
609}
610
611/// Thresholds for detecting and throttling repetitive agent tool call loops.
612#[derive(Debug, Clone, Serialize, Deserialize)]
613#[serde(default)]
614pub struct LoopDetectionConfig {
615    pub normal_threshold: u32,
616    pub reduced_threshold: u32,
617    pub blocked_threshold: u32,
618    pub window_secs: u64,
619    pub search_group_limit: u32,
620    pub tool_total_limits: HashMap<String, u32>,
621}
622
623impl Default for LoopDetectionConfig {
624    fn default() -> Self {
625        let mut tool_total_limits = HashMap::new();
626        tool_total_limits.insert("ctx_read".to_string(), 100);
627        tool_total_limits.insert("ctx_search".to_string(), 80);
628        tool_total_limits.insert("ctx_shell".to_string(), 50);
629        tool_total_limits.insert("ctx_semantic_search".to_string(), 60);
630        Self {
631            normal_threshold: 2,
632            reduced_threshold: 4,
633            blocked_threshold: 0,
634            window_secs: 300,
635            search_group_limit: 10,
636            tool_total_limits,
637        }
638    }
639}
640
641impl Default for Config {
642    fn default() -> Self {
643        Self {
644            ultra_compact: false,
645            tee_mode: TeeMode::default(),
646            output_density: OutputDensity::default(),
647            checkpoint_interval: 15,
648            excluded_commands: Vec::new(),
649            passthrough_urls: Vec::new(),
650            custom_aliases: Vec::new(),
651            slow_command_threshold_ms: 5000,
652            theme: serde_defaults::default_theme(),
653            cloud: CloudConfig::default(),
654            autonomy: AutonomyConfig::default(),
655            providers: ProvidersConfig::default(),
656            proxy: ProxyConfig::default(),
657            proxy_enabled: None,
658            proxy_port: None,
659            proxy_timeout_ms: None,
660            buddy_enabled: serde_defaults::default_buddy_enabled(),
661            enable_wakeup_ctx: true,
662            redirect_exclude: Vec::new(),
663            disabled_tools: Vec::new(),
664            default_tool_categories: Vec::new(),
665            no_degrade: false,
666            profile: None,
667            loop_detection: LoopDetectionConfig::default(),
668            rules_scope: None,
669            extra_ignore_patterns: Vec::new(),
670            terse_agent: TerseAgent::default(),
671            compression_level: CompressionLevel::default(),
672            archive: ArchiveConfig::default(),
673            memory: MemoryPolicy::default(),
674            allow_paths: Vec::new(),
675            extra_roots: Vec::new(),
676            content_defined_chunking: false,
677            minimal_overhead: true,
678            symbol_map_auto: true,
679            journal_enabled: true,
680            auto_capture: true,
681            search: crate::core::hybrid_search::HybridConfig::default(),
682            llm: crate::core::llm_enhance::LlmConfig::default(),
683            shell_hook_disabled: false,
684            shell_activation: ShellActivation::default(),
685            update_check_disabled: false,
686            updates: UpdatesConfig::default(),
687            graph_index_max_files: serde_defaults::default_graph_index_max_files(),
688            bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
689            memory_profile: MemoryProfile::default(),
690            memory_cleanup: MemoryCleanup::default(),
691            max_ram_percent: serde_defaults::default_max_ram_percent(),
692            max_disk_mb: 0,
693            max_staleness_days: 0,
694            savings_footer: SavingsFooter::default(),
695            project_root: None,
696            lsp: std::collections::HashMap::new(),
697            ide_paths: HashMap::new(),
698            model_context_windows: HashMap::new(),
699            response_verbosity: ResponseVerbosity::default(),
700            bypass_hints: None,
701            cache_policy: None,
702            boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
703            secret_detection: SecretDetectionConfig::default(),
704            allow_auto_reroot: false,
705            path_jail: None,
706            sandbox_level: 0,
707            reference_results: false,
708            agent_token_budget: 0,
709            shell_allowlist: default_shell_allowlist(),
710            shell_strict_mode: false,
711        }
712    }
713}
714
715pub(crate) fn default_shell_allowlist() -> Vec<String> {
716    [
717        // VCS
718        "git",
719        "gh",
720        "svn",
721        "hg",
722        // Build tools
723        "cargo",
724        "npm",
725        "npx",
726        "yarn",
727        "pnpm",
728        "bun",
729        "bunx",
730        "make",
731        "cmake",
732        "pip",
733        "pip3",
734        "poetry",
735        "uv",
736        "go",
737        "mvn",
738        "gradle",
739        "mix",
740        "dotnet",
741        "swift",
742        "zig",
743        "rustup",
744        "rustc",
745        "deno",
746        "bazel",
747        // Package managers
748        "pipenv",
749        "conda",
750        "mamba",
751        "brew",
752        "apt",
753        "apt-get",
754        "apk",
755        "nix",
756        // Common CLI
757        "ls",
758        "cat",
759        "head",
760        "tail",
761        "wc",
762        "sort",
763        "uniq",
764        "tr",
765        "cut",
766        "grep",
767        "rg",
768        "find",
769        "fd",
770        "ag",
771        "ack",
772        "sed",
773        "awk",
774        "echo",
775        "printf",
776        "true",
777        "false",
778        "test",
779        "expr",
780        "cd",
781        "pwd",
782        "basename",
783        "dirname",
784        "realpath",
785        "readlink",
786        "cp",
787        "mv",
788        "mkdir",
789        "rm",
790        "rmdir",
791        "touch",
792        "ln",
793        "chmod",
794        "chown",
795        "diff",
796        "patch",
797        "tar",
798        "zip",
799        "unzip",
800        "gzip",
801        "gunzip",
802        "zstd",
803        "curl",
804        "wget",
805        "tree",
806        "du",
807        "df",
808        "ps",
809        "lsof",
810        "watch",
811        "tee",
812        "less",
813        "more",
814        "id",
815        "whoami",
816        "uname",
817        "hostname",
818        // Dev tools
819        // docker/podman removed from default: mount-based PathJail bypass risk
820        // Add explicitly if needed: shell_allowlist = [..., "docker"]
821        "node",
822        "python",
823        "python3",
824        "ruby",
825        "perl",
826        "java",
827        "javac",
828        "tsc",
829        "eslint",
830        "prettier",
831        "black",
832        "ruff",
833        "clippy",
834        "jq",
835        "yq",
836        "which",
837        "type",
838        "file",
839        "stat",
840        "date",
841        "sleep",
842        "timeout",
843        "nice",
844        "ionice",
845        // Testing frameworks
846        "pytest",
847        "py.test",
848        "jest",
849        "vitest",
850        "mocha",
851        "cypress",
852        "playwright",
853        "puppeteer",
854        // Pre-commit & git hooks
855        "pre-commit",
856        "husky",
857        "lint-staged",
858        "lefthook",
859        "overcommit",
860        "commitlint",
861        // Linters & formatters
862        "mypy",
863        "pyright",
864        "pylint",
865        "flake8",
866        "bandit",
867        "isort",
868        "autopep8",
869        "yapf",
870        "golangci-lint",
871        "shellcheck",
872        "markdownlint",
873        "stylelint",
874        // Bundlers & dev servers
875        "webpack",
876        "vite",
877        "esbuild",
878        "rollup",
879        "turbo",
880        "nx",
881        "lerna",
882        "next",
883        "nuxt",
884        // Ruby ecosystem
885        "bundle",
886        "bundler",
887        "rake",
888        "rails",
889        "rspec",
890        "rubocop",
891        // PHP ecosystem
892        "php",
893        "composer",
894        "phpunit",
895        "artisan",
896        // Mobile
897        "flutter",
898        "dart",
899        "xcodebuild",
900        "xcrun",
901        "pod",
902        "fastlane",
903        // Cloud & infra
904        "terraform",
905        "ansible",
906        "kubectl",
907        "helm",
908        "az",
909        "aws",
910        "gcloud",
911        "firebase",
912        "heroku",
913        "vercel",
914        "netlify",
915        "fly",
916        "wrangler",
917        "pulumi",
918        // Database
919        "psql",
920        "mysql",
921        "sqlite3",
922        "mongosh",
923        "redis-cli",
924        "pg_dump",
925        "pg_restore",
926        "mysqldump",
927        // JVM ecosystem
928        "scala",
929        "sbt",
930        "kotlin",
931        "kotlinc",
932        // Elixir
933        "elixir",
934        "iex",
935        // lean-ctx itself
936        "lean-ctx",
937    ]
938    .iter()
939    .map(|s| (*s).to_string())
940    .collect()
941}
942
943impl Config {
944    /// Returns the effective rules scope, preferring env var over config file.
945    pub fn rules_scope_effective(&self) -> RulesScope {
946        let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
947            .ok()
948            .or_else(|| self.rules_scope.clone())
949            .unwrap_or_default();
950        match raw.trim().to_lowercase().as_str() {
951            "global" => RulesScope::Global,
952            "project" => RulesScope::Project,
953            _ => RulesScope::Both,
954        }
955    }
956
957    fn parse_disabled_tools_env(val: &str) -> Vec<String> {
958        val.split(',')
959            .map(|s| s.trim().to_string())
960            .filter(|s| !s.is_empty())
961            .collect()
962    }
963
964    /// Returns the effective disabled tools list, preferring env var over config file.
965    pub fn disabled_tools_effective(&self) -> Vec<String> {
966        if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
967            Self::parse_disabled_tools_env(&val)
968        } else {
969            self.disabled_tools.clone()
970        }
971    }
972
973    /// Returns `true` if minimal overhead is enabled via env var or config.
974    pub fn minimal_overhead_effective(&self) -> bool {
975        std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
976    }
977
978    /// Returns `true` if minimal overhead should be enabled for this MCP client.
979    ///
980    /// This is a superset of `minimal_overhead_effective()`:
981    /// - `LEAN_CTX_OVERHEAD_MODE=minimal` forces minimal overhead
982    /// - `LEAN_CTX_OVERHEAD_MODE=full` disables client/model heuristics (still honors LEAN_CTX_MINIMAL / config)
983    /// - In auto mode (default), certain low-context clients/models are treated as minimal to prevent
984    ///   large metadata blocks from destabilizing smaller context windows (e.g. Hermes + MiniMax).
985    pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
986        if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
987            match raw.trim().to_lowercase().as_str() {
988                "minimal" => return true,
989                "full" => return self.minimal_overhead_effective(),
990                _ => {}
991            }
992        }
993
994        if self.minimal_overhead_effective() {
995            return true;
996        }
997
998        let client_lower = client_name.trim().to_lowercase();
999        if !client_lower.is_empty() {
1000            if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
1001                for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
1002                    if !needle.is_empty() && client_lower.contains(&needle) {
1003                        return true;
1004                    }
1005                }
1006            } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
1007                return true;
1008            }
1009        }
1010
1011        let model = std::env::var("LEAN_CTX_MODEL")
1012            .or_else(|_| std::env::var("LCTX_MODEL"))
1013            .unwrap_or_default();
1014        let model = model.trim().to_lowercase();
1015        if !model.is_empty() {
1016            let m = model.replace(['_', ' '], "-");
1017            if m.contains("minimax")
1018                || m.contains("mini-max")
1019                || m.contains("m2.7")
1020                || m.contains("m2-7")
1021            {
1022                return true;
1023            }
1024        }
1025
1026        false
1027    }
1028
1029    /// Returns `true` if shell hook injection is disabled via env var or config.
1030    pub fn shell_hook_disabled_effective(&self) -> bool {
1031        std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
1032    }
1033
1034    /// Returns the effective shell activation mode (env var > config > default).
1035    pub fn shell_activation_effective(&self) -> ShellActivation {
1036        ShellActivation::effective(self)
1037    }
1038
1039    /// Returns `true` if the daily update check is disabled via env var or config.
1040    pub fn update_check_disabled_effective(&self) -> bool {
1041        std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
1042    }
1043
1044    pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
1045        let mut policy = self.memory.clone();
1046        policy.apply_env_overrides();
1047
1048        // Scale memory limits proportionally when max_disk_mb is set
1049        // and individual limits are still at their defaults.
1050        let budget = self.max_disk_mb_effective();
1051        if budget > 0 {
1052            let scale_factor = (budget as f64 / 500.0).clamp(0.5, 10.0);
1053            let default_policy = MemoryPolicy::default();
1054            if policy.knowledge.max_facts == default_policy.knowledge.max_facts {
1055                policy.knowledge.max_facts = (200.0 * scale_factor) as usize;
1056            }
1057            if policy.knowledge.max_patterns == default_policy.knowledge.max_patterns {
1058                policy.knowledge.max_patterns = (50.0 * scale_factor) as usize;
1059            }
1060            if policy.episodic.max_episodes == default_policy.episodic.max_episodes {
1061                policy.episodic.max_episodes = (500.0 * scale_factor) as usize;
1062            }
1063            if policy.procedural.max_procedures == default_policy.procedural.max_procedures {
1064                policy.procedural.max_procedures = (100.0 * scale_factor) as usize;
1065            }
1066        }
1067
1068        policy.validate()?;
1069        Ok(policy)
1070    }
1071
1072    /// Returns the effective set of default tool categories.
1073    /// Priority: LCTX_DEFAULT_CATEGORIES env var > config.toml > hardcoded default.
1074    pub fn default_tool_categories_effective(&self) -> Vec<String> {
1075        if let Ok(val) = std::env::var("LCTX_DEFAULT_CATEGORIES") {
1076            return val
1077                .split(',')
1078                .map(|s| s.trim().to_lowercase())
1079                .filter(|s| !s.is_empty())
1080                .collect();
1081        }
1082        if !self.default_tool_categories.is_empty() {
1083            return self
1084                .default_tool_categories
1085                .iter()
1086                .map(|s| s.to_lowercase())
1087                .collect();
1088        }
1089        vec!["core".to_string(), "session".to_string()]
1090    }
1091
1092    /// Returns `true` if all automatic read-mode degradation is disabled.
1093    /// Checks LCTX_NO_DEGRADE env var first, then config.toml field.
1094    pub fn no_degrade_effective(&self) -> bool {
1095        if let Ok(val) = std::env::var("LCTX_NO_DEGRADE") {
1096            return val == "1" || val.eq_ignore_ascii_case("true");
1097        }
1098        self.no_degrade
1099    }
1100
1101    /// Effective max_disk_mb from env or config.
1102    pub fn max_disk_mb_effective(&self) -> u64 {
1103        std::env::var("LEAN_CTX_MAX_DISK_MB")
1104            .ok()
1105            .and_then(|v| v.parse().ok())
1106            .unwrap_or(self.max_disk_mb)
1107    }
1108
1109    /// Effective max_staleness_days from env or config.
1110    pub fn max_staleness_days_effective(&self) -> u32 {
1111        std::env::var("LEAN_CTX_MAX_STALENESS_DAYS")
1112            .ok()
1113            .and_then(|v| v.parse().ok())
1114            .unwrap_or(self.max_staleness_days)
1115    }
1116
1117    /// Archive max_disk_mb derived from simplified max_disk_mb if the detail
1118    /// value is still at its default. Explicit overrides take priority.
1119    pub fn archive_max_disk_mb_effective(&self) -> u64 {
1120        let budget = self.max_disk_mb_effective();
1121        if budget > 0 && self.archive.max_disk_mb == ArchiveConfig::default().max_disk_mb {
1122            budget * 25 / 100
1123        } else {
1124            self.archive.max_disk_mb
1125        }
1126    }
1127
1128    /// Archive max_age_hours derived from max_staleness_days if the detail
1129    /// value is still at its default. Explicit overrides take priority.
1130    pub fn archive_max_age_hours_effective(&self) -> u64 {
1131        let staleness = self.max_staleness_days_effective();
1132        if staleness > 0 && self.archive.max_age_hours == ArchiveConfig::default().max_age_hours {
1133            staleness as u64 * 24
1134        } else {
1135            self.archive.max_age_hours
1136        }
1137    }
1138
1139    /// BM25 max cache MB derived from simplified max_disk_mb if the detail
1140    /// value is still at its default. Explicit overrides and MemoryProfile take priority.
1141    pub fn bm25_max_cache_mb_effective(&self) -> u64 {
1142        let budget = self.max_disk_mb_effective();
1143        if budget > 0 && self.bm25_max_cache_mb == serde_defaults::default_bm25_max_cache_mb() {
1144            budget * 10 / 100
1145        } else {
1146            let profile = MemoryProfile::effective(self);
1147            if self.bm25_max_cache_mb == serde_defaults::default_bm25_max_cache_mb() {
1148                profile.bm25_max_cache_mb()
1149            } else {
1150                self.bm25_max_cache_mb
1151            }
1152        }
1153    }
1154}
1155
1156#[cfg(test)]
1157mod disabled_tools_tests {
1158    use super::*;
1159
1160    #[test]
1161    fn config_field_default_is_empty() {
1162        let cfg = Config::default();
1163        assert!(cfg.disabled_tools.is_empty());
1164    }
1165
1166    #[test]
1167    fn effective_returns_config_field_when_no_env_var() {
1168        // Only meaningful when LEAN_CTX_DISABLED_TOOLS is unset; skip otherwise.
1169        if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
1170            return;
1171        }
1172        let cfg = Config {
1173            disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
1174            ..Default::default()
1175        };
1176        assert_eq!(
1177            cfg.disabled_tools_effective(),
1178            vec!["ctx_graph", "ctx_agent"]
1179        );
1180    }
1181
1182    #[test]
1183    fn parse_env_basic() {
1184        let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
1185        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
1186    }
1187
1188    #[test]
1189    fn parse_env_trims_whitespace_and_skips_empty() {
1190        let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
1191        assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
1192    }
1193
1194    #[test]
1195    fn parse_env_single_entry() {
1196        let result = Config::parse_disabled_tools_env("ctx_graph");
1197        assert_eq!(result, vec!["ctx_graph"]);
1198    }
1199
1200    #[test]
1201    fn parse_env_empty_string_returns_empty() {
1202        let result = Config::parse_disabled_tools_env("");
1203        assert!(result.is_empty());
1204    }
1205
1206    #[test]
1207    fn disabled_tools_deserialization_defaults_to_empty() {
1208        let cfg: Config = toml::from_str("").unwrap();
1209        assert!(cfg.disabled_tools.is_empty());
1210    }
1211
1212    #[test]
1213    fn disabled_tools_deserialization_from_toml() {
1214        let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
1215        assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
1216    }
1217}
1218
1219#[cfg(test)]
1220mod default_tool_categories_tests {
1221    use super::*;
1222
1223    // --- Defaults ---
1224
1225    #[test]
1226    fn default_returns_core_and_session() {
1227        if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1228            return;
1229        }
1230        let cfg = Config::default();
1231        assert_eq!(
1232            cfg.default_tool_categories_effective(),
1233            vec!["core", "session"]
1234        );
1235    }
1236
1237    #[test]
1238    fn default_struct_field_is_empty_vec() {
1239        let cfg = Config::default();
1240        assert!(cfg.default_tool_categories.is_empty());
1241    }
1242
1243    // --- Config field overrides ---
1244
1245    #[test]
1246    fn config_field_overrides_default() {
1247        if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1248            return;
1249        }
1250        let cfg = Config {
1251            default_tool_categories: vec![
1252                "core".to_string(),
1253                "arch".to_string(),
1254                "memory".to_string(),
1255            ],
1256            ..Default::default()
1257        };
1258        assert_eq!(
1259            cfg.default_tool_categories_effective(),
1260            vec!["core", "arch", "memory"]
1261        );
1262    }
1263
1264    #[test]
1265    fn single_category_in_config() {
1266        if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1267            return;
1268        }
1269        let cfg = Config {
1270            default_tool_categories: vec!["debug".to_string()],
1271            ..Default::default()
1272        };
1273        assert_eq!(cfg.default_tool_categories_effective(), vec!["debug"]);
1274    }
1275
1276    #[test]
1277    fn all_six_categories_in_config() {
1278        if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1279            return;
1280        }
1281        let cfg = Config {
1282            default_tool_categories: vec![
1283                "core".to_string(),
1284                "arch".to_string(),
1285                "debug".to_string(),
1286                "memory".to_string(),
1287                "metrics".to_string(),
1288                "session".to_string(),
1289            ],
1290            ..Default::default()
1291        };
1292        let effective = cfg.default_tool_categories_effective();
1293        assert_eq!(effective.len(), 6);
1294        assert!(effective.contains(&"core".to_string()));
1295        assert!(effective.contains(&"metrics".to_string()));
1296    }
1297
1298    // --- TOML deserialization ---
1299
1300    #[test]
1301    fn deserialization_defaults_to_empty() {
1302        let cfg: Config = toml::from_str("").unwrap();
1303        assert!(cfg.default_tool_categories.is_empty());
1304    }
1305
1306    #[test]
1307    fn deserialization_from_toml() {
1308        let cfg: Config =
1309            toml::from_str(r#"default_tool_categories = ["core", "arch", "debug"]"#).unwrap();
1310        assert_eq!(cfg.default_tool_categories, vec!["core", "arch", "debug"]);
1311    }
1312
1313    #[test]
1314    fn deserialization_empty_array() {
1315        let cfg: Config = toml::from_str(r"default_tool_categories = []").unwrap();
1316        assert!(cfg.default_tool_categories.is_empty());
1317    }
1318
1319    #[test]
1320    fn deserialization_single_entry() {
1321        let cfg: Config = toml::from_str(r#"default_tool_categories = ["memory"]"#).unwrap();
1322        assert_eq!(cfg.default_tool_categories, vec!["memory"]);
1323    }
1324
1325    // --- Edge cases ---
1326
1327    #[test]
1328    fn effective_normalizes_config_to_lowercase() {
1329        if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1330            return;
1331        }
1332        let cfg = Config {
1333            default_tool_categories: vec!["ARCH".to_string(), "Debug".to_string()],
1334            ..Default::default()
1335        };
1336        let effective = cfg.default_tool_categories_effective();
1337        assert_eq!(effective, vec!["arch", "debug"]);
1338    }
1339}
1340
1341#[cfg(test)]
1342mod no_degrade_tests {
1343    use super::*;
1344
1345    // --- Defaults ---
1346
1347    #[test]
1348    fn default_is_false() {
1349        let cfg = Config::default();
1350        assert!(!cfg.no_degrade);
1351    }
1352
1353    #[test]
1354    fn effective_false_when_unset() {
1355        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1356            return;
1357        }
1358        let cfg = Config::default();
1359        assert!(!cfg.no_degrade_effective());
1360    }
1361
1362    // --- Config field ---
1363
1364    #[test]
1365    fn config_field_true_respected_when_no_env() {
1366        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1367            return;
1368        }
1369        let cfg = Config {
1370            no_degrade: true,
1371            ..Default::default()
1372        };
1373        assert!(cfg.no_degrade_effective());
1374    }
1375
1376    #[test]
1377    fn config_field_false_respected_when_no_env() {
1378        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1379            return;
1380        }
1381        let cfg = Config {
1382            no_degrade: false,
1383            ..Default::default()
1384        };
1385        assert!(!cfg.no_degrade_effective());
1386    }
1387
1388    // --- TOML deserialization ---
1389
1390    #[test]
1391    fn deserialization_true() {
1392        let cfg: Config = toml::from_str("no_degrade = true").unwrap();
1393        assert!(cfg.no_degrade);
1394    }
1395
1396    #[test]
1397    fn deserialization_false() {
1398        let cfg: Config = toml::from_str("no_degrade = false").unwrap();
1399        assert!(!cfg.no_degrade);
1400    }
1401
1402    #[test]
1403    fn deserialization_absent_defaults_false() {
1404        let cfg: Config = toml::from_str("").unwrap();
1405        assert!(!cfg.no_degrade);
1406    }
1407
1408    // --- Coexistence with other config fields ---
1409
1410    #[test]
1411    fn no_degrade_independent_of_disabled_tools() {
1412        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1413            return;
1414        }
1415        let cfg = Config {
1416            no_degrade: true,
1417            disabled_tools: vec!["ctx_graph".to_string()],
1418            ..Default::default()
1419        };
1420        assert!(cfg.no_degrade_effective());
1421        assert!(!cfg.disabled_tools.is_empty());
1422    }
1423
1424    #[test]
1425    fn no_degrade_independent_of_tool_categories() {
1426        if std::env::var("LCTX_NO_DEGRADE").is_ok()
1427            || std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok()
1428        {
1429            return;
1430        }
1431        let cfg = Config {
1432            no_degrade: true,
1433            default_tool_categories: vec!["core".to_string(), "arch".to_string()],
1434            ..Default::default()
1435        };
1436        assert!(cfg.no_degrade_effective());
1437        assert_eq!(
1438            cfg.default_tool_categories_effective(),
1439            vec!["core", "arch"]
1440        );
1441    }
1442}
1443
1444#[cfg(test)]
1445mod rules_scope_tests {
1446    use super::*;
1447
1448    #[test]
1449    fn default_is_both() {
1450        let cfg = Config::default();
1451        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1452    }
1453
1454    #[test]
1455    fn config_global() {
1456        let cfg = Config {
1457            rules_scope: Some("global".to_string()),
1458            ..Default::default()
1459        };
1460        assert_eq!(cfg.rules_scope_effective(), RulesScope::Global);
1461    }
1462
1463    #[test]
1464    fn config_project() {
1465        let cfg = Config {
1466            rules_scope: Some("project".to_string()),
1467            ..Default::default()
1468        };
1469        assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1470    }
1471
1472    #[test]
1473    fn unknown_value_falls_back_to_both() {
1474        let cfg = Config {
1475            rules_scope: Some("nonsense".to_string()),
1476            ..Default::default()
1477        };
1478        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1479    }
1480
1481    #[test]
1482    fn deserialization_none_by_default() {
1483        let cfg: Config = toml::from_str("").unwrap();
1484        assert!(cfg.rules_scope.is_none());
1485        assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1486    }
1487
1488    #[test]
1489    fn deserialization_from_toml() {
1490        let cfg: Config = toml::from_str(r#"rules_scope = "project""#).unwrap();
1491        assert_eq!(cfg.rules_scope.as_deref(), Some("project"));
1492        assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1493    }
1494}
1495
1496#[cfg(test)]
1497mod loop_detection_config_tests {
1498    use super::*;
1499
1500    #[test]
1501    fn defaults_are_reasonable() {
1502        let cfg = LoopDetectionConfig::default();
1503        assert_eq!(cfg.normal_threshold, 2);
1504        assert_eq!(cfg.reduced_threshold, 4);
1505        // 0 = blocking disabled by default (LeanCTX philosophy: always help, never block)
1506        assert_eq!(cfg.blocked_threshold, 0);
1507        assert_eq!(cfg.window_secs, 300);
1508        assert_eq!(cfg.search_group_limit, 10);
1509    }
1510
1511    #[test]
1512    fn deserialization_defaults_when_missing() {
1513        let cfg: Config = toml::from_str("").unwrap();
1514        // 0 = blocking disabled by default
1515        assert_eq!(cfg.loop_detection.blocked_threshold, 0);
1516        assert_eq!(cfg.loop_detection.search_group_limit, 10);
1517    }
1518
1519    #[test]
1520    fn deserialization_from_toml() {
1521        let cfg: Config = toml::from_str(
1522            r"
1523            [loop_detection]
1524            normal_threshold = 1
1525            reduced_threshold = 3
1526            blocked_threshold = 5
1527            window_secs = 120
1528            search_group_limit = 8
1529            ",
1530        )
1531        .unwrap();
1532        assert_eq!(cfg.loop_detection.normal_threshold, 1);
1533        assert_eq!(cfg.loop_detection.reduced_threshold, 3);
1534        assert_eq!(cfg.loop_detection.blocked_threshold, 5);
1535        assert_eq!(cfg.loop_detection.window_secs, 120);
1536        assert_eq!(cfg.loop_detection.search_group_limit, 8);
1537    }
1538
1539    #[test]
1540    fn partial_override_keeps_defaults() {
1541        let cfg: Config = toml::from_str(
1542            r"
1543            [loop_detection]
1544            blocked_threshold = 10
1545            ",
1546        )
1547        .unwrap();
1548        assert_eq!(cfg.loop_detection.blocked_threshold, 10);
1549        assert_eq!(cfg.loop_detection.normal_threshold, 2);
1550        assert_eq!(cfg.loop_detection.search_group_limit, 10);
1551    }
1552}
1553
1554impl Config {
1555    /// Returns the path to the global config file (`~/.lean-ctx/config.toml`).
1556    pub fn path() -> Option<PathBuf> {
1557        crate::core::data_dir::lean_ctx_data_dir()
1558            .ok()
1559            .map(|d| d.join("config.toml"))
1560    }
1561
1562    /// Returns the path to the project-local config override file.
1563    pub fn local_path(project_root: &str) -> PathBuf {
1564        PathBuf::from(project_root).join(".lean-ctx.toml")
1565    }
1566
1567    fn find_project_root() -> Option<String> {
1568        static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
1569        ROOT_CACHE
1570            .get_or_init(Self::find_project_root_inner)
1571            .clone()
1572    }
1573
1574    fn find_project_root_inner() -> Option<String> {
1575        if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
1576            if !env_root.is_empty() {
1577                return Some(env_root);
1578            }
1579        }
1580
1581        let cwd = std::env::current_dir().ok();
1582
1583        if let Some(root) =
1584            crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
1585        {
1586            let root_path = std::path::Path::new(&root);
1587            let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
1588            let has_marker = root_path.join(".git").exists()
1589                || root_path.join("Cargo.toml").exists()
1590                || root_path.join("package.json").exists()
1591                || root_path.join("go.mod").exists()
1592                || root_path.join("pyproject.toml").exists()
1593                || root_path.join(".lean-ctx.toml").exists();
1594
1595            if cwd_is_under_root || has_marker {
1596                return Some(root);
1597            }
1598        }
1599
1600        if let Some(ref cwd) = cwd {
1601            let git_root = std::process::Command::new("git")
1602                .args(["rev-parse", "--show-toplevel"])
1603                .current_dir(cwd)
1604                .stdout(std::process::Stdio::piped())
1605                .stderr(std::process::Stdio::null())
1606                .output()
1607                .ok()
1608                .and_then(|o| {
1609                    if o.status.success() {
1610                        String::from_utf8(o.stdout)
1611                            .ok()
1612                            .map(|s| s.trim().to_string())
1613                    } else {
1614                        None
1615                    }
1616                });
1617            if let Some(root) = git_root {
1618                return Some(root);
1619            }
1620            if !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
1621                return Some(cwd.to_string_lossy().to_string());
1622            }
1623        }
1624        None
1625    }
1626
1627    /// Loads config from disk with caching, merging global + project-local overrides.
1628    pub fn load() -> Self {
1629        static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
1630
1631        let Some(path) = Self::path() else {
1632            return Self::default();
1633        };
1634
1635        let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
1636
1637        let mtime = std::fs::metadata(&path)
1638            .and_then(|m| m.modified())
1639            .unwrap_or(SystemTime::UNIX_EPOCH);
1640
1641        let local_mtime = local_path
1642            .as_ref()
1643            .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
1644
1645        if let Ok(guard) = CACHE.lock() {
1646            if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
1647                if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
1648                    return cfg.clone();
1649                }
1650            }
1651        }
1652
1653        let mut cfg: Config = match std::fs::read_to_string(&path) {
1654            Ok(content) => match toml::from_str(&content) {
1655                Ok(c) => c,
1656                Err(e) => {
1657                    tracing::warn!("config parse error in {}: {e}", path.display());
1658                    eprintln!(
1659                        "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n  \
1660                         Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
1661                        path.display()
1662                    );
1663                    Self::default()
1664                }
1665            },
1666            Err(_) => Self::default(),
1667        };
1668
1669        if let Some(ref lp) = local_path {
1670            if let Ok(local_content) = std::fs::read_to_string(lp) {
1671                cfg.merge_local(&local_content);
1672            }
1673        }
1674
1675        if let Ok(mut guard) = CACHE.lock() {
1676            *guard = Some((cfg.clone(), mtime, local_mtime));
1677        }
1678
1679        cfg
1680    }
1681
1682    fn merge_local(&mut self, local_toml: &str) {
1683        let local: Config = match toml::from_str(local_toml) {
1684            Ok(c) => c,
1685            Err(e) => {
1686                tracing::warn!("local config parse error: {e}");
1687                eprintln!(
1688                    "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n  \
1689                     Local overrides skipped.\x1b[0m"
1690                );
1691                return;
1692            }
1693        };
1694        if local.ultra_compact {
1695            self.ultra_compact = true;
1696        }
1697        if local.tee_mode != TeeMode::default() {
1698            self.tee_mode = local.tee_mode;
1699        }
1700        if local.output_density != OutputDensity::default() {
1701            self.output_density = local.output_density;
1702        }
1703        if local.checkpoint_interval != 15 {
1704            self.checkpoint_interval = local.checkpoint_interval;
1705        }
1706        if !local.excluded_commands.is_empty() {
1707            self.excluded_commands.extend(local.excluded_commands);
1708        }
1709        if !local.passthrough_urls.is_empty() {
1710            self.passthrough_urls.extend(local.passthrough_urls);
1711        }
1712        if !local.custom_aliases.is_empty() {
1713            self.custom_aliases.extend(local.custom_aliases);
1714        }
1715        if local.slow_command_threshold_ms != 5000 {
1716            self.slow_command_threshold_ms = local.slow_command_threshold_ms;
1717        }
1718        if local.theme != "default" {
1719            self.theme = local.theme;
1720        }
1721        if !local.buddy_enabled {
1722            self.buddy_enabled = false;
1723        }
1724        if !local.enable_wakeup_ctx {
1725            self.enable_wakeup_ctx = false;
1726        }
1727        if !local.redirect_exclude.is_empty() {
1728            self.redirect_exclude.extend(local.redirect_exclude);
1729        }
1730        if !local.disabled_tools.is_empty() {
1731            self.disabled_tools.extend(local.disabled_tools);
1732        }
1733        if !local.extra_ignore_patterns.is_empty() {
1734            self.extra_ignore_patterns
1735                .extend(local.extra_ignore_patterns);
1736        }
1737        if local.rules_scope.is_some() {
1738            self.rules_scope = local.rules_scope;
1739        }
1740        if local.proxy.anthropic_upstream.is_some() {
1741            self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
1742        }
1743        if local.proxy.openai_upstream.is_some() {
1744            self.proxy.openai_upstream = local.proxy.openai_upstream;
1745        }
1746        if local.proxy.gemini_upstream.is_some() {
1747            self.proxy.gemini_upstream = local.proxy.gemini_upstream;
1748        }
1749        if !local.autonomy.enabled {
1750            self.autonomy.enabled = false;
1751        }
1752        if !local.autonomy.auto_preload {
1753            self.autonomy.auto_preload = false;
1754        }
1755        if !local.autonomy.auto_dedup {
1756            self.autonomy.auto_dedup = false;
1757        }
1758        if !local.autonomy.auto_related {
1759            self.autonomy.auto_related = false;
1760        }
1761        if !local.autonomy.auto_consolidate {
1762            self.autonomy.auto_consolidate = false;
1763        }
1764        if local.autonomy.silent_preload {
1765            self.autonomy.silent_preload = true;
1766        }
1767        if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1768            self.autonomy.silent_preload = false;
1769        }
1770        if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1771            self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1772        }
1773        if local.autonomy.consolidate_every_calls
1774            != AutonomyConfig::default().consolidate_every_calls
1775        {
1776            self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1777        }
1778        if local.autonomy.consolidate_cooldown_secs
1779            != AutonomyConfig::default().consolidate_cooldown_secs
1780        {
1781            self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1782        }
1783        if !local.autonomy.cognition_loop_enabled {
1784            self.autonomy.cognition_loop_enabled = false;
1785        }
1786        if local.autonomy.cognition_loop_interval_secs
1787            != AutonomyConfig::default().cognition_loop_interval_secs
1788        {
1789            self.autonomy.cognition_loop_interval_secs =
1790                local.autonomy.cognition_loop_interval_secs;
1791        }
1792        if local.autonomy.cognition_loop_max_steps
1793            != AutonomyConfig::default().cognition_loop_max_steps
1794        {
1795            self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
1796        }
1797        if local_toml.contains("compression_level") {
1798            self.compression_level = local.compression_level;
1799        }
1800        if local_toml.contains("terse_agent") {
1801            self.terse_agent = local.terse_agent;
1802        }
1803        if !local.archive.enabled {
1804            self.archive.enabled = false;
1805        }
1806        if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1807            self.archive.threshold_chars = local.archive.threshold_chars;
1808        }
1809        if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1810            self.archive.max_age_hours = local.archive.max_age_hours;
1811        }
1812        if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1813            self.archive.max_disk_mb = local.archive.max_disk_mb;
1814        }
1815        if !local.archive.ephemeral {
1816            self.archive.ephemeral = false;
1817        }
1818        let mem_def = MemoryPolicy::default();
1819        if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1820            self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1821        }
1822        if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1823            self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1824        }
1825        if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1826            self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1827        }
1828        if local.memory.knowledge.contradiction_threshold
1829            != mem_def.knowledge.contradiction_threshold
1830        {
1831            self.memory.knowledge.contradiction_threshold =
1832                local.memory.knowledge.contradiction_threshold;
1833        }
1834
1835        if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1836            self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1837        }
1838        if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1839        {
1840            self.memory.episodic.max_actions_per_episode =
1841                local.memory.episodic.max_actions_per_episode;
1842        }
1843        if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1844            self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1845        }
1846
1847        if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1848            self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1849        }
1850        if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1851            self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1852        }
1853        if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1854            self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1855        }
1856        if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1857            self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1858        }
1859
1860        if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1861            self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1862        }
1863        if local.memory.lifecycle.low_confidence_threshold
1864            != mem_def.lifecycle.low_confidence_threshold
1865        {
1866            self.memory.lifecycle.low_confidence_threshold =
1867                local.memory.lifecycle.low_confidence_threshold;
1868        }
1869        if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1870            self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1871        }
1872        if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1873            self.memory.lifecycle.similarity_threshold =
1874                local.memory.lifecycle.similarity_threshold;
1875        }
1876
1877        if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1878            self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1879        }
1880        if !local.allow_paths.is_empty() {
1881            self.allow_paths.extend(local.allow_paths);
1882        }
1883        if !local.extra_roots.is_empty() {
1884            self.extra_roots.extend(local.extra_roots);
1885        }
1886        if local.minimal_overhead {
1887            self.minimal_overhead = true;
1888        }
1889        if local.shell_hook_disabled {
1890            self.shell_hook_disabled = true;
1891        }
1892        if local.shell_activation != ShellActivation::default() {
1893            self.shell_activation = local.shell_activation.clone();
1894        }
1895        if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1896            self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1897        }
1898        if local.memory_profile != MemoryProfile::default() {
1899            self.memory_profile = local.memory_profile;
1900        }
1901        if local.memory_cleanup != MemoryCleanup::default() {
1902            self.memory_cleanup = local.memory_cleanup;
1903        }
1904        if !local.shell_allowlist.is_empty() {
1905            self.shell_allowlist = local.shell_allowlist;
1906        }
1907        if !local.default_tool_categories.is_empty() {
1908            self.default_tool_categories = local.default_tool_categories;
1909        }
1910        if local.no_degrade {
1911            self.no_degrade = true;
1912        }
1913        if local.profile.is_some() {
1914            self.profile = local.profile;
1915        }
1916        if local.proxy_timeout_ms.is_some() {
1917            self.proxy_timeout_ms = local.proxy_timeout_ms;
1918        }
1919    }
1920
1921    /// Persists the current config to the global config file.
1922    pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1923        let path = Self::path().ok_or_else(|| {
1924            super::error::LeanCtxError::Config("cannot determine home directory".into())
1925        })?;
1926        if let Some(parent) = path.parent() {
1927            std::fs::create_dir_all(parent)?;
1928        }
1929        let content = toml::to_string_pretty(self)
1930            .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1931        std::fs::write(&path, content)?;
1932        Ok(())
1933    }
1934
1935    /// Formats the current config as a human-readable string with file paths.
1936    pub fn show(&self) -> String {
1937        let global_path = Self::path().map_or_else(
1938            || "~/.lean-ctx/config.toml".to_string(),
1939            |p| p.to_string_lossy().to_string(),
1940        );
1941        let content = toml::to_string_pretty(self).unwrap_or_default();
1942        let mut out = format!("Global config: {global_path}\n\n{content}");
1943
1944        if let Some(root) = Self::find_project_root() {
1945            let local = Self::local_path(&root);
1946            if local.exists() {
1947                out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1948            } else {
1949                out.push_str(&format!(
1950                    "\n\nLocal config: not found (create {} to override per-project)\n",
1951                    local.display()
1952                ));
1953            }
1954        }
1955        out
1956    }
1957}
1958
1959#[cfg(test)]
1960mod extra_roots_tests {
1961    use super::*;
1962
1963    #[test]
1964    fn default_is_empty() {
1965        let cfg = Config::default();
1966        assert!(cfg.extra_roots.is_empty());
1967    }
1968
1969    #[test]
1970    fn deserialization_from_toml() {
1971        let cfg: Config = toml::from_str(r#"extra_roots = ["/data/store", "/test/env"]"#).unwrap();
1972        assert_eq!(cfg.extra_roots, vec!["/data/store", "/test/env"]);
1973    }
1974
1975    #[test]
1976    fn merge_extends() {
1977        let mut base = Config {
1978            extra_roots: vec!["/base".to_string()],
1979            ..Config::default()
1980        };
1981        base.merge_local(r#"extra_roots = ["/local"]"#);
1982        assert_eq!(base.extra_roots, vec!["/base", "/local"]);
1983    }
1984}
1985
1986#[cfg(test)]
1987mod compression_level_tests {
1988    use super::*;
1989
1990    #[test]
1991    fn default_is_lite() {
1992        // Friendly default: plain-English concise guidance, not the symbolic
1993        // dense/expert-terse styles (those are opt-in power modes).
1994        assert_eq!(CompressionLevel::default(), CompressionLevel::Lite);
1995    }
1996
1997    #[test]
1998    fn to_components_off() {
1999        let (ta, od, crp, tm) = CompressionLevel::Off.to_components();
2000        assert_eq!(ta, TerseAgent::Off);
2001        assert_eq!(od, OutputDensity::Normal);
2002        assert_eq!(crp, "off");
2003        assert!(!tm);
2004    }
2005
2006    #[test]
2007    fn to_components_lite() {
2008        let (ta, od, crp, tm) = CompressionLevel::Lite.to_components();
2009        assert_eq!(ta, TerseAgent::Lite);
2010        assert_eq!(od, OutputDensity::Terse);
2011        assert_eq!(crp, "off");
2012        assert!(tm);
2013    }
2014
2015    #[test]
2016    fn to_components_standard() {
2017        let (ta, od, crp, tm) = CompressionLevel::Standard.to_components();
2018        assert_eq!(ta, TerseAgent::Full);
2019        assert_eq!(od, OutputDensity::Terse);
2020        assert_eq!(crp, "compact");
2021        assert!(tm);
2022    }
2023
2024    #[test]
2025    fn to_components_max() {
2026        let (ta, od, crp, tm) = CompressionLevel::Max.to_components();
2027        assert_eq!(ta, TerseAgent::Ultra);
2028        assert_eq!(od, OutputDensity::Ultra);
2029        assert_eq!(crp, "tdd");
2030        assert!(tm);
2031    }
2032
2033    #[test]
2034    fn from_legacy_ultra_agent_maps_to_max() {
2035        assert_eq!(
2036            CompressionLevel::from_legacy(&TerseAgent::Ultra, &OutputDensity::Normal),
2037            CompressionLevel::Max
2038        );
2039    }
2040
2041    #[test]
2042    fn from_legacy_ultra_density_maps_to_max() {
2043        assert_eq!(
2044            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Ultra),
2045            CompressionLevel::Max
2046        );
2047    }
2048
2049    #[test]
2050    fn from_legacy_full_agent_maps_to_standard() {
2051        assert_eq!(
2052            CompressionLevel::from_legacy(&TerseAgent::Full, &OutputDensity::Normal),
2053            CompressionLevel::Standard
2054        );
2055    }
2056
2057    #[test]
2058    fn from_legacy_lite_agent_maps_to_lite() {
2059        assert_eq!(
2060            CompressionLevel::from_legacy(&TerseAgent::Lite, &OutputDensity::Normal),
2061            CompressionLevel::Lite
2062        );
2063    }
2064
2065    #[test]
2066    fn from_legacy_terse_density_maps_to_lite() {
2067        assert_eq!(
2068            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Terse),
2069            CompressionLevel::Lite
2070        );
2071    }
2072
2073    #[test]
2074    fn from_legacy_both_off_maps_to_off() {
2075        assert_eq!(
2076            CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Normal),
2077            CompressionLevel::Off
2078        );
2079    }
2080
2081    #[test]
2082    fn labels_match() {
2083        assert_eq!(CompressionLevel::Off.label(), "off");
2084        assert_eq!(CompressionLevel::Lite.label(), "lite");
2085        assert_eq!(CompressionLevel::Standard.label(), "standard");
2086        assert_eq!(CompressionLevel::Max.label(), "max");
2087    }
2088
2089    #[test]
2090    fn is_active_false_for_off() {
2091        assert!(!CompressionLevel::Off.is_active());
2092    }
2093
2094    #[test]
2095    fn is_active_true_for_all_others() {
2096        assert!(CompressionLevel::Lite.is_active());
2097        assert!(CompressionLevel::Standard.is_active());
2098        assert!(CompressionLevel::Max.is_active());
2099    }
2100
2101    #[test]
2102    fn deserialization_defaults_to_lite() {
2103        let cfg: Config = toml::from_str("").unwrap();
2104        assert_eq!(cfg.compression_level, CompressionLevel::Lite);
2105    }
2106
2107    #[test]
2108    fn deserialization_from_toml() {
2109        let cfg: Config = toml::from_str(r#"compression_level = "standard""#).unwrap();
2110        assert_eq!(cfg.compression_level, CompressionLevel::Standard);
2111    }
2112
2113    #[test]
2114    fn roundtrip_all_levels() {
2115        for level in [
2116            CompressionLevel::Off,
2117            CompressionLevel::Lite,
2118            CompressionLevel::Standard,
2119            CompressionLevel::Max,
2120        ] {
2121            let (ta, od, crp, tm) = level.to_components();
2122            assert!(!crp.is_empty());
2123            if level == CompressionLevel::Off {
2124                assert!(!tm);
2125                assert_eq!(ta, TerseAgent::Off);
2126                assert_eq!(od, OutputDensity::Normal);
2127            } else {
2128                assert!(tm);
2129            }
2130        }
2131    }
2132}
2133
2134#[cfg(test)]
2135mod memory_cleanup_tests {
2136    use super::*;
2137
2138    #[test]
2139    fn default_is_aggressive() {
2140        assert_eq!(MemoryCleanup::default(), MemoryCleanup::Aggressive);
2141    }
2142
2143    #[test]
2144    fn aggressive_ttl_is_300() {
2145        assert_eq!(MemoryCleanup::Aggressive.idle_ttl_secs(), 300);
2146    }
2147
2148    #[test]
2149    fn shared_ttl_is_1800() {
2150        assert_eq!(MemoryCleanup::Shared.idle_ttl_secs(), 1800);
2151    }
2152
2153    #[test]
2154    fn index_retention_multiplier_values() {
2155        assert!(
2156            (MemoryCleanup::Aggressive.index_retention_multiplier() - 1.0).abs() < f64::EPSILON
2157        );
2158        assert!((MemoryCleanup::Shared.index_retention_multiplier() - 3.0).abs() < f64::EPSILON);
2159    }
2160
2161    #[test]
2162    fn deserialization_defaults_to_aggressive() {
2163        let cfg: Config = toml::from_str("").unwrap();
2164        assert_eq!(cfg.memory_cleanup, MemoryCleanup::Aggressive);
2165    }
2166
2167    #[test]
2168    fn deserialization_from_toml() {
2169        let cfg: Config = toml::from_str(r#"memory_cleanup = "shared""#).unwrap();
2170        assert_eq!(cfg.memory_cleanup, MemoryCleanup::Shared);
2171    }
2172
2173    #[test]
2174    fn effective_uses_config_when_no_env() {
2175        let cfg = Config {
2176            memory_cleanup: MemoryCleanup::Shared,
2177            ..Default::default()
2178        };
2179        let eff = MemoryCleanup::effective(&cfg);
2180        assert_eq!(eff, MemoryCleanup::Shared);
2181    }
2182}
2183
2184#[cfg(test)]
2185mod simplified_config_tests {
2186    use super::*;
2187
2188    #[test]
2189    fn max_disk_mb_zero_means_disabled() {
2190        let cfg = Config::default();
2191        assert_eq!(cfg.max_disk_mb, 0);
2192        assert_eq!(cfg.max_disk_mb_effective(), 0);
2193    }
2194
2195    #[test]
2196    fn archive_derives_from_disk_budget() {
2197        let cfg = Config {
2198            max_disk_mb: 4000,
2199            ..Default::default()
2200        };
2201        assert_eq!(cfg.archive_max_disk_mb_effective(), 1000);
2202    }
2203
2204    #[test]
2205    fn archive_explicit_overrides_derived() {
2206        let cfg = Config {
2207            max_disk_mb: 4000,
2208            archive: ArchiveConfig {
2209                max_disk_mb: 800,
2210                ..Default::default()
2211            },
2212            ..Default::default()
2213        };
2214        assert_eq!(cfg.archive_max_disk_mb_effective(), 800);
2215    }
2216
2217    #[test]
2218    fn bm25_derives_from_disk_budget() {
2219        let cfg = Config {
2220            max_disk_mb: 4000,
2221            ..Default::default()
2222        };
2223        assert_eq!(cfg.bm25_max_cache_mb_effective(), 400);
2224    }
2225
2226    #[test]
2227    fn bm25_explicit_overrides_derived() {
2228        let cfg = Config {
2229            max_disk_mb: 4000,
2230            bm25_max_cache_mb: 256,
2231            ..Default::default()
2232        };
2233        assert_eq!(cfg.bm25_max_cache_mb_effective(), 256);
2234    }
2235
2236    #[test]
2237    fn staleness_days_derives_archive_age() {
2238        let cfg = Config {
2239            max_staleness_days: 30,
2240            ..Default::default()
2241        };
2242        assert_eq!(cfg.archive_max_age_hours_effective(), 720);
2243    }
2244
2245    #[test]
2246    fn staleness_explicit_archive_age_overrides() {
2247        let cfg = Config {
2248            max_staleness_days: 30,
2249            archive: ArchiveConfig {
2250                max_age_hours: 96,
2251                ..Default::default()
2252            },
2253            ..Default::default()
2254        };
2255        assert_eq!(cfg.archive_max_age_hours_effective(), 96);
2256    }
2257
2258    #[test]
2259    fn no_budget_returns_defaults() {
2260        let cfg = Config::default();
2261        assert_eq!(
2262            cfg.archive_max_disk_mb_effective(),
2263            ArchiveConfig::default().max_disk_mb
2264        );
2265        assert_eq!(
2266            cfg.archive_max_age_hours_effective(),
2267            ArchiveConfig::default().max_age_hours
2268        );
2269    }
2270
2271    #[test]
2272    fn memory_limits_scale_with_disk_budget() {
2273        let cfg = Config {
2274            max_disk_mb: 2000,
2275            ..Default::default()
2276        };
2277        let policy = cfg.memory_policy_effective().unwrap();
2278        // factor = 2000/500 = 4.0
2279        assert_eq!(policy.knowledge.max_facts, 800);
2280        assert_eq!(policy.knowledge.max_patterns, 200);
2281        assert_eq!(policy.episodic.max_episodes, 2000);
2282        assert_eq!(policy.procedural.max_procedures, 400);
2283    }
2284
2285    #[test]
2286    fn memory_limits_clamped_at_max_factor() {
2287        let cfg = Config {
2288            max_disk_mb: 50_000,
2289            ..Default::default()
2290        };
2291        let policy = cfg.memory_policy_effective().unwrap();
2292        // factor clamped at 10.0
2293        assert_eq!(policy.knowledge.max_facts, 2000);
2294        assert_eq!(policy.episodic.max_episodes, 5000);
2295    }
2296
2297    #[test]
2298    fn memory_limits_unchanged_when_no_budget() {
2299        let cfg = Config::default();
2300        let policy = cfg.memory_policy_effective().unwrap();
2301        assert_eq!(policy.knowledge.max_facts, 200);
2302        assert_eq!(policy.episodic.max_episodes, 500);
2303    }
2304
2305    #[test]
2306    fn simplified_template_is_valid_toml() {
2307        let parsed: Result<toml::Table, _> = toml::from_str(crate::cli::SIMPLIFIED_TEMPLATE);
2308        assert!(parsed.is_ok(), "Template must be valid TOML");
2309    }
2310}