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
24pub fn default_bm25_max_cache_mb() -> u64 {
26 serde_defaults::default_bm25_max_cache_mb()
27}
28
29pub const DEFAULT_BM25_PERSIST_MB: u64 = 512;
39
40const _: () = assert!(DEFAULT_BM25_PERSIST_MB >= 512);
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(default)]
47pub struct Config {
48 pub ultra_compact: bool,
49 #[serde(default, deserialize_with = "serde_defaults::deserialize_tee_mode")]
50 pub tee_mode: TeeMode,
51 #[serde(default)]
52 pub output_density: OutputDensity,
53 pub checkpoint_interval: u32,
54 pub excluded_commands: Vec<String>,
55 pub passthrough_urls: Vec<String>,
56 pub custom_aliases: Vec<AliasEntry>,
57 pub slow_command_threshold_ms: u64,
60 #[serde(default = "serde_defaults::default_theme")]
61 pub theme: String,
62 #[serde(default)]
63 pub cloud: CloudConfig,
64 #[serde(default)]
65 pub gain: GainConfig,
66 #[serde(default)]
67 pub autonomy: AutonomyConfig,
68 #[serde(default)]
69 pub providers: ProvidersConfig,
70 #[serde(default)]
71 pub proxy: ProxyConfig,
72 #[serde(default)]
77 pub proxy_enabled: Option<bool>,
78 #[serde(default)]
79 pub proxy_port: Option<u16>,
80 #[serde(default)]
83 pub proxy_timeout_ms: Option<u64>,
84 #[serde(default = "serde_defaults::default_buddy_enabled")]
85 pub buddy_enabled: bool,
86 #[serde(default = "serde_defaults::default_true")]
87 pub enable_wakeup_ctx: bool,
88 #[serde(default)]
89 pub redirect_exclude: Vec<String>,
90 #[serde(default)]
94 pub disabled_tools: Vec<String>,
95 #[serde(default)]
101 pub default_tool_categories: Vec<String>,
102 #[serde(default)]
106 pub no_degrade: bool,
107 #[serde(default)]
110 pub profile: Option<String>,
111 #[serde(default)]
115 pub tool_profile: Option<String>,
116 #[serde(default)]
119 pub tools_enabled: Vec<String>,
120 #[serde(default)]
121 pub loop_detection: LoopDetectionConfig,
122 #[serde(default)]
126 pub rules_scope: Option<String>,
127 #[serde(default)]
130 pub extra_ignore_patterns: Vec<String>,
131 #[serde(default)]
135 pub terse_agent: TerseAgent,
136 #[serde(default)]
140 pub compression_level: CompressionLevel,
141 #[serde(default)]
143 pub archive: ArchiveConfig,
144 #[serde(default)]
146 pub memory: MemoryPolicy,
147 #[serde(default)]
151 pub allow_paths: Vec<String>,
152 #[serde(default)]
157 pub extra_roots: Vec<String>,
158 #[serde(default)]
161 pub content_defined_chunking: bool,
162 #[serde(default)]
165 pub minimal_overhead: bool,
166 #[serde(default = "serde_defaults::default_true")]
168 pub symbol_map_auto: bool,
169 #[serde(default)]
171 pub journal_enabled: bool,
172 #[serde(default)]
174 pub auto_capture: bool,
175 #[serde(default)]
177 pub search: crate::core::hybrid_search::HybridConfig,
178 #[serde(default)]
180 pub llm: crate::core::llm_enhance::LlmConfig,
181 #[serde(default)]
183 pub embedding: EmbeddingConfig,
184 #[serde(default)]
187 pub shell_hook_disabled: bool,
188 #[serde(default)]
193 pub shadow_mode: bool,
194 #[serde(default)]
201 pub shell_activation: ShellActivation,
202 #[serde(default)]
205 pub update_check_disabled: bool,
206 #[serde(default)]
207 pub updates: UpdatesConfig,
208 #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
211 pub bm25_max_cache_mb: u64,
212 #[serde(default = "serde_defaults::default_graph_index_max_files")]
215 pub graph_index_max_files: u64,
216 #[serde(default)]
219 pub memory_profile: MemoryProfile,
220 #[serde(default)]
224 pub memory_cleanup: MemoryCleanup,
225 #[serde(default = "serde_defaults::default_max_ram_percent")]
228 pub max_ram_percent: u8,
229 #[serde(default)]
233 pub max_disk_mb: u64,
234 #[serde(default)]
237 pub max_staleness_days: u32,
238 #[serde(default)]
242 pub savings_footer: SavingsFooter,
243 #[serde(default)]
247 pub project_root: Option<String>,
248 #[serde(default)]
251 pub lsp: std::collections::HashMap<String, String>,
252 #[serde(default)]
256 pub ide_paths: HashMap<String, Vec<String>>,
257 #[serde(default)]
260 pub model_context_windows: HashMap<String, usize>,
261 #[serde(default)]
268 pub response_verbosity: ResponseVerbosity,
269 #[serde(default)]
274 pub bypass_hints: Option<String>,
275 #[serde(default)]
280 pub cache_policy: Option<String>,
281 #[serde(default)]
284 pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
285 #[serde(default)]
286 pub secret_detection: SecretDetectionConfig,
287 #[serde(default)]
291 pub allow_auto_reroot: bool,
292 #[serde(default)]
295 pub path_jail: Option<bool>,
296 #[serde(default)]
300 pub sandbox_level: u8,
301 #[serde(default)]
305 pub reference_results: bool,
306 #[serde(default)]
309 pub agent_token_budget: usize,
310 #[serde(default = "default_shell_allowlist")]
315 pub shell_allowlist: Vec<String>,
316
317 #[serde(default)]
321 pub shell_strict_mode: bool,
322 #[serde(default)]
324 pub setup: SetupConfig,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize)]
328#[serde(default)]
329pub struct SecretDetectionConfig {
330 pub enabled: bool,
331 pub redact: bool,
332 pub custom_patterns: Vec<String>,
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
340#[serde(default)]
341pub struct SetupConfig {
342 pub auto_inject_rules: Option<bool>,
346 pub auto_inject_skills: Option<bool>,
349 #[serde(default = "serde_defaults::default_true")]
351 pub auto_update_mcp: bool,
352}
353
354impl Default for SetupConfig {
355 fn default() -> Self {
356 Self {
357 auto_inject_rules: None,
358 auto_inject_skills: None,
359 auto_update_mcp: true,
360 }
361 }
362}
363
364impl SetupConfig {
365 pub fn should_inject_rules(&self) -> bool {
369 match self.auto_inject_rules {
370 Some(v) => v,
371 None => Self::rules_already_present(),
372 }
373 }
374
375 pub fn should_inject_skills(&self) -> bool {
377 match self.auto_inject_skills {
378 Some(v) => v,
379 None => Self::rules_already_present(),
380 }
381 }
382
383 fn rules_already_present() -> bool {
385 let Some(home) = dirs::home_dir() else {
386 return false;
387 };
388 let marker = crate::rules_inject::RULES_MARKER;
389 let check_paths = [
390 home.join(".cursor/rules/lean-ctx.mdc"),
391 crate::core::editor_registry::claude_rules_dir(&home).join("lean-ctx.md"),
392 home.join(".gemini/GEMINI.md"),
393 home.join(".codeium/windsurf/rules/lean-ctx.md"),
394 ];
395 for p in &check_paths {
396 if let Ok(content) = std::fs::read_to_string(p) {
397 if content.contains(marker) {
398 return true;
399 }
400 }
401 }
402 false
403 }
404}
405
406impl Default for SecretDetectionConfig {
407 fn default() -> Self {
408 Self {
409 enabled: true,
410 redact: true,
411 custom_patterns: Vec::new(),
412 }
413 }
414}
415
416#[derive(Debug, Clone, Serialize, Deserialize)]
418#[serde(default)]
419pub struct ArchiveConfig {
420 pub enabled: bool,
421 pub threshold_chars: usize,
422 pub max_age_hours: u64,
423 pub max_disk_mb: u64,
424 pub ephemeral: bool,
425}
426
427impl Default for ArchiveConfig {
428 fn default() -> Self {
429 Self {
430 enabled: true,
431 threshold_chars: 800,
432 max_age_hours: 48,
433 max_disk_mb: 500,
434 ephemeral: true,
435 }
436 }
437}
438
439impl ArchiveConfig {
440 pub fn ephemeral_effective(&self) -> bool {
441 if let Ok(v) = std::env::var("LEAN_CTX_EPHEMERAL") {
442 return !matches!(v.trim(), "0" | "false" | "off");
443 }
444 self.ephemeral && self.enabled
445 }
446}
447
448#[derive(Debug, Clone, Serialize, Deserialize)]
452#[serde(default)]
453pub struct ProvidersConfig {
454 pub enabled: bool,
456 pub github: ProviderEntryConfig,
458 pub gitlab: ProviderEntryConfig,
460 pub auto_index: bool,
462 pub cache_ttl_secs: u64,
464 #[serde(default)]
466 pub mcp_bridges: std::collections::HashMap<String, McpBridgeEntry>,
467}
468
469impl Default for ProvidersConfig {
470 fn default() -> Self {
471 Self {
472 enabled: true,
473 github: ProviderEntryConfig::default(),
474 gitlab: ProviderEntryConfig::default(),
475 auto_index: true,
476 cache_ttl_secs: 120,
477 mcp_bridges: std::collections::HashMap::new(),
478 }
479 }
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct McpBridgeEntry {
484 #[serde(default)]
486 pub url: Option<String>,
487 #[serde(default)]
489 pub command: Option<String>,
490 #[serde(default)]
492 pub args: Vec<String>,
493 #[serde(default)]
495 pub description: Option<String>,
496 #[serde(default)]
498 pub auth_env: Option<String>,
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize)]
503#[serde(default)]
504pub struct ProviderEntryConfig {
505 pub enabled: bool,
507 pub token: Option<String>,
509 pub api_url: Option<String>,
511 pub project: Option<String>,
513}
514
515impl Default for ProviderEntryConfig {
516 fn default() -> Self {
517 Self {
518 enabled: true,
519 token: None,
520 api_url: None,
521 project: None,
522 }
523 }
524}
525
526#[derive(Debug, Clone, Serialize, Deserialize)]
528#[serde(default)]
529pub struct AutonomyConfig {
530 pub enabled: bool,
531 pub auto_preload: bool,
532 pub auto_dedup: bool,
533 pub auto_related: bool,
534 pub auto_consolidate: bool,
535 pub silent_preload: bool,
536 pub dedup_threshold: usize,
537 pub consolidate_every_calls: u32,
538 pub consolidate_cooldown_secs: u64,
539 #[serde(default = "serde_defaults::default_true")]
540 pub cognition_loop_enabled: bool,
541 #[serde(default = "serde_defaults::default_cognition_loop_interval")]
542 pub cognition_loop_interval_secs: u64,
543 #[serde(default = "serde_defaults::default_cognition_loop_max_steps")]
544 pub cognition_loop_max_steps: u8,
545}
546
547impl Default for AutonomyConfig {
548 fn default() -> Self {
549 Self {
550 enabled: true,
551 auto_preload: true,
552 auto_dedup: true,
553 auto_related: true,
554 auto_consolidate: true,
555 silent_preload: true,
556 dedup_threshold: 8,
557 consolidate_every_calls: 25,
558 consolidate_cooldown_secs: 120,
559 cognition_loop_enabled: true,
560 cognition_loop_interval_secs: 3600,
561 cognition_loop_max_steps: 8,
562 }
563 }
564}
565
566#[derive(Debug, Clone, Serialize, Deserialize)]
569#[serde(default)]
570pub struct UpdatesConfig {
571 pub auto_update: bool,
572 pub check_interval_hours: u64,
573 pub notify_only: bool,
574}
575
576impl Default for UpdatesConfig {
577 fn default() -> Self {
578 Self {
579 auto_update: false,
580 check_interval_hours: 6,
581 notify_only: false,
582 }
583 }
584}
585
586impl UpdatesConfig {
587 pub fn from_env() -> Self {
588 let mut cfg = Self::default();
589 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_UPDATE") {
590 cfg.auto_update = v == "1" || v.eq_ignore_ascii_case("true");
591 }
592 if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_INTERVAL_HOURS") {
593 if let Ok(h) = v.parse::<u64>() {
594 cfg.check_interval_hours = h.clamp(1, 168);
595 }
596 }
597 if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_NOTIFY_ONLY") {
598 cfg.notify_only = v == "1" || v.eq_ignore_ascii_case("true");
599 }
600 cfg
601 }
602}
603
604impl AutonomyConfig {
605 pub fn from_env() -> Self {
607 let mut cfg = Self::default();
608 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
609 if v == "false" || v == "0" {
610 cfg.enabled = false;
611 }
612 }
613 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
614 cfg.auto_preload = v != "false" && v != "0";
615 }
616 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
617 cfg.auto_dedup = v != "false" && v != "0";
618 }
619 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
620 cfg.auto_related = v != "false" && v != "0";
621 }
622 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
623 cfg.auto_consolidate = v != "false" && v != "0";
624 }
625 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
626 cfg.silent_preload = v != "false" && v != "0";
627 }
628 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
629 if let Ok(n) = v.parse() {
630 cfg.dedup_threshold = n;
631 }
632 }
633 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
634 if let Ok(n) = v.parse() {
635 cfg.consolidate_every_calls = n;
636 }
637 }
638 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
639 if let Ok(n) = v.parse() {
640 cfg.consolidate_cooldown_secs = n;
641 }
642 }
643 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
644 cfg.cognition_loop_enabled = v != "false" && v != "0";
645 }
646 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
647 if let Ok(n) = v.parse() {
648 cfg.cognition_loop_interval_secs = n;
649 }
650 }
651 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
652 if let Ok(n) = v.parse() {
653 cfg.cognition_loop_max_steps = n;
654 }
655 }
656 cfg
657 }
658
659 pub fn load() -> Self {
661 let file_cfg = Config::load().autonomy;
662 let mut cfg = file_cfg;
663 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
664 if v == "false" || v == "0" {
665 cfg.enabled = false;
666 }
667 }
668 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
669 cfg.auto_preload = v != "false" && v != "0";
670 }
671 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
672 cfg.auto_dedup = v != "false" && v != "0";
673 }
674 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
675 cfg.auto_related = v != "false" && v != "0";
676 }
677 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
678 cfg.silent_preload = v != "false" && v != "0";
679 }
680 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
681 if let Ok(n) = v.parse() {
682 cfg.dedup_threshold = n;
683 }
684 }
685 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
686 cfg.cognition_loop_enabled = v != "false" && v != "0";
687 }
688 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
689 if let Ok(n) = v.parse() {
690 cfg.cognition_loop_interval_secs = n;
691 }
692 }
693 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
694 if let Ok(n) = v.parse() {
695 cfg.cognition_loop_max_steps = n;
696 }
697 }
698 cfg
699 }
700}
701
702#[derive(Debug, Clone, Serialize, Deserialize, Default)]
704#[serde(default)]
705pub struct CloudConfig {
706 pub contribute_enabled: bool,
707 pub last_contribute: Option<String>,
708 pub last_sync: Option<String>,
709 pub last_gain_sync: Option<String>,
710 pub last_model_pull: Option<String>,
711}
712
713#[derive(Debug, Clone, Serialize, Deserialize)]
720#[serde(default)]
721pub struct GainConfig {
722 pub auto_publish: bool,
725 pub leaderboard: bool,
727 pub display_name: Option<String>,
729 pub auto_publish_interval_hours: u64,
731 pub last_auto_publish: Option<String>,
734}
735
736impl Default for GainConfig {
737 fn default() -> Self {
738 Self {
739 auto_publish: false,
740 leaderboard: false,
741 display_name: None,
742 auto_publish_interval_hours: 24,
743 last_auto_publish: None,
744 }
745 }
746}
747
748#[derive(Debug, Clone, Serialize, Deserialize)]
750pub struct AliasEntry {
751 pub command: String,
752 pub alias: String,
753}
754
755#[derive(Debug, Clone, Serialize, Deserialize)]
757#[serde(default)]
758pub struct LoopDetectionConfig {
759 pub normal_threshold: u32,
760 pub reduced_threshold: u32,
761 pub blocked_threshold: u32,
762 pub window_secs: u64,
763 pub search_group_limit: u32,
764 pub tool_total_limits: HashMap<String, u32>,
765}
766
767impl Default for LoopDetectionConfig {
768 fn default() -> Self {
769 let mut tool_total_limits = HashMap::new();
770 tool_total_limits.insert("ctx_read".to_string(), 100);
771 tool_total_limits.insert("ctx_search".to_string(), 80);
772 tool_total_limits.insert("ctx_shell".to_string(), 50);
773 tool_total_limits.insert("ctx_semantic_search".to_string(), 60);
774 Self {
775 normal_threshold: 2,
776 reduced_threshold: 4,
777 blocked_threshold: 0,
778 window_secs: 300,
779 search_group_limit: 10,
780 tool_total_limits,
781 }
782 }
783}
784
785#[derive(Debug, Clone, Default, Serialize, Deserialize)]
794#[serde(default)]
795pub struct EmbeddingConfig {
796 #[serde(default, skip_serializing_if = "Option::is_none")]
797 pub model: Option<String>,
798}
799
800impl Default for Config {
801 fn default() -> Self {
802 Self {
803 ultra_compact: false,
804 tee_mode: TeeMode::default(),
805 output_density: OutputDensity::default(),
806 checkpoint_interval: 15,
807 excluded_commands: Vec::new(),
808 passthrough_urls: Vec::new(),
809 custom_aliases: Vec::new(),
810 slow_command_threshold_ms: 5000,
811 theme: serde_defaults::default_theme(),
812 cloud: CloudConfig::default(),
813 gain: GainConfig::default(),
814 autonomy: AutonomyConfig::default(),
815 providers: ProvidersConfig::default(),
816 proxy: ProxyConfig::default(),
817 proxy_enabled: None,
818 proxy_port: None,
819 proxy_timeout_ms: None,
820 buddy_enabled: serde_defaults::default_buddy_enabled(),
821 enable_wakeup_ctx: true,
822 redirect_exclude: Vec::new(),
823 disabled_tools: Vec::new(),
824 default_tool_categories: Vec::new(),
825 no_degrade: false,
826 profile: None,
827 tool_profile: None,
828 tools_enabled: Vec::new(),
829 loop_detection: LoopDetectionConfig::default(),
830 rules_scope: None,
831 extra_ignore_patterns: Vec::new(),
832 terse_agent: TerseAgent::default(),
833 compression_level: CompressionLevel::default(),
834 archive: ArchiveConfig::default(),
835 memory: MemoryPolicy::default(),
836 allow_paths: Vec::new(),
837 extra_roots: Vec::new(),
838 content_defined_chunking: false,
839 minimal_overhead: true,
840 symbol_map_auto: true,
841 journal_enabled: true,
842 auto_capture: true,
843 search: crate::core::hybrid_search::HybridConfig::default(),
844 llm: crate::core::llm_enhance::LlmConfig::default(),
845 embedding: EmbeddingConfig::default(),
846 shell_hook_disabled: false,
847 shadow_mode: false,
848 shell_activation: ShellActivation::default(),
849 update_check_disabled: false,
850 updates: UpdatesConfig::default(),
851 graph_index_max_files: serde_defaults::default_graph_index_max_files(),
852 bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
853 memory_profile: MemoryProfile::default(),
854 memory_cleanup: MemoryCleanup::default(),
855 max_ram_percent: serde_defaults::default_max_ram_percent(),
856 max_disk_mb: 0,
857 max_staleness_days: 0,
858 savings_footer: SavingsFooter::default(),
859 project_root: None,
860 lsp: std::collections::HashMap::new(),
861 ide_paths: HashMap::new(),
862 model_context_windows: HashMap::new(),
863 response_verbosity: ResponseVerbosity::default(),
864 bypass_hints: None,
865 cache_policy: None,
866 boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
867 secret_detection: SecretDetectionConfig::default(),
868 allow_auto_reroot: false,
869 path_jail: None,
870 sandbox_level: 0,
871 reference_results: false,
872 agent_token_budget: 0,
873 shell_allowlist: default_shell_allowlist(),
874 shell_strict_mode: false,
875 setup: SetupConfig::default(),
876 }
877 }
878}
879
880pub(crate) fn default_shell_allowlist() -> Vec<String> {
881 [
882 "git",
884 "gh",
885 "svn",
886 "hg",
887 "cargo",
889 "npm",
890 "npx",
891 "yarn",
892 "pnpm",
893 "bun",
894 "bunx",
895 "make",
896 "cmake",
897 "pip",
898 "pip3",
899 "poetry",
900 "uv",
901 "go",
902 "mvn",
903 "gradle",
904 "mix",
905 "dotnet",
906 "swift",
907 "zig",
908 "rustup",
909 "rustc",
910 "deno",
911 "bazel",
912 "pipenv",
914 "conda",
915 "mamba",
916 "brew",
917 "apt",
918 "apt-get",
919 "apk",
920 "nix",
921 "ls",
923 "cat",
924 "head",
925 "tail",
926 "wc",
927 "sort",
928 "uniq",
929 "tr",
930 "cut",
931 "grep",
932 "rg",
933 "find",
934 "fd",
935 "ag",
936 "ack",
937 "sed",
938 "awk",
939 "echo",
940 "printf",
941 "true",
942 "false",
943 "test",
944 "expr",
945 "cd",
946 "pwd",
947 "basename",
948 "dirname",
949 "realpath",
950 "readlink",
951 "cp",
952 "mv",
953 "mkdir",
954 "rm",
955 "rmdir",
956 "touch",
957 "ln",
958 "chmod",
959 "chown",
960 "diff",
961 "patch",
962 "tar",
963 "zip",
964 "unzip",
965 "gzip",
966 "gunzip",
967 "zstd",
968 "curl",
969 "wget",
970 "tree",
971 "du",
972 "df",
973 "ps",
974 "lsof",
975 "watch",
976 "tee",
977 "less",
978 "more",
979 "id",
980 "whoami",
981 "uname",
982 "hostname",
983 "node",
987 "python",
988 "python3",
989 "ruby",
990 "perl",
991 "java",
992 "javac",
993 "tsc",
994 "eslint",
995 "prettier",
996 "black",
997 "ruff",
998 "clippy",
999 "jq",
1000 "yq",
1001 "which",
1002 "type",
1003 "file",
1004 "stat",
1005 "date",
1006 "sleep",
1007 "timeout",
1008 "nice",
1009 "ionice",
1010 "pytest",
1012 "py.test",
1013 "jest",
1014 "vitest",
1015 "mocha",
1016 "cypress",
1017 "playwright",
1018 "puppeteer",
1019 "pre-commit",
1021 "husky",
1022 "lint-staged",
1023 "lefthook",
1024 "overcommit",
1025 "commitlint",
1026 "mypy",
1028 "pyright",
1029 "pylint",
1030 "flake8",
1031 "bandit",
1032 "isort",
1033 "autopep8",
1034 "yapf",
1035 "golangci-lint",
1036 "shellcheck",
1037 "markdownlint",
1038 "stylelint",
1039 "webpack",
1041 "vite",
1042 "esbuild",
1043 "rollup",
1044 "turbo",
1045 "nx",
1046 "lerna",
1047 "next",
1048 "nuxt",
1049 "bundle",
1051 "bundler",
1052 "rake",
1053 "rails",
1054 "rspec",
1055 "rubocop",
1056 "php",
1058 "composer",
1059 "phpunit",
1060 "artisan",
1061 "flutter",
1063 "dart",
1064 "xcodebuild",
1065 "xcrun",
1066 "pod",
1067 "fastlane",
1068 "terraform",
1070 "ansible",
1071 "kubectl",
1072 "helm",
1073 "az",
1074 "aws",
1075 "gcloud",
1076 "firebase",
1077 "heroku",
1078 "vercel",
1079 "netlify",
1080 "fly",
1081 "wrangler",
1082 "pulumi",
1083 "psql",
1085 "mysql",
1086 "sqlite3",
1087 "mongosh",
1088 "redis-cli",
1089 "pg_dump",
1090 "pg_restore",
1091 "mysqldump",
1092 "scala",
1094 "sbt",
1095 "kotlin",
1096 "kotlinc",
1097 "elixir",
1099 "iex",
1100 "lean-ctx",
1102 ]
1103 .iter()
1104 .map(|s| (*s).to_string())
1105 .collect()
1106}
1107
1108impl Config {
1109 pub fn rules_scope_effective(&self) -> RulesScope {
1111 let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
1112 .ok()
1113 .or_else(|| self.rules_scope.clone())
1114 .unwrap_or_default();
1115 match raw.trim().to_lowercase().as_str() {
1116 "global" => RulesScope::Global,
1117 "project" => RulesScope::Project,
1118 _ => RulesScope::Both,
1119 }
1120 }
1121
1122 fn parse_disabled_tools_env(val: &str) -> Vec<String> {
1123 val.split(',')
1124 .map(|s| s.trim().to_string())
1125 .filter(|s| !s.is_empty())
1126 .collect()
1127 }
1128
1129 pub fn disabled_tools_effective(&self) -> Vec<String> {
1131 if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
1132 Self::parse_disabled_tools_env(&val)
1133 } else {
1134 self.disabled_tools.clone()
1135 }
1136 }
1137
1138 pub fn minimal_overhead_effective(&self) -> bool {
1140 std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
1141 }
1142
1143 pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
1151 if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
1152 match raw.trim().to_lowercase().as_str() {
1153 "minimal" => return true,
1154 "full" => return self.minimal_overhead_effective(),
1155 _ => {}
1156 }
1157 }
1158
1159 if self.minimal_overhead_effective() {
1160 return true;
1161 }
1162
1163 let client_lower = client_name.trim().to_lowercase();
1164 if !client_lower.is_empty() {
1165 if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
1166 for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
1167 if !needle.is_empty() && client_lower.contains(&needle) {
1168 return true;
1169 }
1170 }
1171 } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
1172 return true;
1173 }
1174 }
1175
1176 let model = std::env::var("LEAN_CTX_MODEL")
1177 .or_else(|_| std::env::var("LCTX_MODEL"))
1178 .unwrap_or_default();
1179 let model = model.trim().to_lowercase();
1180 if !model.is_empty() {
1181 let m = model.replace(['_', ' '], "-");
1182 if m.contains("minimax")
1183 || m.contains("mini-max")
1184 || m.contains("m2.7")
1185 || m.contains("m2-7")
1186 {
1187 return true;
1188 }
1189 }
1190
1191 false
1192 }
1193
1194 pub fn shell_hook_disabled_effective(&self) -> bool {
1196 std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
1197 }
1198
1199 pub fn shell_activation_effective(&self) -> ShellActivation {
1201 ShellActivation::effective(self)
1202 }
1203
1204 pub fn update_check_disabled_effective(&self) -> bool {
1206 std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
1207 }
1208
1209 pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
1210 let mut policy = self.memory.clone();
1211 policy.apply_env_overrides();
1212
1213 let budget = self.max_disk_mb_effective();
1216 if budget > 0 {
1217 let scale_factor = (budget as f64 / 500.0).clamp(0.5, 10.0);
1218 let default_policy = MemoryPolicy::default();
1219 if policy.knowledge.max_facts == default_policy.knowledge.max_facts {
1220 policy.knowledge.max_facts = (200.0 * scale_factor) as usize;
1221 }
1222 if policy.knowledge.max_patterns == default_policy.knowledge.max_patterns {
1223 policy.knowledge.max_patterns = (50.0 * scale_factor) as usize;
1224 }
1225 if policy.episodic.max_episodes == default_policy.episodic.max_episodes {
1226 policy.episodic.max_episodes = (500.0 * scale_factor) as usize;
1227 }
1228 if policy.procedural.max_procedures == default_policy.procedural.max_procedures {
1229 policy.procedural.max_procedures = (100.0 * scale_factor) as usize;
1230 }
1231 }
1232
1233 policy.validate()?;
1234 Ok(policy)
1235 }
1236
1237 pub fn default_tool_categories_effective(&self) -> Vec<String> {
1240 if let Ok(val) = std::env::var("LCTX_DEFAULT_CATEGORIES") {
1241 return val
1242 .split(',')
1243 .map(|s| s.trim().to_lowercase())
1244 .filter(|s| !s.is_empty())
1245 .collect();
1246 }
1247 if !self.default_tool_categories.is_empty() {
1248 return self
1249 .default_tool_categories
1250 .iter()
1251 .map(|s| s.to_lowercase())
1252 .collect();
1253 }
1254 vec!["core".to_string(), "session".to_string()]
1255 }
1256
1257 pub fn tool_profile_effective(&self) -> super::tool_profiles::ToolProfile {
1260 super::tool_profiles::ToolProfile::from_config(self)
1261 }
1262
1263 pub fn no_degrade_effective(&self) -> bool {
1266 if let Ok(val) = std::env::var("LCTX_NO_DEGRADE") {
1267 return val == "1" || val.eq_ignore_ascii_case("true");
1268 }
1269 self.no_degrade
1270 }
1271
1272 pub fn max_disk_mb_effective(&self) -> u64 {
1274 std::env::var("LEAN_CTX_MAX_DISK_MB")
1275 .ok()
1276 .and_then(|v| v.parse().ok())
1277 .unwrap_or(self.max_disk_mb)
1278 }
1279
1280 pub fn max_staleness_days_effective(&self) -> u32 {
1282 std::env::var("LEAN_CTX_MAX_STALENESS_DAYS")
1283 .ok()
1284 .and_then(|v| v.parse().ok())
1285 .unwrap_or(self.max_staleness_days)
1286 }
1287
1288 pub fn archive_max_disk_mb_effective(&self) -> u64 {
1291 let budget = self.max_disk_mb_effective();
1292 if budget > 0 && self.archive.max_disk_mb == ArchiveConfig::default().max_disk_mb {
1293 budget * 25 / 100
1294 } else {
1295 self.archive.max_disk_mb
1296 }
1297 }
1298
1299 pub fn archive_max_age_hours_effective(&self) -> u64 {
1302 let staleness = self.max_staleness_days_effective();
1303 if staleness > 0 && self.archive.max_age_hours == ArchiveConfig::default().max_age_hours {
1304 staleness as u64 * 24
1305 } else {
1306 self.archive.max_age_hours
1307 }
1308 }
1309
1310 pub fn bm25_max_cache_mb_effective(&self) -> u64 {
1318 if self.bm25_max_cache_mb != serde_defaults::default_bm25_max_cache_mb() {
1320 return self.bm25_max_cache_mb;
1321 }
1322 let budget = self.max_disk_mb_effective();
1324 if budget > 0 {
1325 return budget * 10 / 100;
1326 }
1327 DEFAULT_BM25_PERSIST_MB
1329 }
1330}
1331
1332#[cfg(test)]
1333mod disabled_tools_tests {
1334 use super::*;
1335
1336 #[test]
1337 fn config_field_default_is_empty() {
1338 let cfg = Config::default();
1339 assert!(cfg.disabled_tools.is_empty());
1340 }
1341
1342 #[test]
1343 fn effective_returns_config_field_when_no_env_var() {
1344 if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
1346 return;
1347 }
1348 let cfg = Config {
1349 disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
1350 ..Default::default()
1351 };
1352 assert_eq!(
1353 cfg.disabled_tools_effective(),
1354 vec!["ctx_graph", "ctx_agent"]
1355 );
1356 }
1357
1358 #[test]
1359 fn parse_env_basic() {
1360 let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
1361 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
1362 }
1363
1364 #[test]
1365 fn parse_env_trims_whitespace_and_skips_empty() {
1366 let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
1367 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
1368 }
1369
1370 #[test]
1371 fn parse_env_single_entry() {
1372 let result = Config::parse_disabled_tools_env("ctx_graph");
1373 assert_eq!(result, vec!["ctx_graph"]);
1374 }
1375
1376 #[test]
1377 fn parse_env_empty_string_returns_empty() {
1378 let result = Config::parse_disabled_tools_env("");
1379 assert!(result.is_empty());
1380 }
1381
1382 #[test]
1383 fn disabled_tools_deserialization_defaults_to_empty() {
1384 let cfg: Config = toml::from_str("").unwrap();
1385 assert!(cfg.disabled_tools.is_empty());
1386 }
1387
1388 #[test]
1389 fn disabled_tools_deserialization_from_toml() {
1390 let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
1391 assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
1392 }
1393}
1394
1395#[cfg(test)]
1396mod default_tool_categories_tests {
1397 use super::*;
1398
1399 #[test]
1402 fn default_returns_core_and_session() {
1403 if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1404 return;
1405 }
1406 let cfg = Config::default();
1407 assert_eq!(
1408 cfg.default_tool_categories_effective(),
1409 vec!["core", "session"]
1410 );
1411 }
1412
1413 #[test]
1414 fn default_struct_field_is_empty_vec() {
1415 let cfg = Config::default();
1416 assert!(cfg.default_tool_categories.is_empty());
1417 }
1418
1419 #[test]
1422 fn config_field_overrides_default() {
1423 if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1424 return;
1425 }
1426 let cfg = Config {
1427 default_tool_categories: vec![
1428 "core".to_string(),
1429 "arch".to_string(),
1430 "memory".to_string(),
1431 ],
1432 ..Default::default()
1433 };
1434 assert_eq!(
1435 cfg.default_tool_categories_effective(),
1436 vec!["core", "arch", "memory"]
1437 );
1438 }
1439
1440 #[test]
1441 fn single_category_in_config() {
1442 if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1443 return;
1444 }
1445 let cfg = Config {
1446 default_tool_categories: vec!["debug".to_string()],
1447 ..Default::default()
1448 };
1449 assert_eq!(cfg.default_tool_categories_effective(), vec!["debug"]);
1450 }
1451
1452 #[test]
1453 fn all_six_categories_in_config() {
1454 if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1455 return;
1456 }
1457 let cfg = Config {
1458 default_tool_categories: vec![
1459 "core".to_string(),
1460 "arch".to_string(),
1461 "debug".to_string(),
1462 "memory".to_string(),
1463 "metrics".to_string(),
1464 "session".to_string(),
1465 ],
1466 ..Default::default()
1467 };
1468 let effective = cfg.default_tool_categories_effective();
1469 assert_eq!(effective.len(), 6);
1470 assert!(effective.contains(&"core".to_string()));
1471 assert!(effective.contains(&"metrics".to_string()));
1472 }
1473
1474 #[test]
1477 fn deserialization_defaults_to_empty() {
1478 let cfg: Config = toml::from_str("").unwrap();
1479 assert!(cfg.default_tool_categories.is_empty());
1480 }
1481
1482 #[test]
1483 fn deserialization_from_toml() {
1484 let cfg: Config =
1485 toml::from_str(r#"default_tool_categories = ["core", "arch", "debug"]"#).unwrap();
1486 assert_eq!(cfg.default_tool_categories, vec!["core", "arch", "debug"]);
1487 }
1488
1489 #[test]
1490 fn deserialization_empty_array() {
1491 let cfg: Config = toml::from_str(r"default_tool_categories = []").unwrap();
1492 assert!(cfg.default_tool_categories.is_empty());
1493 }
1494
1495 #[test]
1496 fn deserialization_single_entry() {
1497 let cfg: Config = toml::from_str(r#"default_tool_categories = ["memory"]"#).unwrap();
1498 assert_eq!(cfg.default_tool_categories, vec!["memory"]);
1499 }
1500
1501 #[test]
1504 fn effective_normalizes_config_to_lowercase() {
1505 if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1506 return;
1507 }
1508 let cfg = Config {
1509 default_tool_categories: vec!["ARCH".to_string(), "Debug".to_string()],
1510 ..Default::default()
1511 };
1512 let effective = cfg.default_tool_categories_effective();
1513 assert_eq!(effective, vec!["arch", "debug"]);
1514 }
1515}
1516
1517#[cfg(test)]
1518mod no_degrade_tests {
1519 use super::*;
1520
1521 #[test]
1524 fn default_is_false() {
1525 let cfg = Config::default();
1526 assert!(!cfg.no_degrade);
1527 }
1528
1529 #[test]
1530 fn effective_false_when_unset() {
1531 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1532 return;
1533 }
1534 let cfg = Config::default();
1535 assert!(!cfg.no_degrade_effective());
1536 }
1537
1538 #[test]
1541 fn config_field_true_respected_when_no_env() {
1542 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1543 return;
1544 }
1545 let cfg = Config {
1546 no_degrade: true,
1547 ..Default::default()
1548 };
1549 assert!(cfg.no_degrade_effective());
1550 }
1551
1552 #[test]
1553 fn config_field_false_respected_when_no_env() {
1554 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1555 return;
1556 }
1557 let cfg = Config {
1558 no_degrade: false,
1559 ..Default::default()
1560 };
1561 assert!(!cfg.no_degrade_effective());
1562 }
1563
1564 #[test]
1567 fn deserialization_true() {
1568 let cfg: Config = toml::from_str("no_degrade = true").unwrap();
1569 assert!(cfg.no_degrade);
1570 }
1571
1572 #[test]
1573 fn deserialization_false() {
1574 let cfg: Config = toml::from_str("no_degrade = false").unwrap();
1575 assert!(!cfg.no_degrade);
1576 }
1577
1578 #[test]
1579 fn deserialization_absent_defaults_false() {
1580 let cfg: Config = toml::from_str("").unwrap();
1581 assert!(!cfg.no_degrade);
1582 }
1583
1584 #[test]
1587 fn no_degrade_independent_of_disabled_tools() {
1588 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1589 return;
1590 }
1591 let cfg = Config {
1592 no_degrade: true,
1593 disabled_tools: vec!["ctx_graph".to_string()],
1594 ..Default::default()
1595 };
1596 assert!(cfg.no_degrade_effective());
1597 assert!(!cfg.disabled_tools.is_empty());
1598 }
1599
1600 #[test]
1601 fn no_degrade_independent_of_tool_categories() {
1602 if std::env::var("LCTX_NO_DEGRADE").is_ok()
1603 || std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok()
1604 {
1605 return;
1606 }
1607 let cfg = Config {
1608 no_degrade: true,
1609 default_tool_categories: vec!["core".to_string(), "arch".to_string()],
1610 ..Default::default()
1611 };
1612 assert!(cfg.no_degrade_effective());
1613 assert_eq!(
1614 cfg.default_tool_categories_effective(),
1615 vec!["core", "arch"]
1616 );
1617 }
1618}
1619
1620#[cfg(test)]
1621mod rules_scope_tests {
1622 use super::*;
1623
1624 #[test]
1625 fn default_is_both() {
1626 let cfg = Config::default();
1627 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1628 }
1629
1630 #[test]
1631 fn config_global() {
1632 let cfg = Config {
1633 rules_scope: Some("global".to_string()),
1634 ..Default::default()
1635 };
1636 assert_eq!(cfg.rules_scope_effective(), RulesScope::Global);
1637 }
1638
1639 #[test]
1640 fn config_project() {
1641 let cfg = Config {
1642 rules_scope: Some("project".to_string()),
1643 ..Default::default()
1644 };
1645 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1646 }
1647
1648 #[test]
1649 fn unknown_value_falls_back_to_both() {
1650 let cfg = Config {
1651 rules_scope: Some("nonsense".to_string()),
1652 ..Default::default()
1653 };
1654 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1655 }
1656
1657 #[test]
1658 fn deserialization_none_by_default() {
1659 let cfg: Config = toml::from_str("").unwrap();
1660 assert!(cfg.rules_scope.is_none());
1661 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1662 }
1663
1664 #[test]
1665 fn deserialization_from_toml() {
1666 let cfg: Config = toml::from_str(r#"rules_scope = "project""#).unwrap();
1667 assert_eq!(cfg.rules_scope.as_deref(), Some("project"));
1668 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1669 }
1670}
1671
1672#[cfg(test)]
1673mod loop_detection_config_tests {
1674 use super::*;
1675
1676 #[test]
1677 fn defaults_are_reasonable() {
1678 let cfg = LoopDetectionConfig::default();
1679 assert_eq!(cfg.normal_threshold, 2);
1680 assert_eq!(cfg.reduced_threshold, 4);
1681 assert_eq!(cfg.blocked_threshold, 0);
1683 assert_eq!(cfg.window_secs, 300);
1684 assert_eq!(cfg.search_group_limit, 10);
1685 }
1686
1687 #[test]
1688 fn deserialization_defaults_when_missing() {
1689 let cfg: Config = toml::from_str("").unwrap();
1690 assert_eq!(cfg.loop_detection.blocked_threshold, 0);
1692 assert_eq!(cfg.loop_detection.search_group_limit, 10);
1693 }
1694
1695 #[test]
1696 fn deserialization_from_toml() {
1697 let cfg: Config = toml::from_str(
1698 r"
1699 [loop_detection]
1700 normal_threshold = 1
1701 reduced_threshold = 3
1702 blocked_threshold = 5
1703 window_secs = 120
1704 search_group_limit = 8
1705 ",
1706 )
1707 .unwrap();
1708 assert_eq!(cfg.loop_detection.normal_threshold, 1);
1709 assert_eq!(cfg.loop_detection.reduced_threshold, 3);
1710 assert_eq!(cfg.loop_detection.blocked_threshold, 5);
1711 assert_eq!(cfg.loop_detection.window_secs, 120);
1712 assert_eq!(cfg.loop_detection.search_group_limit, 8);
1713 }
1714
1715 #[test]
1716 fn partial_override_keeps_defaults() {
1717 let cfg: Config = toml::from_str(
1718 r"
1719 [loop_detection]
1720 blocked_threshold = 10
1721 ",
1722 )
1723 .unwrap();
1724 assert_eq!(cfg.loop_detection.blocked_threshold, 10);
1725 assert_eq!(cfg.loop_detection.normal_threshold, 2);
1726 assert_eq!(cfg.loop_detection.search_group_limit, 10);
1727 }
1728}
1729
1730impl Config {
1731 pub fn path() -> Option<PathBuf> {
1733 crate::core::data_dir::lean_ctx_data_dir()
1734 .ok()
1735 .map(|d| d.join("config.toml"))
1736 }
1737
1738 pub fn local_path(project_root: &str) -> PathBuf {
1740 PathBuf::from(project_root).join(".lean-ctx.toml")
1741 }
1742
1743 fn find_project_root() -> Option<String> {
1744 static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
1745 ROOT_CACHE
1746 .get_or_init(Self::find_project_root_inner)
1747 .clone()
1748 }
1749
1750 fn find_project_root_inner() -> Option<String> {
1751 if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
1752 if !env_root.is_empty() {
1753 return Some(env_root);
1754 }
1755 }
1756
1757 let cwd = std::env::current_dir().ok();
1758
1759 if let Some(root) =
1760 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
1761 {
1762 let root_path = std::path::Path::new(&root);
1763 let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
1764 let has_marker = root_path.join(".git").exists()
1765 || root_path.join("Cargo.toml").exists()
1766 || root_path.join("package.json").exists()
1767 || root_path.join("go.mod").exists()
1768 || root_path.join("pyproject.toml").exists()
1769 || root_path.join(".lean-ctx.toml").exists();
1770
1771 if cwd_is_under_root || has_marker {
1772 return Some(root);
1773 }
1774 }
1775
1776 if let Some(ref cwd) = cwd {
1777 let git_root = std::process::Command::new("git")
1778 .args(["rev-parse", "--show-toplevel"])
1779 .current_dir(cwd)
1780 .stdout(std::process::Stdio::piped())
1781 .stderr(std::process::Stdio::null())
1782 .output()
1783 .ok()
1784 .and_then(|o| {
1785 if o.status.success() {
1786 String::from_utf8(o.stdout)
1787 .ok()
1788 .map(|s| s.trim().to_string())
1789 } else {
1790 None
1791 }
1792 });
1793 if let Some(root) = git_root {
1794 return Some(root);
1795 }
1796 if !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
1797 return Some(cwd.to_string_lossy().to_string());
1798 }
1799 }
1800 None
1801 }
1802
1803 pub fn load() -> Self {
1805 static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
1806
1807 let Some(path) = Self::path() else {
1808 return Self::default();
1809 };
1810
1811 let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
1812
1813 let mtime = std::fs::metadata(&path)
1814 .and_then(|m| m.modified())
1815 .unwrap_or(SystemTime::UNIX_EPOCH);
1816
1817 let local_mtime = local_path
1818 .as_ref()
1819 .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
1820
1821 if let Ok(guard) = CACHE.lock() {
1822 if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
1823 if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
1824 return cfg.clone();
1825 }
1826 }
1827 }
1828
1829 let mut cfg: Config = match std::fs::read_to_string(&path) {
1830 Ok(content) => match toml::from_str(&content) {
1831 Ok(c) => c,
1832 Err(e) => {
1833 tracing::warn!("config parse error in {}: {e}", path.display());
1834 eprintln!(
1835 "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n \
1836 Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
1837 path.display()
1838 );
1839 Self::default()
1840 }
1841 },
1842 Err(_) => Self::default(),
1843 };
1844
1845 if let Some(ref lp) = local_path {
1846 if let Ok(local_content) = std::fs::read_to_string(lp) {
1847 cfg.merge_local(&local_content);
1848 }
1849 }
1850
1851 if let Ok(mut guard) = CACHE.lock() {
1852 *guard = Some((cfg.clone(), mtime, local_mtime));
1853 }
1854
1855 cfg
1856 }
1857
1858 fn merge_local(&mut self, local_toml: &str) {
1859 let local: Config = match toml::from_str(local_toml) {
1860 Ok(c) => c,
1861 Err(e) => {
1862 tracing::warn!("local config parse error: {e}");
1863 eprintln!(
1864 "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n \
1865 Local overrides skipped.\x1b[0m"
1866 );
1867 return;
1868 }
1869 };
1870 if local.ultra_compact {
1871 self.ultra_compact = true;
1872 }
1873 if local.tee_mode != TeeMode::default() {
1874 self.tee_mode = local.tee_mode;
1875 }
1876 if local.output_density != OutputDensity::default() {
1877 self.output_density = local.output_density;
1878 }
1879 if local.checkpoint_interval != 15 {
1880 self.checkpoint_interval = local.checkpoint_interval;
1881 }
1882 if !local.excluded_commands.is_empty() {
1883 self.excluded_commands.extend(local.excluded_commands);
1884 }
1885 if !local.passthrough_urls.is_empty() {
1886 self.passthrough_urls.extend(local.passthrough_urls);
1887 }
1888 if !local.custom_aliases.is_empty() {
1889 self.custom_aliases.extend(local.custom_aliases);
1890 }
1891 if local.slow_command_threshold_ms != 5000 {
1892 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
1893 }
1894 if local.theme != "default" {
1895 self.theme = local.theme;
1896 }
1897 if !local.buddy_enabled {
1898 self.buddy_enabled = false;
1899 }
1900 if !local.enable_wakeup_ctx {
1901 self.enable_wakeup_ctx = false;
1902 }
1903 if !local.redirect_exclude.is_empty() {
1904 self.redirect_exclude.extend(local.redirect_exclude);
1905 }
1906 if !local.disabled_tools.is_empty() {
1907 self.disabled_tools.extend(local.disabled_tools);
1908 }
1909 if !local.extra_ignore_patterns.is_empty() {
1910 self.extra_ignore_patterns
1911 .extend(local.extra_ignore_patterns);
1912 }
1913 if local.rules_scope.is_some() {
1914 self.rules_scope = local.rules_scope;
1915 }
1916 if local.proxy.anthropic_upstream.is_some() {
1917 self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
1918 }
1919 if local.proxy.openai_upstream.is_some() {
1920 self.proxy.openai_upstream = local.proxy.openai_upstream;
1921 }
1922 if local.proxy.gemini_upstream.is_some() {
1923 self.proxy.gemini_upstream = local.proxy.gemini_upstream;
1924 }
1925 if !local.autonomy.enabled {
1926 self.autonomy.enabled = false;
1927 }
1928 if !local.autonomy.auto_preload {
1929 self.autonomy.auto_preload = false;
1930 }
1931 if !local.autonomy.auto_dedup {
1932 self.autonomy.auto_dedup = false;
1933 }
1934 if !local.autonomy.auto_related {
1935 self.autonomy.auto_related = false;
1936 }
1937 if !local.autonomy.auto_consolidate {
1938 self.autonomy.auto_consolidate = false;
1939 }
1940 if local.autonomy.silent_preload {
1941 self.autonomy.silent_preload = true;
1942 }
1943 if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1944 self.autonomy.silent_preload = false;
1945 }
1946 if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1947 self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1948 }
1949 if local.autonomy.consolidate_every_calls
1950 != AutonomyConfig::default().consolidate_every_calls
1951 {
1952 self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1953 }
1954 if local.autonomy.consolidate_cooldown_secs
1955 != AutonomyConfig::default().consolidate_cooldown_secs
1956 {
1957 self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1958 }
1959 if !local.autonomy.cognition_loop_enabled {
1960 self.autonomy.cognition_loop_enabled = false;
1961 }
1962 if local.autonomy.cognition_loop_interval_secs
1963 != AutonomyConfig::default().cognition_loop_interval_secs
1964 {
1965 self.autonomy.cognition_loop_interval_secs =
1966 local.autonomy.cognition_loop_interval_secs;
1967 }
1968 if local.autonomy.cognition_loop_max_steps
1969 != AutonomyConfig::default().cognition_loop_max_steps
1970 {
1971 self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
1972 }
1973 if local_toml.contains("compression_level") {
1974 self.compression_level = local.compression_level;
1975 }
1976 if local_toml.contains("terse_agent") {
1977 self.terse_agent = local.terse_agent;
1978 }
1979 if !local.archive.enabled {
1980 self.archive.enabled = false;
1981 }
1982 if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1983 self.archive.threshold_chars = local.archive.threshold_chars;
1984 }
1985 if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1986 self.archive.max_age_hours = local.archive.max_age_hours;
1987 }
1988 if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1989 self.archive.max_disk_mb = local.archive.max_disk_mb;
1990 }
1991 if !local.archive.ephemeral {
1992 self.archive.ephemeral = false;
1993 }
1994 let mem_def = MemoryPolicy::default();
1995 if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1996 self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1997 }
1998 if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1999 self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
2000 }
2001 if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
2002 self.memory.knowledge.max_history = local.memory.knowledge.max_history;
2003 }
2004 if local.memory.knowledge.contradiction_threshold
2005 != mem_def.knowledge.contradiction_threshold
2006 {
2007 self.memory.knowledge.contradiction_threshold =
2008 local.memory.knowledge.contradiction_threshold;
2009 }
2010
2011 if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
2012 self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
2013 }
2014 if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
2015 {
2016 self.memory.episodic.max_actions_per_episode =
2017 local.memory.episodic.max_actions_per_episode;
2018 }
2019 if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
2020 self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
2021 }
2022
2023 if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
2024 self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
2025 }
2026 if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
2027 self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
2028 }
2029 if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
2030 self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
2031 }
2032 if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
2033 self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
2034 }
2035
2036 if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
2037 self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
2038 }
2039 if local.memory.lifecycle.low_confidence_threshold
2040 != mem_def.lifecycle.low_confidence_threshold
2041 {
2042 self.memory.lifecycle.low_confidence_threshold =
2043 local.memory.lifecycle.low_confidence_threshold;
2044 }
2045 if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
2046 self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
2047 }
2048 if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
2049 self.memory.lifecycle.similarity_threshold =
2050 local.memory.lifecycle.similarity_threshold;
2051 }
2052
2053 if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
2054 self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
2055 }
2056 if !local.allow_paths.is_empty() {
2057 self.allow_paths.extend(local.allow_paths);
2058 }
2059 if !local.extra_roots.is_empty() {
2060 self.extra_roots.extend(local.extra_roots);
2061 }
2062 if local.minimal_overhead {
2063 self.minimal_overhead = true;
2064 }
2065 if local.shell_hook_disabled {
2066 self.shell_hook_disabled = true;
2067 }
2068 if local.shell_activation != ShellActivation::default() {
2069 self.shell_activation = local.shell_activation.clone();
2070 }
2071 if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
2072 self.bm25_max_cache_mb = local.bm25_max_cache_mb;
2073 }
2074 if local.memory_profile != MemoryProfile::default() {
2075 self.memory_profile = local.memory_profile;
2076 }
2077 if local.memory_cleanup != MemoryCleanup::default() {
2078 self.memory_cleanup = local.memory_cleanup;
2079 }
2080 if !local.shell_allowlist.is_empty() {
2081 self.shell_allowlist = local.shell_allowlist;
2082 }
2083 if !local.default_tool_categories.is_empty() {
2084 self.default_tool_categories = local.default_tool_categories;
2085 }
2086 if local.tool_profile.is_some() {
2087 self.tool_profile = local.tool_profile;
2088 }
2089 if !local.tools_enabled.is_empty() {
2090 self.tools_enabled = local.tools_enabled;
2091 }
2092 if local.no_degrade {
2093 self.no_degrade = true;
2094 }
2095 if local.profile.is_some() {
2096 self.profile = local.profile;
2097 }
2098 if local.proxy_timeout_ms.is_some() {
2099 self.proxy_timeout_ms = local.proxy_timeout_ms;
2100 }
2101 }
2102
2103 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
2109 let path = Self::path().ok_or_else(|| {
2110 super::error::LeanCtxError::Config("cannot determine home directory".into())
2111 })?;
2112 if let Some(parent) = path.parent() {
2113 std::fs::create_dir_all(parent)?;
2114 }
2115 let content = toml::to_string_pretty(self)
2116 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
2117 let baseline = toml::from_str::<Self>("").unwrap_or_else(|_| Self::default());
2122 let defaults = toml::to_string_pretty(&baseline)
2123 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
2124 crate::config_io::write_toml_preserving_minimal(&path, &content, &defaults)
2125 .map_err(super::error::LeanCtxError::Config)?;
2126 Ok(())
2127 }
2128
2129 pub fn show(&self) -> String {
2131 let global_path = Self::path().map_or_else(
2132 || "~/.lean-ctx/config.toml".to_string(),
2133 |p| p.to_string_lossy().to_string(),
2134 );
2135 let content = toml::to_string_pretty(self).unwrap_or_default();
2136 let mut out = format!("Global config: {global_path}\n\n{content}");
2137
2138 if let Some(root) = Self::find_project_root() {
2139 let local = Self::local_path(&root);
2140 if local.exists() {
2141 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
2142 } else {
2143 out.push_str(&format!(
2144 "\n\nLocal config: not found (create {} to override per-project)\n",
2145 local.display()
2146 ));
2147 }
2148 }
2149 out
2150 }
2151}
2152
2153#[cfg(test)]
2154mod extra_roots_tests {
2155 use super::*;
2156
2157 #[test]
2158 fn default_is_empty() {
2159 let cfg = Config::default();
2160 assert!(cfg.extra_roots.is_empty());
2161 }
2162
2163 #[test]
2164 fn deserialization_from_toml() {
2165 let cfg: Config = toml::from_str(r#"extra_roots = ["/data/store", "/test/env"]"#).unwrap();
2166 assert_eq!(cfg.extra_roots, vec!["/data/store", "/test/env"]);
2167 }
2168
2169 #[test]
2170 fn merge_extends() {
2171 let mut base = Config {
2172 extra_roots: vec!["/base".to_string()],
2173 ..Config::default()
2174 };
2175 base.merge_local(r#"extra_roots = ["/local"]"#);
2176 assert_eq!(base.extra_roots, vec!["/base", "/local"]);
2177 }
2178}
2179
2180#[cfg(test)]
2181mod compression_level_tests {
2182 use super::*;
2183
2184 #[test]
2185 fn default_is_lite() {
2186 assert_eq!(CompressionLevel::default(), CompressionLevel::Lite);
2189 }
2190
2191 #[test]
2192 fn to_components_off() {
2193 let (ta, od, crp, tm) = CompressionLevel::Off.to_components();
2194 assert_eq!(ta, TerseAgent::Off);
2195 assert_eq!(od, OutputDensity::Normal);
2196 assert_eq!(crp, "off");
2197 assert!(!tm);
2198 }
2199
2200 #[test]
2201 fn to_components_lite() {
2202 let (ta, od, crp, tm) = CompressionLevel::Lite.to_components();
2203 assert_eq!(ta, TerseAgent::Lite);
2204 assert_eq!(od, OutputDensity::Terse);
2205 assert_eq!(crp, "off");
2206 assert!(tm);
2207 }
2208
2209 #[test]
2210 fn to_components_standard() {
2211 let (ta, od, crp, tm) = CompressionLevel::Standard.to_components();
2212 assert_eq!(ta, TerseAgent::Full);
2213 assert_eq!(od, OutputDensity::Terse);
2214 assert_eq!(crp, "compact");
2215 assert!(tm);
2216 }
2217
2218 #[test]
2219 fn to_components_max() {
2220 let (ta, od, crp, tm) = CompressionLevel::Max.to_components();
2221 assert_eq!(ta, TerseAgent::Ultra);
2222 assert_eq!(od, OutputDensity::Ultra);
2223 assert_eq!(crp, "tdd");
2224 assert!(tm);
2225 }
2226
2227 #[test]
2228 fn from_legacy_ultra_agent_maps_to_max() {
2229 assert_eq!(
2230 CompressionLevel::from_legacy(&TerseAgent::Ultra, &OutputDensity::Normal),
2231 CompressionLevel::Max
2232 );
2233 }
2234
2235 #[test]
2236 fn from_legacy_ultra_density_maps_to_max() {
2237 assert_eq!(
2238 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Ultra),
2239 CompressionLevel::Max
2240 );
2241 }
2242
2243 #[test]
2244 fn from_legacy_full_agent_maps_to_standard() {
2245 assert_eq!(
2246 CompressionLevel::from_legacy(&TerseAgent::Full, &OutputDensity::Normal),
2247 CompressionLevel::Standard
2248 );
2249 }
2250
2251 #[test]
2252 fn from_legacy_lite_agent_maps_to_lite() {
2253 assert_eq!(
2254 CompressionLevel::from_legacy(&TerseAgent::Lite, &OutputDensity::Normal),
2255 CompressionLevel::Lite
2256 );
2257 }
2258
2259 #[test]
2260 fn from_legacy_terse_density_maps_to_lite() {
2261 assert_eq!(
2262 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Terse),
2263 CompressionLevel::Lite
2264 );
2265 }
2266
2267 #[test]
2268 fn from_legacy_both_off_maps_to_off() {
2269 assert_eq!(
2270 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Normal),
2271 CompressionLevel::Off
2272 );
2273 }
2274
2275 #[test]
2276 fn labels_match() {
2277 assert_eq!(CompressionLevel::Off.label(), "off");
2278 assert_eq!(CompressionLevel::Lite.label(), "lite");
2279 assert_eq!(CompressionLevel::Standard.label(), "standard");
2280 assert_eq!(CompressionLevel::Max.label(), "max");
2281 }
2282
2283 #[test]
2284 fn is_active_false_for_off() {
2285 assert!(!CompressionLevel::Off.is_active());
2286 }
2287
2288 #[test]
2289 fn is_active_true_for_all_others() {
2290 assert!(CompressionLevel::Lite.is_active());
2291 assert!(CompressionLevel::Standard.is_active());
2292 assert!(CompressionLevel::Max.is_active());
2293 }
2294
2295 #[test]
2296 fn deserialization_defaults_to_lite() {
2297 let cfg: Config = toml::from_str("").unwrap();
2298 assert_eq!(cfg.compression_level, CompressionLevel::Lite);
2299 }
2300
2301 #[test]
2302 fn deserialization_from_toml() {
2303 let cfg: Config = toml::from_str(r#"compression_level = "standard""#).unwrap();
2304 assert_eq!(cfg.compression_level, CompressionLevel::Standard);
2305 }
2306
2307 #[test]
2308 fn roundtrip_all_levels() {
2309 for level in [
2310 CompressionLevel::Off,
2311 CompressionLevel::Lite,
2312 CompressionLevel::Standard,
2313 CompressionLevel::Max,
2314 ] {
2315 let (ta, od, crp, tm) = level.to_components();
2316 assert!(!crp.is_empty());
2317 if level == CompressionLevel::Off {
2318 assert!(!tm);
2319 assert_eq!(ta, TerseAgent::Off);
2320 assert_eq!(od, OutputDensity::Normal);
2321 } else {
2322 assert!(tm);
2323 }
2324 }
2325 }
2326}
2327
2328#[cfg(test)]
2329mod memory_cleanup_tests {
2330 use super::*;
2331
2332 #[test]
2333 fn default_is_aggressive() {
2334 assert_eq!(MemoryCleanup::default(), MemoryCleanup::Aggressive);
2335 }
2336
2337 #[test]
2338 fn aggressive_ttl_is_300() {
2339 assert_eq!(MemoryCleanup::Aggressive.idle_ttl_secs(), 300);
2340 }
2341
2342 #[test]
2343 fn shared_ttl_is_1800() {
2344 assert_eq!(MemoryCleanup::Shared.idle_ttl_secs(), 1800);
2345 }
2346
2347 #[test]
2348 fn index_retention_multiplier_values() {
2349 assert!(
2350 (MemoryCleanup::Aggressive.index_retention_multiplier() - 1.0).abs() < f64::EPSILON
2351 );
2352 assert!((MemoryCleanup::Shared.index_retention_multiplier() - 3.0).abs() < f64::EPSILON);
2353 }
2354
2355 #[test]
2356 fn deserialization_defaults_to_aggressive() {
2357 let cfg: Config = toml::from_str("").unwrap();
2358 assert_eq!(cfg.memory_cleanup, MemoryCleanup::Aggressive);
2359 }
2360
2361 #[test]
2362 fn deserialization_from_toml() {
2363 let cfg: Config = toml::from_str(r#"memory_cleanup = "shared""#).unwrap();
2364 assert_eq!(cfg.memory_cleanup, MemoryCleanup::Shared);
2365 }
2366
2367 #[test]
2368 fn effective_uses_config_when_no_env() {
2369 let cfg = Config {
2370 memory_cleanup: MemoryCleanup::Shared,
2371 ..Default::default()
2372 };
2373 let eff = MemoryCleanup::effective(&cfg);
2374 assert_eq!(eff, MemoryCleanup::Shared);
2375 }
2376}
2377
2378#[cfg(test)]
2379mod simplified_config_tests {
2380 use super::*;
2381
2382 #[test]
2383 fn max_disk_mb_zero_means_disabled() {
2384 let cfg = Config::default();
2385 assert_eq!(cfg.max_disk_mb, 0);
2386 assert_eq!(cfg.max_disk_mb_effective(), 0);
2387 }
2388
2389 #[test]
2390 fn archive_derives_from_disk_budget() {
2391 let cfg = Config {
2392 max_disk_mb: 4000,
2393 ..Default::default()
2394 };
2395 assert_eq!(cfg.archive_max_disk_mb_effective(), 1000);
2396 }
2397
2398 #[test]
2399 fn archive_explicit_overrides_derived() {
2400 let cfg = Config {
2401 max_disk_mb: 4000,
2402 archive: ArchiveConfig {
2403 max_disk_mb: 800,
2404 ..Default::default()
2405 },
2406 ..Default::default()
2407 };
2408 assert_eq!(cfg.archive_max_disk_mb_effective(), 800);
2409 }
2410
2411 #[test]
2412 fn bm25_derives_from_disk_budget() {
2413 let cfg = Config {
2414 max_disk_mb: 4000,
2415 ..Default::default()
2416 };
2417 assert_eq!(cfg.bm25_max_cache_mb_effective(), 400);
2418 }
2419
2420 #[test]
2421 fn bm25_explicit_overrides_derived() {
2422 let cfg = Config {
2423 max_disk_mb: 4000,
2424 bm25_max_cache_mb: 256,
2425 ..Default::default()
2426 };
2427 assert_eq!(cfg.bm25_max_cache_mb_effective(), 256);
2428 }
2429
2430 #[test]
2431 fn bm25_pure_default_is_generous_not_ram_profile() {
2432 let cfg = Config {
2436 memory_profile: MemoryProfile::Balanced,
2437 ..Default::default()
2438 };
2439 assert_eq!(cfg.bm25_max_cache_mb_effective(), DEFAULT_BM25_PERSIST_MB);
2440 }
2441
2442 #[test]
2443 fn staleness_days_derives_archive_age() {
2444 let cfg = Config {
2445 max_staleness_days: 30,
2446 ..Default::default()
2447 };
2448 assert_eq!(cfg.archive_max_age_hours_effective(), 720);
2449 }
2450
2451 #[test]
2452 fn staleness_explicit_archive_age_overrides() {
2453 let cfg = Config {
2454 max_staleness_days: 30,
2455 archive: ArchiveConfig {
2456 max_age_hours: 96,
2457 ..Default::default()
2458 },
2459 ..Default::default()
2460 };
2461 assert_eq!(cfg.archive_max_age_hours_effective(), 96);
2462 }
2463
2464 #[test]
2465 fn no_budget_returns_defaults() {
2466 let cfg = Config::default();
2467 assert_eq!(
2468 cfg.archive_max_disk_mb_effective(),
2469 ArchiveConfig::default().max_disk_mb
2470 );
2471 assert_eq!(
2472 cfg.archive_max_age_hours_effective(),
2473 ArchiveConfig::default().max_age_hours
2474 );
2475 }
2476
2477 #[test]
2478 fn memory_limits_scale_with_disk_budget() {
2479 let cfg = Config {
2480 max_disk_mb: 2000,
2481 ..Default::default()
2482 };
2483 let policy = cfg.memory_policy_effective().unwrap();
2484 assert_eq!(policy.knowledge.max_facts, 800);
2486 assert_eq!(policy.knowledge.max_patterns, 200);
2487 assert_eq!(policy.episodic.max_episodes, 2000);
2488 assert_eq!(policy.procedural.max_procedures, 400);
2489 }
2490
2491 #[test]
2492 fn memory_limits_clamped_at_max_factor() {
2493 let cfg = Config {
2494 max_disk_mb: 50_000,
2495 ..Default::default()
2496 };
2497 let policy = cfg.memory_policy_effective().unwrap();
2498 assert_eq!(policy.knowledge.max_facts, 2000);
2500 assert_eq!(policy.episodic.max_episodes, 5000);
2501 }
2502
2503 #[test]
2504 fn memory_limits_unchanged_when_no_budget() {
2505 let cfg = Config::default();
2506 let policy = cfg.memory_policy_effective().unwrap();
2507 assert_eq!(policy.knowledge.max_facts, 200);
2508 assert_eq!(policy.episodic.max_episodes, 500);
2509 }
2510
2511 #[test]
2512 fn simplified_template_is_valid_toml() {
2513 let parsed: Result<toml::Table, _> = toml::from_str(crate::cli::SIMPLIFIED_TEMPLATE);
2514 assert!(parsed.is_ok(), "Template must be valid TOML");
2515 }
2516}
2517
2518#[cfg(test)]
2519mod setup_config_tests {
2520 use super::*;
2521
2522 #[test]
2523 fn default_is_none_for_rules_and_skills() {
2524 let cfg = SetupConfig::default();
2525 assert!(cfg.auto_inject_rules.is_none());
2526 assert!(cfg.auto_inject_skills.is_none());
2527 assert!(cfg.auto_update_mcp);
2528 }
2529
2530 #[test]
2531 fn explicit_true_injects() {
2532 let cfg = SetupConfig {
2533 auto_inject_rules: Some(true),
2534 auto_inject_skills: Some(true),
2535 auto_update_mcp: true,
2536 };
2537 assert!(cfg.should_inject_rules());
2538 assert!(cfg.should_inject_skills());
2539 }
2540
2541 #[test]
2542 fn explicit_false_skips() {
2543 let cfg = SetupConfig {
2544 auto_inject_rules: Some(false),
2545 auto_inject_skills: Some(false),
2546 auto_update_mcp: true,
2547 };
2548 assert!(!cfg.should_inject_rules());
2549 assert!(!cfg.should_inject_skills());
2550 }
2551
2552 #[test]
2553 fn deserialization_defaults_when_absent() {
2554 let cfg: Config = toml::from_str("").unwrap();
2555 assert!(cfg.setup.auto_inject_rules.is_none());
2556 assert!(cfg.setup.auto_inject_skills.is_none());
2557 assert!(cfg.setup.auto_update_mcp);
2558 }
2559
2560 #[test]
2561 fn deserialization_from_toml() {
2562 let cfg: Config = toml::from_str(
2563 r"
2564 [setup]
2565 auto_inject_rules = true
2566 auto_inject_skills = false
2567 auto_update_mcp = true
2568 ",
2569 )
2570 .unwrap();
2571 assert_eq!(cfg.setup.auto_inject_rules, Some(true));
2572 assert_eq!(cfg.setup.auto_inject_skills, Some(false));
2573 assert!(cfg.setup.auto_update_mcp);
2574 }
2575
2576 #[test]
2577 fn deserialization_null_values() {
2578 let cfg: Config = toml::from_str(
2579 r"
2580 [setup]
2581 auto_update_mcp = false
2582 ",
2583 )
2584 .unwrap();
2585 assert!(cfg.setup.auto_inject_rules.is_none());
2586 assert!(cfg.setup.auto_inject_skills.is_none());
2587 assert!(!cfg.setup.auto_update_mcp);
2588 }
2589
2590 #[test]
2591 fn roundtrip_serialize_deserialize() {
2592 let original = Config {
2593 setup: SetupConfig {
2594 auto_inject_rules: Some(true),
2595 auto_inject_skills: Some(false),
2596 auto_update_mcp: true,
2597 },
2598 ..Config::default()
2599 };
2600 let toml_str = toml::to_string_pretty(&original).unwrap();
2601 let parsed: Config = toml::from_str(&toml_str).unwrap();
2602 assert_eq!(parsed.setup.auto_inject_rules, Some(true));
2603 assert_eq!(parsed.setup.auto_inject_skills, Some(false));
2604 assert!(parsed.setup.auto_update_mcp);
2605 }
2606
2607 #[test]
2608 fn fresh_install_no_rules_should_not_inject() {
2609 let cfg = SetupConfig::default();
2610 let result = cfg.should_inject_rules();
2613 let _ = result;
2616 }
2617}