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