1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4use std::sync::atomic::AtomicU8;
5use std::sync::Mutex;
6use std::time::SystemTime;
7
8static SESSION_DEGRADE_LEVEL: AtomicU8 = AtomicU8::new(0);
9
10use super::memory_policy::MemoryPolicy;
11
12mod memory;
13mod proxy;
14pub mod schema;
15mod serde_defaults;
16mod shell_activation;
17
18pub use memory::{MemoryCleanup, MemoryGuardConfig, MemoryProfile, SavingsFooter};
19pub use proxy::{is_local_proxy_url, normalize_url, normalize_url_opt, ProxyConfig, ProxyProvider};
20pub use shell_activation::ShellActivation;
21
22pub fn default_bm25_max_cache_mb() -> u64 {
24 serde_defaults::default_bm25_max_cache_mb()
25}
26
27#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
29#[serde(rename_all = "lowercase")]
30pub enum TeeMode {
31 Never,
32 #[default]
33 Failures,
34 HighCompression,
35 Always,
36}
37
38#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
42#[serde(rename_all = "lowercase")]
43pub enum TerseAgent {
44 #[default]
45 Off,
46 Lite,
47 Full,
48 Ultra,
49}
50
51impl TerseAgent {
52 pub fn from_env() -> Self {
54 match std::env::var("LEAN_CTX_TERSE_AGENT")
55 .unwrap_or_default()
56 .to_lowercase()
57 .as_str()
58 {
59 "lite" => Self::Lite,
60 "full" => Self::Full,
61 "ultra" => Self::Ultra,
62 _ => Self::Off,
63 }
64 }
65}
66
67#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
71#[serde(rename_all = "lowercase")]
72pub enum OutputDensity {
73 #[default]
74 Normal,
75 Terse,
76 Ultra,
77}
78
79impl OutputDensity {
80 pub fn from_env() -> Self {
82 match std::env::var("LEAN_CTX_OUTPUT_DENSITY")
83 .unwrap_or_default()
84 .to_lowercase()
85 .as_str()
86 {
87 "terse" => Self::Terse,
88 "ultra" => Self::Ultra,
89 _ => Self::Normal,
90 }
91 }
92}
93
94#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
99#[serde(rename_all = "snake_case")]
100pub enum ResponseVerbosity {
101 #[default]
102 Full,
103 HeadersOnly,
104}
105
106impl ResponseVerbosity {
107 pub fn effective() -> Self {
108 if let Ok(v) = std::env::var("LEAN_CTX_RESPONSE_VERBOSITY") {
109 match v.trim().to_lowercase().as_str() {
110 "headers_only" | "headers" | "minimal" => return Self::HeadersOnly,
111 "full" | "" => return Self::Full,
112 _ => {}
113 }
114 }
115 Config::load().response_verbosity
116 }
117
118 pub fn is_headers_only(&self) -> bool {
119 matches!(self, Self::HeadersOnly)
120 }
121}
122
123#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
125#[serde(rename_all = "lowercase")]
126pub enum CompressionLevel {
127 Off,
128 Lite,
129 #[default]
130 Standard,
131 Max,
132}
133
134impl CompressionLevel {
135 pub fn to_components(&self) -> (TerseAgent, OutputDensity, &'static str, bool) {
138 match self {
139 Self::Off => (TerseAgent::Off, OutputDensity::Normal, "off", false),
140 Self::Lite => (TerseAgent::Lite, OutputDensity::Terse, "off", true),
141 Self::Standard => (TerseAgent::Full, OutputDensity::Terse, "compact", true),
142 Self::Max => (TerseAgent::Ultra, OutputDensity::Ultra, "tdd", true),
143 }
144 }
145
146 pub fn from_legacy(terse_agent: &TerseAgent, output_density: &OutputDensity) -> Self {
149 match (terse_agent, output_density) {
150 (TerseAgent::Ultra, _) | (_, OutputDensity::Ultra) => Self::Max,
151 (TerseAgent::Full, _) => Self::Standard,
152 (TerseAgent::Lite, _) | (_, OutputDensity::Terse) => Self::Lite,
153 _ => Self::Off,
154 }
155 }
156
157 pub fn from_env() -> Option<Self> {
159 std::env::var("LEAN_CTX_COMPRESSION").ok().and_then(|v| {
160 match v.trim().to_lowercase().as_str() {
161 "off" => Some(Self::Off),
162 "lite" => Some(Self::Lite),
163 "standard" => Some(Self::Standard),
164 "max" => Some(Self::Max),
165 _ => None,
166 }
167 })
168 }
169
170 pub fn effective(config: &Config) -> Self {
178 if let Some(degraded) = Self::session_degrade_level() {
179 return degraded;
180 }
181 if let Some(env_level) = Self::from_env() {
182 return env_level;
183 }
184 if config.compression_level != Self::Off {
185 return config.compression_level.clone();
186 }
187 if config.ultra_compact {
188 return Self::Max;
189 }
190 let ta_env = TerseAgent::from_env();
191 let od_env = OutputDensity::from_env();
192 let ta = if ta_env == TerseAgent::Off {
193 config.terse_agent.clone()
194 } else {
195 ta_env
196 };
197 let od = if od_env == OutputDensity::Normal {
198 config.output_density.clone()
199 } else {
200 od_env
201 };
202 Self::from_legacy(&ta, &od)
203 }
204
205 pub fn session_degrade_level() -> Option<Self> {
208 match SESSION_DEGRADE_LEVEL.load(std::sync::atomic::Ordering::Relaxed) {
209 1 => Some(Self::Off),
210 2 => Some(Self::Lite),
211 _ => None,
212 }
213 }
214
215 pub fn set_session_degrade(level: &Self) {
217 let val = match level {
218 Self::Off => 1u8,
219 Self::Lite => 2u8,
220 _ => 0u8,
221 };
222 SESSION_DEGRADE_LEVEL.store(val, std::sync::atomic::Ordering::Relaxed);
223 }
224
225 pub fn clear_session_degrade() {
227 SESSION_DEGRADE_LEVEL.store(0, std::sync::atomic::Ordering::Relaxed);
228 }
229
230 pub fn from_str_label(s: &str) -> Option<Self> {
231 match s.trim().to_lowercase().as_str() {
232 "off" => Some(Self::Off),
233 "lite" => Some(Self::Lite),
234 "standard" | "std" => Some(Self::Standard),
235 "max" => Some(Self::Max),
236 _ => None,
237 }
238 }
239
240 pub fn is_active(&self) -> bool {
241 !matches!(self, Self::Off)
242 }
243
244 pub fn label(&self) -> &'static str {
245 match self {
246 Self::Off => "off",
247 Self::Lite => "lite",
248 Self::Standard => "standard",
249 Self::Max => "max",
250 }
251 }
252
253 pub fn description(&self) -> &'static str {
254 match self {
255 Self::Off => "No compression — full verbose output",
256 Self::Lite => "Light compression — concise output, basic terse filtering",
257 Self::Standard => {
258 "Standard compression — dense output, compact protocol, pattern-aware"
259 }
260 Self::Max => "Maximum compression — expert mode, TDD protocol, all layers active",
261 }
262 }
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
267#[serde(default)]
268pub struct Config {
269 pub ultra_compact: bool,
270 #[serde(default, deserialize_with = "serde_defaults::deserialize_tee_mode")]
271 pub tee_mode: TeeMode,
272 #[serde(default)]
273 pub output_density: OutputDensity,
274 pub checkpoint_interval: u32,
275 pub excluded_commands: Vec<String>,
276 pub passthrough_urls: Vec<String>,
277 pub custom_aliases: Vec<AliasEntry>,
278 pub slow_command_threshold_ms: u64,
281 #[serde(default = "serde_defaults::default_theme")]
282 pub theme: String,
283 #[serde(default)]
284 pub cloud: CloudConfig,
285 #[serde(default)]
286 pub autonomy: AutonomyConfig,
287 #[serde(default)]
288 pub providers: ProvidersConfig,
289 #[serde(default)]
290 pub proxy: ProxyConfig,
291 #[serde(default)]
296 pub proxy_enabled: Option<bool>,
297 #[serde(default)]
298 pub proxy_port: Option<u16>,
299 #[serde(default = "serde_defaults::default_buddy_enabled")]
300 pub buddy_enabled: bool,
301 #[serde(default = "serde_defaults::default_true")]
302 pub enable_wakeup_ctx: bool,
303 #[serde(default)]
304 pub redirect_exclude: Vec<String>,
305 #[serde(default)]
309 pub disabled_tools: Vec<String>,
310 #[serde(default)]
311 pub loop_detection: LoopDetectionConfig,
312 #[serde(default)]
316 pub rules_scope: Option<String>,
317 #[serde(default)]
320 pub extra_ignore_patterns: Vec<String>,
321 #[serde(default)]
325 pub terse_agent: TerseAgent,
326 #[serde(default)]
330 pub compression_level: CompressionLevel,
331 #[serde(default)]
333 pub archive: ArchiveConfig,
334 #[serde(default)]
336 pub memory: MemoryPolicy,
337 #[serde(default)]
341 pub allow_paths: Vec<String>,
342 #[serde(default)]
345 pub content_defined_chunking: bool,
346 #[serde(default)]
349 pub minimal_overhead: bool,
350 #[serde(default)]
353 pub shell_hook_disabled: bool,
354 #[serde(default)]
361 pub shell_activation: ShellActivation,
362 #[serde(default)]
365 pub update_check_disabled: bool,
366 #[serde(default)]
367 pub updates: UpdatesConfig,
368 #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
371 pub bm25_max_cache_mb: u64,
372 #[serde(default = "serde_defaults::default_graph_index_max_files")]
375 pub graph_index_max_files: u64,
376 #[serde(default)]
379 pub memory_profile: MemoryProfile,
380 #[serde(default)]
384 pub memory_cleanup: MemoryCleanup,
385 #[serde(default = "serde_defaults::default_max_ram_percent")]
388 pub max_ram_percent: u8,
389 #[serde(default)]
393 pub savings_footer: SavingsFooter,
394 #[serde(default)]
398 pub project_root: Option<String>,
399 #[serde(default)]
402 pub lsp: std::collections::HashMap<String, String>,
403 #[serde(default)]
407 pub ide_paths: HashMap<String, Vec<String>>,
408 #[serde(default)]
411 pub model_context_windows: HashMap<String, usize>,
412 #[serde(default)]
419 pub response_verbosity: ResponseVerbosity,
420 #[serde(default)]
425 pub bypass_hints: Option<String>,
426 #[serde(default)]
431 pub cache_policy: Option<String>,
432 #[serde(default)]
435 pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
436 #[serde(default)]
437 pub secret_detection: SecretDetectionConfig,
438 #[serde(default)]
442 pub allow_auto_reroot: bool,
443 #[serde(default)]
446 pub path_jail: Option<bool>,
447 #[serde(default)]
451 pub sandbox_level: u8,
452 #[serde(default)]
456 pub reference_results: bool,
457 #[serde(default)]
460 pub agent_token_budget: usize,
461 #[serde(default = "default_shell_allowlist")]
466 pub shell_allowlist: Vec<String>,
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize)]
470#[serde(default)]
471pub struct SecretDetectionConfig {
472 pub enabled: bool,
473 pub redact: bool,
474 pub custom_patterns: Vec<String>,
475}
476
477impl Default for SecretDetectionConfig {
478 fn default() -> Self {
479 Self {
480 enabled: true,
481 redact: false,
482 custom_patterns: Vec::new(),
483 }
484 }
485}
486
487#[derive(Debug, Clone, Serialize, Deserialize)]
489#[serde(default)]
490pub struct ArchiveConfig {
491 pub enabled: bool,
492 pub threshold_chars: usize,
493 pub max_age_hours: u64,
494 pub max_disk_mb: u64,
495}
496
497impl Default for ArchiveConfig {
498 fn default() -> Self {
499 Self {
500 enabled: true,
501 threshold_chars: 4096,
502 max_age_hours: 48,
503 max_disk_mb: 500,
504 }
505 }
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize)]
512#[serde(default)]
513pub struct ProvidersConfig {
514 pub enabled: bool,
516 pub github: ProviderEntryConfig,
518 pub gitlab: ProviderEntryConfig,
520 pub auto_index: bool,
522 pub cache_ttl_secs: u64,
524}
525
526impl Default for ProvidersConfig {
527 fn default() -> Self {
528 Self {
529 enabled: true,
530 github: ProviderEntryConfig::default(),
531 gitlab: ProviderEntryConfig::default(),
532 auto_index: false,
533 cache_ttl_secs: 120,
534 }
535 }
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize)]
540#[serde(default)]
541pub struct ProviderEntryConfig {
542 pub enabled: bool,
544 pub token: Option<String>,
546 pub api_url: Option<String>,
548 pub project: Option<String>,
550}
551
552impl Default for ProviderEntryConfig {
553 fn default() -> Self {
554 Self {
555 enabled: true,
556 token: None,
557 api_url: None,
558 project: None,
559 }
560 }
561}
562
563#[derive(Debug, Clone, Serialize, Deserialize)]
565#[serde(default)]
566pub struct AutonomyConfig {
567 pub enabled: bool,
568 pub auto_preload: bool,
569 pub auto_dedup: bool,
570 pub auto_related: bool,
571 pub auto_consolidate: bool,
572 pub silent_preload: bool,
573 pub dedup_threshold: usize,
574 pub consolidate_every_calls: u32,
575 pub consolidate_cooldown_secs: u64,
576 #[serde(default = "serde_defaults::default_true")]
577 pub cognition_loop_enabled: bool,
578 #[serde(default = "serde_defaults::default_cognition_loop_interval")]
579 pub cognition_loop_interval_secs: u64,
580 #[serde(default = "serde_defaults::default_cognition_loop_max_steps")]
581 pub cognition_loop_max_steps: u8,
582}
583
584impl Default for AutonomyConfig {
585 fn default() -> Self {
586 Self {
587 enabled: true,
588 auto_preload: true,
589 auto_dedup: true,
590 auto_related: true,
591 auto_consolidate: true,
592 silent_preload: true,
593 dedup_threshold: 8,
594 consolidate_every_calls: 25,
595 consolidate_cooldown_secs: 120,
596 cognition_loop_enabled: true,
597 cognition_loop_interval_secs: 3600,
598 cognition_loop_max_steps: 8,
599 }
600 }
601}
602
603#[derive(Debug, Clone, Serialize, Deserialize)]
606#[serde(default)]
607pub struct UpdatesConfig {
608 pub auto_update: bool,
609 pub check_interval_hours: u64,
610 pub notify_only: bool,
611}
612
613impl Default for UpdatesConfig {
614 fn default() -> Self {
615 Self {
616 auto_update: false,
617 check_interval_hours: 6,
618 notify_only: false,
619 }
620 }
621}
622
623impl UpdatesConfig {
624 pub fn from_env() -> Self {
625 let mut cfg = Self::default();
626 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_UPDATE") {
627 cfg.auto_update = v == "1" || v.eq_ignore_ascii_case("true");
628 }
629 if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_INTERVAL_HOURS") {
630 if let Ok(h) = v.parse::<u64>() {
631 cfg.check_interval_hours = h.clamp(1, 168);
632 }
633 }
634 if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_NOTIFY_ONLY") {
635 cfg.notify_only = v == "1" || v.eq_ignore_ascii_case("true");
636 }
637 cfg
638 }
639}
640
641impl AutonomyConfig {
642 pub fn from_env() -> Self {
644 let mut cfg = Self::default();
645 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
646 if v == "false" || v == "0" {
647 cfg.enabled = false;
648 }
649 }
650 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
651 cfg.auto_preload = v != "false" && v != "0";
652 }
653 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
654 cfg.auto_dedup = v != "false" && v != "0";
655 }
656 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
657 cfg.auto_related = v != "false" && v != "0";
658 }
659 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
660 cfg.auto_consolidate = v != "false" && v != "0";
661 }
662 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
663 cfg.silent_preload = v != "false" && v != "0";
664 }
665 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
666 if let Ok(n) = v.parse() {
667 cfg.dedup_threshold = n;
668 }
669 }
670 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
671 if let Ok(n) = v.parse() {
672 cfg.consolidate_every_calls = n;
673 }
674 }
675 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
676 if let Ok(n) = v.parse() {
677 cfg.consolidate_cooldown_secs = 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 pub fn load() -> Self {
698 let file_cfg = Config::load().autonomy;
699 let mut cfg = file_cfg;
700 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
701 if v == "false" || v == "0" {
702 cfg.enabled = false;
703 }
704 }
705 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
706 cfg.auto_preload = v != "false" && v != "0";
707 }
708 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
709 cfg.auto_dedup = v != "false" && v != "0";
710 }
711 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
712 cfg.auto_related = v != "false" && v != "0";
713 }
714 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
715 cfg.silent_preload = v != "false" && v != "0";
716 }
717 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
718 if let Ok(n) = v.parse() {
719 cfg.dedup_threshold = n;
720 }
721 }
722 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
723 cfg.cognition_loop_enabled = v != "false" && v != "0";
724 }
725 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
726 if let Ok(n) = v.parse() {
727 cfg.cognition_loop_interval_secs = n;
728 }
729 }
730 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
731 if let Ok(n) = v.parse() {
732 cfg.cognition_loop_max_steps = n;
733 }
734 }
735 cfg
736 }
737}
738
739#[derive(Debug, Clone, Serialize, Deserialize, Default)]
741#[serde(default)]
742pub struct CloudConfig {
743 pub contribute_enabled: bool,
744 pub last_contribute: Option<String>,
745 pub last_sync: Option<String>,
746 pub last_gain_sync: Option<String>,
747 pub last_model_pull: Option<String>,
748}
749
750#[derive(Debug, Clone, Serialize, Deserialize)]
752pub struct AliasEntry {
753 pub command: String,
754 pub alias: String,
755}
756
757#[derive(Debug, Clone, Serialize, Deserialize)]
759#[serde(default)]
760pub struct LoopDetectionConfig {
761 pub normal_threshold: u32,
762 pub reduced_threshold: u32,
763 pub blocked_threshold: u32,
764 pub window_secs: u64,
765 pub search_group_limit: u32,
766 pub tool_total_limits: HashMap<String, u32>,
767}
768
769impl Default for LoopDetectionConfig {
770 fn default() -> Self {
771 let mut tool_total_limits = HashMap::new();
772 tool_total_limits.insert("ctx_read".to_string(), 100);
773 tool_total_limits.insert("ctx_search".to_string(), 80);
774 tool_total_limits.insert("ctx_shell".to_string(), 50);
775 tool_total_limits.insert("ctx_semantic_search".to_string(), 60);
776 Self {
777 normal_threshold: 2,
778 reduced_threshold: 4,
779 blocked_threshold: 0,
780 window_secs: 300,
781 search_group_limit: 10,
782 tool_total_limits,
783 }
784 }
785}
786
787impl Default for Config {
788 fn default() -> Self {
789 Self {
790 ultra_compact: false,
791 tee_mode: TeeMode::default(),
792 output_density: OutputDensity::default(),
793 checkpoint_interval: 15,
794 excluded_commands: Vec::new(),
795 passthrough_urls: Vec::new(),
796 custom_aliases: Vec::new(),
797 slow_command_threshold_ms: 5000,
798 theme: serde_defaults::default_theme(),
799 cloud: CloudConfig::default(),
800 autonomy: AutonomyConfig::default(),
801 providers: ProvidersConfig::default(),
802 proxy: ProxyConfig::default(),
803 proxy_enabled: None,
804 proxy_port: None,
805 buddy_enabled: serde_defaults::default_buddy_enabled(),
806 enable_wakeup_ctx: true,
807 redirect_exclude: Vec::new(),
808 disabled_tools: Vec::new(),
809 loop_detection: LoopDetectionConfig::default(),
810 rules_scope: None,
811 extra_ignore_patterns: Vec::new(),
812 terse_agent: TerseAgent::default(),
813 compression_level: CompressionLevel::default(),
814 archive: ArchiveConfig::default(),
815 memory: MemoryPolicy::default(),
816 allow_paths: Vec::new(),
817 content_defined_chunking: false,
818 minimal_overhead: false,
819 shell_hook_disabled: false,
820 shell_activation: ShellActivation::default(),
821 update_check_disabled: false,
822 updates: UpdatesConfig::default(),
823 graph_index_max_files: serde_defaults::default_graph_index_max_files(),
824 bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
825 memory_profile: MemoryProfile::default(),
826 memory_cleanup: MemoryCleanup::default(),
827 max_ram_percent: serde_defaults::default_max_ram_percent(),
828 savings_footer: SavingsFooter::default(),
829 project_root: None,
830 lsp: std::collections::HashMap::new(),
831 ide_paths: HashMap::new(),
832 model_context_windows: HashMap::new(),
833 response_verbosity: ResponseVerbosity::default(),
834 bypass_hints: None,
835 cache_policy: None,
836 boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
837 secret_detection: SecretDetectionConfig::default(),
838 allow_auto_reroot: false,
839 path_jail: None,
840 sandbox_level: 0,
841 reference_results: false,
842 agent_token_budget: 0,
843 shell_allowlist: default_shell_allowlist(),
844 }
845 }
846}
847
848fn default_shell_allowlist() -> Vec<String> {
849 [
850 "git",
852 "gh",
853 "svn",
854 "cargo",
856 "npm",
857 "npx",
858 "yarn",
859 "pnpm",
860 "bun",
861 "make",
862 "cmake",
863 "pip",
864 "pip3",
865 "poetry",
866 "uv",
867 "go",
868 "mvn",
869 "gradle",
870 "mix",
871 "dotnet",
872 "swift",
873 "zig",
874 "rustup",
875 "rustc",
876 "ls",
878 "cat",
879 "head",
880 "tail",
881 "wc",
882 "sort",
883 "uniq",
884 "tr",
885 "cut",
886 "grep",
887 "rg",
888 "find",
889 "fd",
890 "ag",
891 "ack",
892 "sed",
893 "awk",
894 "echo",
895 "printf",
896 "true",
897 "false",
898 "test",
899 "expr",
900 "cd",
901 "pwd",
902 "basename",
903 "dirname",
904 "realpath",
905 "readlink",
906 "cp",
907 "mv",
908 "mkdir",
909 "rm",
910 "rmdir",
911 "touch",
912 "ln",
913 "chmod",
914 "diff",
915 "patch",
916 "tar",
917 "zip",
918 "unzip",
919 "gzip",
920 "gunzip",
921 "zstd",
922 "curl",
923 "wget",
924 "docker",
926 "docker-compose",
927 "podman",
928 "node",
929 "python",
930 "python3",
931 "ruby",
932 "perl",
933 "java",
934 "javac",
935 "tsc",
936 "eslint",
937 "prettier",
938 "black",
939 "ruff",
940 "clippy",
941 "jq",
942 "yq",
943 "xargs",
944 "env",
945 "which",
946 "type",
947 "file",
948 "stat",
949 "date",
950 "sleep",
951 "timeout",
952 "nice",
953 "ionice",
954 "lean-ctx",
956 ]
957 .iter()
958 .map(|s| (*s).to_string())
959 .collect()
960}
961
962#[derive(Debug, Clone, Copy, PartialEq, Eq)]
964pub enum RulesScope {
965 Both,
966 Global,
967 Project,
968}
969
970impl Config {
971 pub fn rules_scope_effective(&self) -> RulesScope {
973 let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
974 .ok()
975 .or_else(|| self.rules_scope.clone())
976 .unwrap_or_default();
977 match raw.trim().to_lowercase().as_str() {
978 "global" => RulesScope::Global,
979 "project" => RulesScope::Project,
980 _ => RulesScope::Both,
981 }
982 }
983
984 fn parse_disabled_tools_env(val: &str) -> Vec<String> {
985 val.split(',')
986 .map(|s| s.trim().to_string())
987 .filter(|s| !s.is_empty())
988 .collect()
989 }
990
991 pub fn disabled_tools_effective(&self) -> Vec<String> {
993 if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
994 Self::parse_disabled_tools_env(&val)
995 } else {
996 self.disabled_tools.clone()
997 }
998 }
999
1000 pub fn minimal_overhead_effective(&self) -> bool {
1002 std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
1003 }
1004
1005 pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
1013 if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
1014 match raw.trim().to_lowercase().as_str() {
1015 "minimal" => return true,
1016 "full" => return self.minimal_overhead_effective(),
1017 _ => {}
1018 }
1019 }
1020
1021 if self.minimal_overhead_effective() {
1022 return true;
1023 }
1024
1025 let client_lower = client_name.trim().to_lowercase();
1026 if !client_lower.is_empty() {
1027 if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
1028 for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
1029 if !needle.is_empty() && client_lower.contains(&needle) {
1030 return true;
1031 }
1032 }
1033 } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
1034 return true;
1035 }
1036 }
1037
1038 let model = std::env::var("LEAN_CTX_MODEL")
1039 .or_else(|_| std::env::var("LCTX_MODEL"))
1040 .unwrap_or_default();
1041 let model = model.trim().to_lowercase();
1042 if !model.is_empty() {
1043 let m = model.replace(['_', ' '], "-");
1044 if m.contains("minimax")
1045 || m.contains("mini-max")
1046 || m.contains("m2.7")
1047 || m.contains("m2-7")
1048 {
1049 return true;
1050 }
1051 }
1052
1053 false
1054 }
1055
1056 pub fn shell_hook_disabled_effective(&self) -> bool {
1058 std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
1059 }
1060
1061 pub fn shell_activation_effective(&self) -> ShellActivation {
1063 ShellActivation::effective(self)
1064 }
1065
1066 pub fn update_check_disabled_effective(&self) -> bool {
1068 std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
1069 }
1070
1071 pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
1072 let mut policy = self.memory.clone();
1073 policy.apply_env_overrides();
1074 policy.validate()?;
1075 Ok(policy)
1076 }
1077}
1078
1079#[cfg(test)]
1080mod disabled_tools_tests {
1081 use super::*;
1082
1083 #[test]
1084 fn config_field_default_is_empty() {
1085 let cfg = Config::default();
1086 assert!(cfg.disabled_tools.is_empty());
1087 }
1088
1089 #[test]
1090 fn effective_returns_config_field_when_no_env_var() {
1091 if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
1093 return;
1094 }
1095 let cfg = Config {
1096 disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
1097 ..Default::default()
1098 };
1099 assert_eq!(
1100 cfg.disabled_tools_effective(),
1101 vec!["ctx_graph", "ctx_agent"]
1102 );
1103 }
1104
1105 #[test]
1106 fn parse_env_basic() {
1107 let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
1108 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
1109 }
1110
1111 #[test]
1112 fn parse_env_trims_whitespace_and_skips_empty() {
1113 let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
1114 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
1115 }
1116
1117 #[test]
1118 fn parse_env_single_entry() {
1119 let result = Config::parse_disabled_tools_env("ctx_graph");
1120 assert_eq!(result, vec!["ctx_graph"]);
1121 }
1122
1123 #[test]
1124 fn parse_env_empty_string_returns_empty() {
1125 let result = Config::parse_disabled_tools_env("");
1126 assert!(result.is_empty());
1127 }
1128
1129 #[test]
1130 fn disabled_tools_deserialization_defaults_to_empty() {
1131 let cfg: Config = toml::from_str("").unwrap();
1132 assert!(cfg.disabled_tools.is_empty());
1133 }
1134
1135 #[test]
1136 fn disabled_tools_deserialization_from_toml() {
1137 let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
1138 assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
1139 }
1140}
1141
1142#[cfg(test)]
1143mod rules_scope_tests {
1144 use super::*;
1145
1146 #[test]
1147 fn default_is_both() {
1148 let cfg = Config::default();
1149 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1150 }
1151
1152 #[test]
1153 fn config_global() {
1154 let cfg = Config {
1155 rules_scope: Some("global".to_string()),
1156 ..Default::default()
1157 };
1158 assert_eq!(cfg.rules_scope_effective(), RulesScope::Global);
1159 }
1160
1161 #[test]
1162 fn config_project() {
1163 let cfg = Config {
1164 rules_scope: Some("project".to_string()),
1165 ..Default::default()
1166 };
1167 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1168 }
1169
1170 #[test]
1171 fn unknown_value_falls_back_to_both() {
1172 let cfg = Config {
1173 rules_scope: Some("nonsense".to_string()),
1174 ..Default::default()
1175 };
1176 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1177 }
1178
1179 #[test]
1180 fn deserialization_none_by_default() {
1181 let cfg: Config = toml::from_str("").unwrap();
1182 assert!(cfg.rules_scope.is_none());
1183 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1184 }
1185
1186 #[test]
1187 fn deserialization_from_toml() {
1188 let cfg: Config = toml::from_str(r#"rules_scope = "project""#).unwrap();
1189 assert_eq!(cfg.rules_scope.as_deref(), Some("project"));
1190 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1191 }
1192}
1193
1194#[cfg(test)]
1195mod loop_detection_config_tests {
1196 use super::*;
1197
1198 #[test]
1199 fn defaults_are_reasonable() {
1200 let cfg = LoopDetectionConfig::default();
1201 assert_eq!(cfg.normal_threshold, 2);
1202 assert_eq!(cfg.reduced_threshold, 4);
1203 assert_eq!(cfg.blocked_threshold, 0);
1205 assert_eq!(cfg.window_secs, 300);
1206 assert_eq!(cfg.search_group_limit, 10);
1207 }
1208
1209 #[test]
1210 fn deserialization_defaults_when_missing() {
1211 let cfg: Config = toml::from_str("").unwrap();
1212 assert_eq!(cfg.loop_detection.blocked_threshold, 0);
1214 assert_eq!(cfg.loop_detection.search_group_limit, 10);
1215 }
1216
1217 #[test]
1218 fn deserialization_from_toml() {
1219 let cfg: Config = toml::from_str(
1220 r"
1221 [loop_detection]
1222 normal_threshold = 1
1223 reduced_threshold = 3
1224 blocked_threshold = 5
1225 window_secs = 120
1226 search_group_limit = 8
1227 ",
1228 )
1229 .unwrap();
1230 assert_eq!(cfg.loop_detection.normal_threshold, 1);
1231 assert_eq!(cfg.loop_detection.reduced_threshold, 3);
1232 assert_eq!(cfg.loop_detection.blocked_threshold, 5);
1233 assert_eq!(cfg.loop_detection.window_secs, 120);
1234 assert_eq!(cfg.loop_detection.search_group_limit, 8);
1235 }
1236
1237 #[test]
1238 fn partial_override_keeps_defaults() {
1239 let cfg: Config = toml::from_str(
1240 r"
1241 [loop_detection]
1242 blocked_threshold = 10
1243 ",
1244 )
1245 .unwrap();
1246 assert_eq!(cfg.loop_detection.blocked_threshold, 10);
1247 assert_eq!(cfg.loop_detection.normal_threshold, 2);
1248 assert_eq!(cfg.loop_detection.search_group_limit, 10);
1249 }
1250}
1251
1252impl Config {
1253 pub fn path() -> Option<PathBuf> {
1255 crate::core::data_dir::lean_ctx_data_dir()
1256 .ok()
1257 .map(|d| d.join("config.toml"))
1258 }
1259
1260 pub fn local_path(project_root: &str) -> PathBuf {
1262 PathBuf::from(project_root).join(".lean-ctx.toml")
1263 }
1264
1265 fn find_project_root() -> Option<String> {
1266 static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
1267 ROOT_CACHE
1268 .get_or_init(Self::find_project_root_inner)
1269 .clone()
1270 }
1271
1272 fn find_project_root_inner() -> Option<String> {
1273 if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
1274 if !env_root.is_empty() {
1275 return Some(env_root);
1276 }
1277 }
1278
1279 let cwd = std::env::current_dir().ok();
1280
1281 if let Some(root) =
1282 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
1283 {
1284 let root_path = std::path::Path::new(&root);
1285 let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
1286 let has_marker = root_path.join(".git").exists()
1287 || root_path.join("Cargo.toml").exists()
1288 || root_path.join("package.json").exists()
1289 || root_path.join("go.mod").exists()
1290 || root_path.join("pyproject.toml").exists()
1291 || root_path.join(".lean-ctx.toml").exists();
1292
1293 if cwd_is_under_root || has_marker {
1294 return Some(root);
1295 }
1296 }
1297
1298 if let Some(ref cwd) = cwd {
1299 let git_root = std::process::Command::new("git")
1300 .args(["rev-parse", "--show-toplevel"])
1301 .current_dir(cwd)
1302 .stdout(std::process::Stdio::piped())
1303 .stderr(std::process::Stdio::null())
1304 .output()
1305 .ok()
1306 .and_then(|o| {
1307 if o.status.success() {
1308 String::from_utf8(o.stdout)
1309 .ok()
1310 .map(|s| s.trim().to_string())
1311 } else {
1312 None
1313 }
1314 });
1315 if let Some(root) = git_root {
1316 return Some(root);
1317 }
1318 if !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
1319 return Some(cwd.to_string_lossy().to_string());
1320 }
1321 }
1322 None
1323 }
1324
1325 pub fn load() -> Self {
1327 static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
1328
1329 let Some(path) = Self::path() else {
1330 return Self::default();
1331 };
1332
1333 let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
1334
1335 let mtime = std::fs::metadata(&path)
1336 .and_then(|m| m.modified())
1337 .unwrap_or(SystemTime::UNIX_EPOCH);
1338
1339 let local_mtime = local_path
1340 .as_ref()
1341 .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
1342
1343 if let Ok(guard) = CACHE.lock() {
1344 if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
1345 if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
1346 return cfg.clone();
1347 }
1348 }
1349 }
1350
1351 let mut cfg: Config = match std::fs::read_to_string(&path) {
1352 Ok(content) => match toml::from_str(&content) {
1353 Ok(c) => c,
1354 Err(e) => {
1355 tracing::warn!("config parse error in {}: {e}", path.display());
1356 eprintln!(
1357 "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n \
1358 Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
1359 path.display()
1360 );
1361 Self::default()
1362 }
1363 },
1364 Err(_) => Self::default(),
1365 };
1366
1367 if let Some(ref lp) = local_path {
1368 if let Ok(local_content) = std::fs::read_to_string(lp) {
1369 cfg.merge_local(&local_content);
1370 }
1371 }
1372
1373 if let Ok(mut guard) = CACHE.lock() {
1374 *guard = Some((cfg.clone(), mtime, local_mtime));
1375 }
1376
1377 cfg
1378 }
1379
1380 fn merge_local(&mut self, local_toml: &str) {
1381 let local: Config = match toml::from_str(local_toml) {
1382 Ok(c) => c,
1383 Err(e) => {
1384 tracing::warn!("local config parse error: {e}");
1385 eprintln!(
1386 "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n \
1387 Local overrides skipped.\x1b[0m"
1388 );
1389 return;
1390 }
1391 };
1392 if local.ultra_compact {
1393 self.ultra_compact = true;
1394 }
1395 if local.tee_mode != TeeMode::default() {
1396 self.tee_mode = local.tee_mode;
1397 }
1398 if local.output_density != OutputDensity::default() {
1399 self.output_density = local.output_density;
1400 }
1401 if local.checkpoint_interval != 15 {
1402 self.checkpoint_interval = local.checkpoint_interval;
1403 }
1404 if !local.excluded_commands.is_empty() {
1405 self.excluded_commands.extend(local.excluded_commands);
1406 }
1407 if !local.passthrough_urls.is_empty() {
1408 self.passthrough_urls.extend(local.passthrough_urls);
1409 }
1410 if !local.custom_aliases.is_empty() {
1411 self.custom_aliases.extend(local.custom_aliases);
1412 }
1413 if local.slow_command_threshold_ms != 5000 {
1414 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
1415 }
1416 if local.theme != "default" {
1417 self.theme = local.theme;
1418 }
1419 if !local.buddy_enabled {
1420 self.buddy_enabled = false;
1421 }
1422 if !local.enable_wakeup_ctx {
1423 self.enable_wakeup_ctx = false;
1424 }
1425 if !local.redirect_exclude.is_empty() {
1426 self.redirect_exclude.extend(local.redirect_exclude);
1427 }
1428 if !local.disabled_tools.is_empty() {
1429 self.disabled_tools.extend(local.disabled_tools);
1430 }
1431 if !local.extra_ignore_patterns.is_empty() {
1432 self.extra_ignore_patterns
1433 .extend(local.extra_ignore_patterns);
1434 }
1435 if local.rules_scope.is_some() {
1436 self.rules_scope = local.rules_scope;
1437 }
1438 if local.proxy.anthropic_upstream.is_some() {
1439 self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
1440 }
1441 if local.proxy.openai_upstream.is_some() {
1442 self.proxy.openai_upstream = local.proxy.openai_upstream;
1443 }
1444 if local.proxy.gemini_upstream.is_some() {
1445 self.proxy.gemini_upstream = local.proxy.gemini_upstream;
1446 }
1447 if !local.autonomy.enabled {
1448 self.autonomy.enabled = false;
1449 }
1450 if !local.autonomy.auto_preload {
1451 self.autonomy.auto_preload = false;
1452 }
1453 if !local.autonomy.auto_dedup {
1454 self.autonomy.auto_dedup = false;
1455 }
1456 if !local.autonomy.auto_related {
1457 self.autonomy.auto_related = false;
1458 }
1459 if !local.autonomy.auto_consolidate {
1460 self.autonomy.auto_consolidate = false;
1461 }
1462 if local.autonomy.silent_preload {
1463 self.autonomy.silent_preload = true;
1464 }
1465 if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1466 self.autonomy.silent_preload = false;
1467 }
1468 if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1469 self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1470 }
1471 if local.autonomy.consolidate_every_calls
1472 != AutonomyConfig::default().consolidate_every_calls
1473 {
1474 self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1475 }
1476 if local.autonomy.consolidate_cooldown_secs
1477 != AutonomyConfig::default().consolidate_cooldown_secs
1478 {
1479 self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1480 }
1481 if !local.autonomy.cognition_loop_enabled {
1482 self.autonomy.cognition_loop_enabled = false;
1483 }
1484 if local.autonomy.cognition_loop_interval_secs
1485 != AutonomyConfig::default().cognition_loop_interval_secs
1486 {
1487 self.autonomy.cognition_loop_interval_secs =
1488 local.autonomy.cognition_loop_interval_secs;
1489 }
1490 if local.autonomy.cognition_loop_max_steps
1491 != AutonomyConfig::default().cognition_loop_max_steps
1492 {
1493 self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
1494 }
1495 if local_toml.contains("compression_level") {
1496 self.compression_level = local.compression_level;
1497 }
1498 if local_toml.contains("terse_agent") {
1499 self.terse_agent = local.terse_agent;
1500 }
1501 if !local.archive.enabled {
1502 self.archive.enabled = false;
1503 }
1504 if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1505 self.archive.threshold_chars = local.archive.threshold_chars;
1506 }
1507 if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1508 self.archive.max_age_hours = local.archive.max_age_hours;
1509 }
1510 if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1511 self.archive.max_disk_mb = local.archive.max_disk_mb;
1512 }
1513 let mem_def = MemoryPolicy::default();
1514 if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1515 self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1516 }
1517 if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1518 self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1519 }
1520 if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1521 self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1522 }
1523 if local.memory.knowledge.contradiction_threshold
1524 != mem_def.knowledge.contradiction_threshold
1525 {
1526 self.memory.knowledge.contradiction_threshold =
1527 local.memory.knowledge.contradiction_threshold;
1528 }
1529
1530 if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1531 self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1532 }
1533 if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1534 {
1535 self.memory.episodic.max_actions_per_episode =
1536 local.memory.episodic.max_actions_per_episode;
1537 }
1538 if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1539 self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1540 }
1541
1542 if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1543 self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1544 }
1545 if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1546 self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1547 }
1548 if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1549 self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1550 }
1551 if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1552 self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1553 }
1554
1555 if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1556 self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1557 }
1558 if local.memory.lifecycle.low_confidence_threshold
1559 != mem_def.lifecycle.low_confidence_threshold
1560 {
1561 self.memory.lifecycle.low_confidence_threshold =
1562 local.memory.lifecycle.low_confidence_threshold;
1563 }
1564 if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1565 self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1566 }
1567 if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1568 self.memory.lifecycle.similarity_threshold =
1569 local.memory.lifecycle.similarity_threshold;
1570 }
1571
1572 if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1573 self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1574 }
1575 if !local.allow_paths.is_empty() {
1576 self.allow_paths.extend(local.allow_paths);
1577 }
1578 if local.minimal_overhead {
1579 self.minimal_overhead = true;
1580 }
1581 if local.shell_hook_disabled {
1582 self.shell_hook_disabled = true;
1583 }
1584 if local.shell_activation != ShellActivation::default() {
1585 self.shell_activation = local.shell_activation.clone();
1586 }
1587 if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1588 self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1589 }
1590 if local.memory_profile != MemoryProfile::default() {
1591 self.memory_profile = local.memory_profile;
1592 }
1593 if local.memory_cleanup != MemoryCleanup::default() {
1594 self.memory_cleanup = local.memory_cleanup;
1595 }
1596 if !local.shell_allowlist.is_empty() {
1597 self.shell_allowlist = local.shell_allowlist;
1598 }
1599 }
1600
1601 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1603 let path = Self::path().ok_or_else(|| {
1604 super::error::LeanCtxError::Config("cannot determine home directory".into())
1605 })?;
1606 if let Some(parent) = path.parent() {
1607 std::fs::create_dir_all(parent)?;
1608 }
1609 let content = toml::to_string_pretty(self)
1610 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1611 std::fs::write(&path, content)?;
1612 Ok(())
1613 }
1614
1615 pub fn show(&self) -> String {
1617 let global_path = Self::path().map_or_else(
1618 || "~/.lean-ctx/config.toml".to_string(),
1619 |p| p.to_string_lossy().to_string(),
1620 );
1621 let content = toml::to_string_pretty(self).unwrap_or_default();
1622 let mut out = format!("Global config: {global_path}\n\n{content}");
1623
1624 if let Some(root) = Self::find_project_root() {
1625 let local = Self::local_path(&root);
1626 if local.exists() {
1627 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1628 } else {
1629 out.push_str(&format!(
1630 "\n\nLocal config: not found (create {} to override per-project)\n",
1631 local.display()
1632 ));
1633 }
1634 }
1635 out
1636 }
1637}
1638
1639#[cfg(test)]
1640mod compression_level_tests {
1641 use super::*;
1642
1643 #[test]
1644 fn default_is_standard() {
1645 assert_eq!(CompressionLevel::default(), CompressionLevel::Standard);
1646 }
1647
1648 #[test]
1649 fn to_components_off() {
1650 let (ta, od, crp, tm) = CompressionLevel::Off.to_components();
1651 assert_eq!(ta, TerseAgent::Off);
1652 assert_eq!(od, OutputDensity::Normal);
1653 assert_eq!(crp, "off");
1654 assert!(!tm);
1655 }
1656
1657 #[test]
1658 fn to_components_lite() {
1659 let (ta, od, crp, tm) = CompressionLevel::Lite.to_components();
1660 assert_eq!(ta, TerseAgent::Lite);
1661 assert_eq!(od, OutputDensity::Terse);
1662 assert_eq!(crp, "off");
1663 assert!(tm);
1664 }
1665
1666 #[test]
1667 fn to_components_standard() {
1668 let (ta, od, crp, tm) = CompressionLevel::Standard.to_components();
1669 assert_eq!(ta, TerseAgent::Full);
1670 assert_eq!(od, OutputDensity::Terse);
1671 assert_eq!(crp, "compact");
1672 assert!(tm);
1673 }
1674
1675 #[test]
1676 fn to_components_max() {
1677 let (ta, od, crp, tm) = CompressionLevel::Max.to_components();
1678 assert_eq!(ta, TerseAgent::Ultra);
1679 assert_eq!(od, OutputDensity::Ultra);
1680 assert_eq!(crp, "tdd");
1681 assert!(tm);
1682 }
1683
1684 #[test]
1685 fn from_legacy_ultra_agent_maps_to_max() {
1686 assert_eq!(
1687 CompressionLevel::from_legacy(&TerseAgent::Ultra, &OutputDensity::Normal),
1688 CompressionLevel::Max
1689 );
1690 }
1691
1692 #[test]
1693 fn from_legacy_ultra_density_maps_to_max() {
1694 assert_eq!(
1695 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Ultra),
1696 CompressionLevel::Max
1697 );
1698 }
1699
1700 #[test]
1701 fn from_legacy_full_agent_maps_to_standard() {
1702 assert_eq!(
1703 CompressionLevel::from_legacy(&TerseAgent::Full, &OutputDensity::Normal),
1704 CompressionLevel::Standard
1705 );
1706 }
1707
1708 #[test]
1709 fn from_legacy_lite_agent_maps_to_lite() {
1710 assert_eq!(
1711 CompressionLevel::from_legacy(&TerseAgent::Lite, &OutputDensity::Normal),
1712 CompressionLevel::Lite
1713 );
1714 }
1715
1716 #[test]
1717 fn from_legacy_terse_density_maps_to_lite() {
1718 assert_eq!(
1719 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Terse),
1720 CompressionLevel::Lite
1721 );
1722 }
1723
1724 #[test]
1725 fn from_legacy_both_off_maps_to_off() {
1726 assert_eq!(
1727 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Normal),
1728 CompressionLevel::Off
1729 );
1730 }
1731
1732 #[test]
1733 fn labels_match() {
1734 assert_eq!(CompressionLevel::Off.label(), "off");
1735 assert_eq!(CompressionLevel::Lite.label(), "lite");
1736 assert_eq!(CompressionLevel::Standard.label(), "standard");
1737 assert_eq!(CompressionLevel::Max.label(), "max");
1738 }
1739
1740 #[test]
1741 fn is_active_false_for_off() {
1742 assert!(!CompressionLevel::Off.is_active());
1743 }
1744
1745 #[test]
1746 fn is_active_true_for_all_others() {
1747 assert!(CompressionLevel::Lite.is_active());
1748 assert!(CompressionLevel::Standard.is_active());
1749 assert!(CompressionLevel::Max.is_active());
1750 }
1751
1752 #[test]
1753 fn deserialization_defaults_to_standard() {
1754 let cfg: Config = toml::from_str("").unwrap();
1755 assert_eq!(cfg.compression_level, CompressionLevel::Standard);
1756 }
1757
1758 #[test]
1759 fn deserialization_from_toml() {
1760 let cfg: Config = toml::from_str(r#"compression_level = "standard""#).unwrap();
1761 assert_eq!(cfg.compression_level, CompressionLevel::Standard);
1762 }
1763
1764 #[test]
1765 fn roundtrip_all_levels() {
1766 for level in [
1767 CompressionLevel::Off,
1768 CompressionLevel::Lite,
1769 CompressionLevel::Standard,
1770 CompressionLevel::Max,
1771 ] {
1772 let (ta, od, crp, tm) = level.to_components();
1773 assert!(!crp.is_empty());
1774 if level == CompressionLevel::Off {
1775 assert!(!tm);
1776 assert_eq!(ta, TerseAgent::Off);
1777 assert_eq!(od, OutputDensity::Normal);
1778 } else {
1779 assert!(tm);
1780 }
1781 }
1782 }
1783}
1784
1785#[cfg(test)]
1786mod memory_cleanup_tests {
1787 use super::*;
1788
1789 #[test]
1790 fn default_is_aggressive() {
1791 assert_eq!(MemoryCleanup::default(), MemoryCleanup::Aggressive);
1792 }
1793
1794 #[test]
1795 fn aggressive_ttl_is_300() {
1796 assert_eq!(MemoryCleanup::Aggressive.idle_ttl_secs(), 300);
1797 }
1798
1799 #[test]
1800 fn shared_ttl_is_1800() {
1801 assert_eq!(MemoryCleanup::Shared.idle_ttl_secs(), 1800);
1802 }
1803
1804 #[test]
1805 fn index_retention_multiplier_values() {
1806 assert!(
1807 (MemoryCleanup::Aggressive.index_retention_multiplier() - 1.0).abs() < f64::EPSILON
1808 );
1809 assert!((MemoryCleanup::Shared.index_retention_multiplier() - 3.0).abs() < f64::EPSILON);
1810 }
1811
1812 #[test]
1813 fn deserialization_defaults_to_aggressive() {
1814 let cfg: Config = toml::from_str("").unwrap();
1815 assert_eq!(cfg.memory_cleanup, MemoryCleanup::Aggressive);
1816 }
1817
1818 #[test]
1819 fn deserialization_from_toml() {
1820 let cfg: Config = toml::from_str(r#"memory_cleanup = "shared""#).unwrap();
1821 assert_eq!(cfg.memory_cleanup, MemoryCleanup::Shared);
1822 }
1823
1824 #[test]
1825 fn effective_uses_config_when_no_env() {
1826 let cfg = Config {
1827 memory_cleanup: MemoryCleanup::Shared,
1828 ..Default::default()
1829 };
1830 let eff = MemoryCleanup::effective(&cfg);
1831 assert_eq!(eff, MemoryCleanup::Shared);
1832 }
1833}