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