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 #[serde(default)]
526 pub mcp_bridges: std::collections::HashMap<String, McpBridgeEntry>,
527}
528
529impl Default for ProvidersConfig {
530 fn default() -> Self {
531 Self {
532 enabled: true,
533 github: ProviderEntryConfig::default(),
534 gitlab: ProviderEntryConfig::default(),
535 auto_index: true,
536 cache_ttl_secs: 120,
537 mcp_bridges: std::collections::HashMap::new(),
538 }
539 }
540}
541
542#[derive(Debug, Clone, Serialize, Deserialize)]
543pub struct McpBridgeEntry {
544 #[serde(default)]
546 pub url: Option<String>,
547 #[serde(default)]
549 pub command: Option<String>,
550 #[serde(default)]
552 pub args: Vec<String>,
553 #[serde(default)]
555 pub description: Option<String>,
556 #[serde(default)]
558 pub auth_env: Option<String>,
559}
560
561#[derive(Debug, Clone, Serialize, Deserialize)]
563#[serde(default)]
564pub struct ProviderEntryConfig {
565 pub enabled: bool,
567 pub token: Option<String>,
569 pub api_url: Option<String>,
571 pub project: Option<String>,
573}
574
575impl Default for ProviderEntryConfig {
576 fn default() -> Self {
577 Self {
578 enabled: true,
579 token: None,
580 api_url: None,
581 project: None,
582 }
583 }
584}
585
586#[derive(Debug, Clone, Serialize, Deserialize)]
588#[serde(default)]
589pub struct AutonomyConfig {
590 pub enabled: bool,
591 pub auto_preload: bool,
592 pub auto_dedup: bool,
593 pub auto_related: bool,
594 pub auto_consolidate: bool,
595 pub silent_preload: bool,
596 pub dedup_threshold: usize,
597 pub consolidate_every_calls: u32,
598 pub consolidate_cooldown_secs: u64,
599 #[serde(default = "serde_defaults::default_true")]
600 pub cognition_loop_enabled: bool,
601 #[serde(default = "serde_defaults::default_cognition_loop_interval")]
602 pub cognition_loop_interval_secs: u64,
603 #[serde(default = "serde_defaults::default_cognition_loop_max_steps")]
604 pub cognition_loop_max_steps: u8,
605}
606
607impl Default for AutonomyConfig {
608 fn default() -> Self {
609 Self {
610 enabled: true,
611 auto_preload: true,
612 auto_dedup: true,
613 auto_related: true,
614 auto_consolidate: true,
615 silent_preload: true,
616 dedup_threshold: 8,
617 consolidate_every_calls: 25,
618 consolidate_cooldown_secs: 120,
619 cognition_loop_enabled: true,
620 cognition_loop_interval_secs: 3600,
621 cognition_loop_max_steps: 8,
622 }
623 }
624}
625
626#[derive(Debug, Clone, Serialize, Deserialize)]
629#[serde(default)]
630pub struct UpdatesConfig {
631 pub auto_update: bool,
632 pub check_interval_hours: u64,
633 pub notify_only: bool,
634}
635
636impl Default for UpdatesConfig {
637 fn default() -> Self {
638 Self {
639 auto_update: false,
640 check_interval_hours: 6,
641 notify_only: false,
642 }
643 }
644}
645
646impl UpdatesConfig {
647 pub fn from_env() -> Self {
648 let mut cfg = Self::default();
649 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_UPDATE") {
650 cfg.auto_update = v == "1" || v.eq_ignore_ascii_case("true");
651 }
652 if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_INTERVAL_HOURS") {
653 if let Ok(h) = v.parse::<u64>() {
654 cfg.check_interval_hours = h.clamp(1, 168);
655 }
656 }
657 if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_NOTIFY_ONLY") {
658 cfg.notify_only = v == "1" || v.eq_ignore_ascii_case("true");
659 }
660 cfg
661 }
662}
663
664impl AutonomyConfig {
665 pub fn from_env() -> Self {
667 let mut cfg = Self::default();
668 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
669 if v == "false" || v == "0" {
670 cfg.enabled = false;
671 }
672 }
673 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
674 cfg.auto_preload = v != "false" && v != "0";
675 }
676 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
677 cfg.auto_dedup = v != "false" && v != "0";
678 }
679 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
680 cfg.auto_related = v != "false" && v != "0";
681 }
682 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
683 cfg.auto_consolidate = v != "false" && v != "0";
684 }
685 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
686 cfg.silent_preload = v != "false" && v != "0";
687 }
688 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
689 if let Ok(n) = v.parse() {
690 cfg.dedup_threshold = n;
691 }
692 }
693 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
694 if let Ok(n) = v.parse() {
695 cfg.consolidate_every_calls = n;
696 }
697 }
698 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
699 if let Ok(n) = v.parse() {
700 cfg.consolidate_cooldown_secs = n;
701 }
702 }
703 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
704 cfg.cognition_loop_enabled = v != "false" && v != "0";
705 }
706 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
707 if let Ok(n) = v.parse() {
708 cfg.cognition_loop_interval_secs = n;
709 }
710 }
711 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
712 if let Ok(n) = v.parse() {
713 cfg.cognition_loop_max_steps = n;
714 }
715 }
716 cfg
717 }
718
719 pub fn load() -> Self {
721 let file_cfg = Config::load().autonomy;
722 let mut cfg = file_cfg;
723 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
724 if v == "false" || v == "0" {
725 cfg.enabled = false;
726 }
727 }
728 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
729 cfg.auto_preload = v != "false" && v != "0";
730 }
731 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
732 cfg.auto_dedup = v != "false" && v != "0";
733 }
734 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
735 cfg.auto_related = v != "false" && v != "0";
736 }
737 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
738 cfg.silent_preload = v != "false" && v != "0";
739 }
740 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
741 if let Ok(n) = v.parse() {
742 cfg.dedup_threshold = n;
743 }
744 }
745 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
746 cfg.cognition_loop_enabled = v != "false" && v != "0";
747 }
748 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
749 if let Ok(n) = v.parse() {
750 cfg.cognition_loop_interval_secs = n;
751 }
752 }
753 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
754 if let Ok(n) = v.parse() {
755 cfg.cognition_loop_max_steps = n;
756 }
757 }
758 cfg
759 }
760}
761
762#[derive(Debug, Clone, Serialize, Deserialize, Default)]
764#[serde(default)]
765pub struct CloudConfig {
766 pub contribute_enabled: bool,
767 pub last_contribute: Option<String>,
768 pub last_sync: Option<String>,
769 pub last_gain_sync: Option<String>,
770 pub last_model_pull: Option<String>,
771}
772
773#[derive(Debug, Clone, Serialize, Deserialize)]
775pub struct AliasEntry {
776 pub command: String,
777 pub alias: String,
778}
779
780#[derive(Debug, Clone, Serialize, Deserialize)]
782#[serde(default)]
783pub struct LoopDetectionConfig {
784 pub normal_threshold: u32,
785 pub reduced_threshold: u32,
786 pub blocked_threshold: u32,
787 pub window_secs: u64,
788 pub search_group_limit: u32,
789 pub tool_total_limits: HashMap<String, u32>,
790}
791
792impl Default for LoopDetectionConfig {
793 fn default() -> Self {
794 let mut tool_total_limits = HashMap::new();
795 tool_total_limits.insert("ctx_read".to_string(), 100);
796 tool_total_limits.insert("ctx_search".to_string(), 80);
797 tool_total_limits.insert("ctx_shell".to_string(), 50);
798 tool_total_limits.insert("ctx_semantic_search".to_string(), 60);
799 Self {
800 normal_threshold: 2,
801 reduced_threshold: 4,
802 blocked_threshold: 0,
803 window_secs: 300,
804 search_group_limit: 10,
805 tool_total_limits,
806 }
807 }
808}
809
810impl Default for Config {
811 fn default() -> Self {
812 Self {
813 ultra_compact: false,
814 tee_mode: TeeMode::default(),
815 output_density: OutputDensity::default(),
816 checkpoint_interval: 15,
817 excluded_commands: Vec::new(),
818 passthrough_urls: Vec::new(),
819 custom_aliases: Vec::new(),
820 slow_command_threshold_ms: 5000,
821 theme: serde_defaults::default_theme(),
822 cloud: CloudConfig::default(),
823 autonomy: AutonomyConfig::default(),
824 providers: ProvidersConfig::default(),
825 proxy: ProxyConfig::default(),
826 proxy_enabled: None,
827 proxy_port: None,
828 buddy_enabled: serde_defaults::default_buddy_enabled(),
829 enable_wakeup_ctx: true,
830 redirect_exclude: Vec::new(),
831 disabled_tools: Vec::new(),
832 loop_detection: LoopDetectionConfig::default(),
833 rules_scope: None,
834 extra_ignore_patterns: Vec::new(),
835 terse_agent: TerseAgent::default(),
836 compression_level: CompressionLevel::default(),
837 archive: ArchiveConfig::default(),
838 memory: MemoryPolicy::default(),
839 allow_paths: Vec::new(),
840 content_defined_chunking: false,
841 minimal_overhead: false,
842 shell_hook_disabled: false,
843 shell_activation: ShellActivation::default(),
844 update_check_disabled: false,
845 updates: UpdatesConfig::default(),
846 graph_index_max_files: serde_defaults::default_graph_index_max_files(),
847 bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
848 memory_profile: MemoryProfile::default(),
849 memory_cleanup: MemoryCleanup::default(),
850 max_ram_percent: serde_defaults::default_max_ram_percent(),
851 savings_footer: SavingsFooter::default(),
852 project_root: None,
853 lsp: std::collections::HashMap::new(),
854 ide_paths: HashMap::new(),
855 model_context_windows: HashMap::new(),
856 response_verbosity: ResponseVerbosity::default(),
857 bypass_hints: None,
858 cache_policy: None,
859 boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
860 secret_detection: SecretDetectionConfig::default(),
861 allow_auto_reroot: false,
862 path_jail: None,
863 sandbox_level: 0,
864 reference_results: false,
865 agent_token_budget: 0,
866 shell_allowlist: default_shell_allowlist(),
867 }
868 }
869}
870
871fn default_shell_allowlist() -> Vec<String> {
872 [
873 "git",
875 "gh",
876 "svn",
877 "cargo",
879 "npm",
880 "npx",
881 "yarn",
882 "pnpm",
883 "bun",
884 "make",
885 "cmake",
886 "pip",
887 "pip3",
888 "poetry",
889 "uv",
890 "go",
891 "mvn",
892 "gradle",
893 "mix",
894 "dotnet",
895 "swift",
896 "zig",
897 "rustup",
898 "rustc",
899 "ls",
901 "cat",
902 "head",
903 "tail",
904 "wc",
905 "sort",
906 "uniq",
907 "tr",
908 "cut",
909 "grep",
910 "rg",
911 "find",
912 "fd",
913 "ag",
914 "ack",
915 "sed",
916 "awk",
917 "echo",
918 "printf",
919 "true",
920 "false",
921 "test",
922 "expr",
923 "cd",
924 "pwd",
925 "basename",
926 "dirname",
927 "realpath",
928 "readlink",
929 "cp",
930 "mv",
931 "mkdir",
932 "rm",
933 "rmdir",
934 "touch",
935 "ln",
936 "chmod",
937 "diff",
938 "patch",
939 "tar",
940 "zip",
941 "unzip",
942 "gzip",
943 "gunzip",
944 "zstd",
945 "curl",
946 "wget",
947 "docker",
949 "docker-compose",
950 "podman",
951 "node",
952 "python",
953 "python3",
954 "ruby",
955 "perl",
956 "java",
957 "javac",
958 "tsc",
959 "eslint",
960 "prettier",
961 "black",
962 "ruff",
963 "clippy",
964 "jq",
965 "yq",
966 "xargs",
967 "env",
968 "which",
969 "type",
970 "file",
971 "stat",
972 "date",
973 "sleep",
974 "timeout",
975 "nice",
976 "ionice",
977 "lean-ctx",
979 ]
980 .iter()
981 .map(|s| (*s).to_string())
982 .collect()
983}
984
985#[derive(Debug, Clone, Copy, PartialEq, Eq)]
987pub enum RulesScope {
988 Both,
989 Global,
990 Project,
991}
992
993impl Config {
994 pub fn rules_scope_effective(&self) -> RulesScope {
996 let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
997 .ok()
998 .or_else(|| self.rules_scope.clone())
999 .unwrap_or_default();
1000 match raw.trim().to_lowercase().as_str() {
1001 "global" => RulesScope::Global,
1002 "project" => RulesScope::Project,
1003 _ => RulesScope::Both,
1004 }
1005 }
1006
1007 fn parse_disabled_tools_env(val: &str) -> Vec<String> {
1008 val.split(',')
1009 .map(|s| s.trim().to_string())
1010 .filter(|s| !s.is_empty())
1011 .collect()
1012 }
1013
1014 pub fn disabled_tools_effective(&self) -> Vec<String> {
1016 if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
1017 Self::parse_disabled_tools_env(&val)
1018 } else {
1019 self.disabled_tools.clone()
1020 }
1021 }
1022
1023 pub fn minimal_overhead_effective(&self) -> bool {
1025 std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
1026 }
1027
1028 pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
1036 if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
1037 match raw.trim().to_lowercase().as_str() {
1038 "minimal" => return true,
1039 "full" => return self.minimal_overhead_effective(),
1040 _ => {}
1041 }
1042 }
1043
1044 if self.minimal_overhead_effective() {
1045 return true;
1046 }
1047
1048 let client_lower = client_name.trim().to_lowercase();
1049 if !client_lower.is_empty() {
1050 if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
1051 for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
1052 if !needle.is_empty() && client_lower.contains(&needle) {
1053 return true;
1054 }
1055 }
1056 } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
1057 return true;
1058 }
1059 }
1060
1061 let model = std::env::var("LEAN_CTX_MODEL")
1062 .or_else(|_| std::env::var("LCTX_MODEL"))
1063 .unwrap_or_default();
1064 let model = model.trim().to_lowercase();
1065 if !model.is_empty() {
1066 let m = model.replace(['_', ' '], "-");
1067 if m.contains("minimax")
1068 || m.contains("mini-max")
1069 || m.contains("m2.7")
1070 || m.contains("m2-7")
1071 {
1072 return true;
1073 }
1074 }
1075
1076 false
1077 }
1078
1079 pub fn shell_hook_disabled_effective(&self) -> bool {
1081 std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
1082 }
1083
1084 pub fn shell_activation_effective(&self) -> ShellActivation {
1086 ShellActivation::effective(self)
1087 }
1088
1089 pub fn update_check_disabled_effective(&self) -> bool {
1091 std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
1092 }
1093
1094 pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
1095 let mut policy = self.memory.clone();
1096 policy.apply_env_overrides();
1097 policy.validate()?;
1098 Ok(policy)
1099 }
1100}
1101
1102#[cfg(test)]
1103mod disabled_tools_tests {
1104 use super::*;
1105
1106 #[test]
1107 fn config_field_default_is_empty() {
1108 let cfg = Config::default();
1109 assert!(cfg.disabled_tools.is_empty());
1110 }
1111
1112 #[test]
1113 fn effective_returns_config_field_when_no_env_var() {
1114 if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
1116 return;
1117 }
1118 let cfg = Config {
1119 disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
1120 ..Default::default()
1121 };
1122 assert_eq!(
1123 cfg.disabled_tools_effective(),
1124 vec!["ctx_graph", "ctx_agent"]
1125 );
1126 }
1127
1128 #[test]
1129 fn parse_env_basic() {
1130 let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
1131 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
1132 }
1133
1134 #[test]
1135 fn parse_env_trims_whitespace_and_skips_empty() {
1136 let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
1137 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
1138 }
1139
1140 #[test]
1141 fn parse_env_single_entry() {
1142 let result = Config::parse_disabled_tools_env("ctx_graph");
1143 assert_eq!(result, vec!["ctx_graph"]);
1144 }
1145
1146 #[test]
1147 fn parse_env_empty_string_returns_empty() {
1148 let result = Config::parse_disabled_tools_env("");
1149 assert!(result.is_empty());
1150 }
1151
1152 #[test]
1153 fn disabled_tools_deserialization_defaults_to_empty() {
1154 let cfg: Config = toml::from_str("").unwrap();
1155 assert!(cfg.disabled_tools.is_empty());
1156 }
1157
1158 #[test]
1159 fn disabled_tools_deserialization_from_toml() {
1160 let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
1161 assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
1162 }
1163}
1164
1165#[cfg(test)]
1166mod rules_scope_tests {
1167 use super::*;
1168
1169 #[test]
1170 fn default_is_both() {
1171 let cfg = Config::default();
1172 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1173 }
1174
1175 #[test]
1176 fn config_global() {
1177 let cfg = Config {
1178 rules_scope: Some("global".to_string()),
1179 ..Default::default()
1180 };
1181 assert_eq!(cfg.rules_scope_effective(), RulesScope::Global);
1182 }
1183
1184 #[test]
1185 fn config_project() {
1186 let cfg = Config {
1187 rules_scope: Some("project".to_string()),
1188 ..Default::default()
1189 };
1190 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1191 }
1192
1193 #[test]
1194 fn unknown_value_falls_back_to_both() {
1195 let cfg = Config {
1196 rules_scope: Some("nonsense".to_string()),
1197 ..Default::default()
1198 };
1199 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1200 }
1201
1202 #[test]
1203 fn deserialization_none_by_default() {
1204 let cfg: Config = toml::from_str("").unwrap();
1205 assert!(cfg.rules_scope.is_none());
1206 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1207 }
1208
1209 #[test]
1210 fn deserialization_from_toml() {
1211 let cfg: Config = toml::from_str(r#"rules_scope = "project""#).unwrap();
1212 assert_eq!(cfg.rules_scope.as_deref(), Some("project"));
1213 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1214 }
1215}
1216
1217#[cfg(test)]
1218mod loop_detection_config_tests {
1219 use super::*;
1220
1221 #[test]
1222 fn defaults_are_reasonable() {
1223 let cfg = LoopDetectionConfig::default();
1224 assert_eq!(cfg.normal_threshold, 2);
1225 assert_eq!(cfg.reduced_threshold, 4);
1226 assert_eq!(cfg.blocked_threshold, 0);
1228 assert_eq!(cfg.window_secs, 300);
1229 assert_eq!(cfg.search_group_limit, 10);
1230 }
1231
1232 #[test]
1233 fn deserialization_defaults_when_missing() {
1234 let cfg: Config = toml::from_str("").unwrap();
1235 assert_eq!(cfg.loop_detection.blocked_threshold, 0);
1237 assert_eq!(cfg.loop_detection.search_group_limit, 10);
1238 }
1239
1240 #[test]
1241 fn deserialization_from_toml() {
1242 let cfg: Config = toml::from_str(
1243 r"
1244 [loop_detection]
1245 normal_threshold = 1
1246 reduced_threshold = 3
1247 blocked_threshold = 5
1248 window_secs = 120
1249 search_group_limit = 8
1250 ",
1251 )
1252 .unwrap();
1253 assert_eq!(cfg.loop_detection.normal_threshold, 1);
1254 assert_eq!(cfg.loop_detection.reduced_threshold, 3);
1255 assert_eq!(cfg.loop_detection.blocked_threshold, 5);
1256 assert_eq!(cfg.loop_detection.window_secs, 120);
1257 assert_eq!(cfg.loop_detection.search_group_limit, 8);
1258 }
1259
1260 #[test]
1261 fn partial_override_keeps_defaults() {
1262 let cfg: Config = toml::from_str(
1263 r"
1264 [loop_detection]
1265 blocked_threshold = 10
1266 ",
1267 )
1268 .unwrap();
1269 assert_eq!(cfg.loop_detection.blocked_threshold, 10);
1270 assert_eq!(cfg.loop_detection.normal_threshold, 2);
1271 assert_eq!(cfg.loop_detection.search_group_limit, 10);
1272 }
1273}
1274
1275impl Config {
1276 pub fn path() -> Option<PathBuf> {
1278 crate::core::data_dir::lean_ctx_data_dir()
1279 .ok()
1280 .map(|d| d.join("config.toml"))
1281 }
1282
1283 pub fn local_path(project_root: &str) -> PathBuf {
1285 PathBuf::from(project_root).join(".lean-ctx.toml")
1286 }
1287
1288 fn find_project_root() -> Option<String> {
1289 static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
1290 ROOT_CACHE
1291 .get_or_init(Self::find_project_root_inner)
1292 .clone()
1293 }
1294
1295 fn find_project_root_inner() -> Option<String> {
1296 if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
1297 if !env_root.is_empty() {
1298 return Some(env_root);
1299 }
1300 }
1301
1302 let cwd = std::env::current_dir().ok();
1303
1304 if let Some(root) =
1305 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
1306 {
1307 let root_path = std::path::Path::new(&root);
1308 let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
1309 let has_marker = root_path.join(".git").exists()
1310 || root_path.join("Cargo.toml").exists()
1311 || root_path.join("package.json").exists()
1312 || root_path.join("go.mod").exists()
1313 || root_path.join("pyproject.toml").exists()
1314 || root_path.join(".lean-ctx.toml").exists();
1315
1316 if cwd_is_under_root || has_marker {
1317 return Some(root);
1318 }
1319 }
1320
1321 if let Some(ref cwd) = cwd {
1322 let git_root = std::process::Command::new("git")
1323 .args(["rev-parse", "--show-toplevel"])
1324 .current_dir(cwd)
1325 .stdout(std::process::Stdio::piped())
1326 .stderr(std::process::Stdio::null())
1327 .output()
1328 .ok()
1329 .and_then(|o| {
1330 if o.status.success() {
1331 String::from_utf8(o.stdout)
1332 .ok()
1333 .map(|s| s.trim().to_string())
1334 } else {
1335 None
1336 }
1337 });
1338 if let Some(root) = git_root {
1339 return Some(root);
1340 }
1341 if !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
1342 return Some(cwd.to_string_lossy().to_string());
1343 }
1344 }
1345 None
1346 }
1347
1348 pub fn load() -> Self {
1350 static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
1351
1352 let Some(path) = Self::path() else {
1353 return Self::default();
1354 };
1355
1356 let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
1357
1358 let mtime = std::fs::metadata(&path)
1359 .and_then(|m| m.modified())
1360 .unwrap_or(SystemTime::UNIX_EPOCH);
1361
1362 let local_mtime = local_path
1363 .as_ref()
1364 .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
1365
1366 if let Ok(guard) = CACHE.lock() {
1367 if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
1368 if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
1369 return cfg.clone();
1370 }
1371 }
1372 }
1373
1374 let mut cfg: Config = match std::fs::read_to_string(&path) {
1375 Ok(content) => match toml::from_str(&content) {
1376 Ok(c) => c,
1377 Err(e) => {
1378 tracing::warn!("config parse error in {}: {e}", path.display());
1379 eprintln!(
1380 "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n \
1381 Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
1382 path.display()
1383 );
1384 Self::default()
1385 }
1386 },
1387 Err(_) => Self::default(),
1388 };
1389
1390 if let Some(ref lp) = local_path {
1391 if let Ok(local_content) = std::fs::read_to_string(lp) {
1392 cfg.merge_local(&local_content);
1393 }
1394 }
1395
1396 if let Ok(mut guard) = CACHE.lock() {
1397 *guard = Some((cfg.clone(), mtime, local_mtime));
1398 }
1399
1400 cfg
1401 }
1402
1403 fn merge_local(&mut self, local_toml: &str) {
1404 let local: Config = match toml::from_str(local_toml) {
1405 Ok(c) => c,
1406 Err(e) => {
1407 tracing::warn!("local config parse error: {e}");
1408 eprintln!(
1409 "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n \
1410 Local overrides skipped.\x1b[0m"
1411 );
1412 return;
1413 }
1414 };
1415 if local.ultra_compact {
1416 self.ultra_compact = true;
1417 }
1418 if local.tee_mode != TeeMode::default() {
1419 self.tee_mode = local.tee_mode;
1420 }
1421 if local.output_density != OutputDensity::default() {
1422 self.output_density = local.output_density;
1423 }
1424 if local.checkpoint_interval != 15 {
1425 self.checkpoint_interval = local.checkpoint_interval;
1426 }
1427 if !local.excluded_commands.is_empty() {
1428 self.excluded_commands.extend(local.excluded_commands);
1429 }
1430 if !local.passthrough_urls.is_empty() {
1431 self.passthrough_urls.extend(local.passthrough_urls);
1432 }
1433 if !local.custom_aliases.is_empty() {
1434 self.custom_aliases.extend(local.custom_aliases);
1435 }
1436 if local.slow_command_threshold_ms != 5000 {
1437 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
1438 }
1439 if local.theme != "default" {
1440 self.theme = local.theme;
1441 }
1442 if !local.buddy_enabled {
1443 self.buddy_enabled = false;
1444 }
1445 if !local.enable_wakeup_ctx {
1446 self.enable_wakeup_ctx = false;
1447 }
1448 if !local.redirect_exclude.is_empty() {
1449 self.redirect_exclude.extend(local.redirect_exclude);
1450 }
1451 if !local.disabled_tools.is_empty() {
1452 self.disabled_tools.extend(local.disabled_tools);
1453 }
1454 if !local.extra_ignore_patterns.is_empty() {
1455 self.extra_ignore_patterns
1456 .extend(local.extra_ignore_patterns);
1457 }
1458 if local.rules_scope.is_some() {
1459 self.rules_scope = local.rules_scope;
1460 }
1461 if local.proxy.anthropic_upstream.is_some() {
1462 self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
1463 }
1464 if local.proxy.openai_upstream.is_some() {
1465 self.proxy.openai_upstream = local.proxy.openai_upstream;
1466 }
1467 if local.proxy.gemini_upstream.is_some() {
1468 self.proxy.gemini_upstream = local.proxy.gemini_upstream;
1469 }
1470 if !local.autonomy.enabled {
1471 self.autonomy.enabled = false;
1472 }
1473 if !local.autonomy.auto_preload {
1474 self.autonomy.auto_preload = false;
1475 }
1476 if !local.autonomy.auto_dedup {
1477 self.autonomy.auto_dedup = false;
1478 }
1479 if !local.autonomy.auto_related {
1480 self.autonomy.auto_related = false;
1481 }
1482 if !local.autonomy.auto_consolidate {
1483 self.autonomy.auto_consolidate = false;
1484 }
1485 if local.autonomy.silent_preload {
1486 self.autonomy.silent_preload = true;
1487 }
1488 if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1489 self.autonomy.silent_preload = false;
1490 }
1491 if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1492 self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1493 }
1494 if local.autonomy.consolidate_every_calls
1495 != AutonomyConfig::default().consolidate_every_calls
1496 {
1497 self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1498 }
1499 if local.autonomy.consolidate_cooldown_secs
1500 != AutonomyConfig::default().consolidate_cooldown_secs
1501 {
1502 self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1503 }
1504 if !local.autonomy.cognition_loop_enabled {
1505 self.autonomy.cognition_loop_enabled = false;
1506 }
1507 if local.autonomy.cognition_loop_interval_secs
1508 != AutonomyConfig::default().cognition_loop_interval_secs
1509 {
1510 self.autonomy.cognition_loop_interval_secs =
1511 local.autonomy.cognition_loop_interval_secs;
1512 }
1513 if local.autonomy.cognition_loop_max_steps
1514 != AutonomyConfig::default().cognition_loop_max_steps
1515 {
1516 self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
1517 }
1518 if local_toml.contains("compression_level") {
1519 self.compression_level = local.compression_level;
1520 }
1521 if local_toml.contains("terse_agent") {
1522 self.terse_agent = local.terse_agent;
1523 }
1524 if !local.archive.enabled {
1525 self.archive.enabled = false;
1526 }
1527 if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1528 self.archive.threshold_chars = local.archive.threshold_chars;
1529 }
1530 if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1531 self.archive.max_age_hours = local.archive.max_age_hours;
1532 }
1533 if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1534 self.archive.max_disk_mb = local.archive.max_disk_mb;
1535 }
1536 let mem_def = MemoryPolicy::default();
1537 if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1538 self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1539 }
1540 if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1541 self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1542 }
1543 if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1544 self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1545 }
1546 if local.memory.knowledge.contradiction_threshold
1547 != mem_def.knowledge.contradiction_threshold
1548 {
1549 self.memory.knowledge.contradiction_threshold =
1550 local.memory.knowledge.contradiction_threshold;
1551 }
1552
1553 if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1554 self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1555 }
1556 if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1557 {
1558 self.memory.episodic.max_actions_per_episode =
1559 local.memory.episodic.max_actions_per_episode;
1560 }
1561 if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1562 self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1563 }
1564
1565 if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1566 self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1567 }
1568 if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1569 self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1570 }
1571 if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1572 self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1573 }
1574 if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1575 self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1576 }
1577
1578 if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1579 self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1580 }
1581 if local.memory.lifecycle.low_confidence_threshold
1582 != mem_def.lifecycle.low_confidence_threshold
1583 {
1584 self.memory.lifecycle.low_confidence_threshold =
1585 local.memory.lifecycle.low_confidence_threshold;
1586 }
1587 if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1588 self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1589 }
1590 if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1591 self.memory.lifecycle.similarity_threshold =
1592 local.memory.lifecycle.similarity_threshold;
1593 }
1594
1595 if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1596 self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1597 }
1598 if !local.allow_paths.is_empty() {
1599 self.allow_paths.extend(local.allow_paths);
1600 }
1601 if local.minimal_overhead {
1602 self.minimal_overhead = true;
1603 }
1604 if local.shell_hook_disabled {
1605 self.shell_hook_disabled = true;
1606 }
1607 if local.shell_activation != ShellActivation::default() {
1608 self.shell_activation = local.shell_activation.clone();
1609 }
1610 if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1611 self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1612 }
1613 if local.memory_profile != MemoryProfile::default() {
1614 self.memory_profile = local.memory_profile;
1615 }
1616 if local.memory_cleanup != MemoryCleanup::default() {
1617 self.memory_cleanup = local.memory_cleanup;
1618 }
1619 if !local.shell_allowlist.is_empty() {
1620 self.shell_allowlist = local.shell_allowlist;
1621 }
1622 }
1623
1624 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1626 let path = Self::path().ok_or_else(|| {
1627 super::error::LeanCtxError::Config("cannot determine home directory".into())
1628 })?;
1629 if let Some(parent) = path.parent() {
1630 std::fs::create_dir_all(parent)?;
1631 }
1632 let content = toml::to_string_pretty(self)
1633 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1634 std::fs::write(&path, content)?;
1635 Ok(())
1636 }
1637
1638 pub fn show(&self) -> String {
1640 let global_path = Self::path().map_or_else(
1641 || "~/.lean-ctx/config.toml".to_string(),
1642 |p| p.to_string_lossy().to_string(),
1643 );
1644 let content = toml::to_string_pretty(self).unwrap_or_default();
1645 let mut out = format!("Global config: {global_path}\n\n{content}");
1646
1647 if let Some(root) = Self::find_project_root() {
1648 let local = Self::local_path(&root);
1649 if local.exists() {
1650 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1651 } else {
1652 out.push_str(&format!(
1653 "\n\nLocal config: not found (create {} to override per-project)\n",
1654 local.display()
1655 ));
1656 }
1657 }
1658 out
1659 }
1660}
1661
1662#[cfg(test)]
1663mod compression_level_tests {
1664 use super::*;
1665
1666 #[test]
1667 fn default_is_standard() {
1668 assert_eq!(CompressionLevel::default(), CompressionLevel::Standard);
1669 }
1670
1671 #[test]
1672 fn to_components_off() {
1673 let (ta, od, crp, tm) = CompressionLevel::Off.to_components();
1674 assert_eq!(ta, TerseAgent::Off);
1675 assert_eq!(od, OutputDensity::Normal);
1676 assert_eq!(crp, "off");
1677 assert!(!tm);
1678 }
1679
1680 #[test]
1681 fn to_components_lite() {
1682 let (ta, od, crp, tm) = CompressionLevel::Lite.to_components();
1683 assert_eq!(ta, TerseAgent::Lite);
1684 assert_eq!(od, OutputDensity::Terse);
1685 assert_eq!(crp, "off");
1686 assert!(tm);
1687 }
1688
1689 #[test]
1690 fn to_components_standard() {
1691 let (ta, od, crp, tm) = CompressionLevel::Standard.to_components();
1692 assert_eq!(ta, TerseAgent::Full);
1693 assert_eq!(od, OutputDensity::Terse);
1694 assert_eq!(crp, "compact");
1695 assert!(tm);
1696 }
1697
1698 #[test]
1699 fn to_components_max() {
1700 let (ta, od, crp, tm) = CompressionLevel::Max.to_components();
1701 assert_eq!(ta, TerseAgent::Ultra);
1702 assert_eq!(od, OutputDensity::Ultra);
1703 assert_eq!(crp, "tdd");
1704 assert!(tm);
1705 }
1706
1707 #[test]
1708 fn from_legacy_ultra_agent_maps_to_max() {
1709 assert_eq!(
1710 CompressionLevel::from_legacy(&TerseAgent::Ultra, &OutputDensity::Normal),
1711 CompressionLevel::Max
1712 );
1713 }
1714
1715 #[test]
1716 fn from_legacy_ultra_density_maps_to_max() {
1717 assert_eq!(
1718 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Ultra),
1719 CompressionLevel::Max
1720 );
1721 }
1722
1723 #[test]
1724 fn from_legacy_full_agent_maps_to_standard() {
1725 assert_eq!(
1726 CompressionLevel::from_legacy(&TerseAgent::Full, &OutputDensity::Normal),
1727 CompressionLevel::Standard
1728 );
1729 }
1730
1731 #[test]
1732 fn from_legacy_lite_agent_maps_to_lite() {
1733 assert_eq!(
1734 CompressionLevel::from_legacy(&TerseAgent::Lite, &OutputDensity::Normal),
1735 CompressionLevel::Lite
1736 );
1737 }
1738
1739 #[test]
1740 fn from_legacy_terse_density_maps_to_lite() {
1741 assert_eq!(
1742 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Terse),
1743 CompressionLevel::Lite
1744 );
1745 }
1746
1747 #[test]
1748 fn from_legacy_both_off_maps_to_off() {
1749 assert_eq!(
1750 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Normal),
1751 CompressionLevel::Off
1752 );
1753 }
1754
1755 #[test]
1756 fn labels_match() {
1757 assert_eq!(CompressionLevel::Off.label(), "off");
1758 assert_eq!(CompressionLevel::Lite.label(), "lite");
1759 assert_eq!(CompressionLevel::Standard.label(), "standard");
1760 assert_eq!(CompressionLevel::Max.label(), "max");
1761 }
1762
1763 #[test]
1764 fn is_active_false_for_off() {
1765 assert!(!CompressionLevel::Off.is_active());
1766 }
1767
1768 #[test]
1769 fn is_active_true_for_all_others() {
1770 assert!(CompressionLevel::Lite.is_active());
1771 assert!(CompressionLevel::Standard.is_active());
1772 assert!(CompressionLevel::Max.is_active());
1773 }
1774
1775 #[test]
1776 fn deserialization_defaults_to_standard() {
1777 let cfg: Config = toml::from_str("").unwrap();
1778 assert_eq!(cfg.compression_level, CompressionLevel::Standard);
1779 }
1780
1781 #[test]
1782 fn deserialization_from_toml() {
1783 let cfg: Config = toml::from_str(r#"compression_level = "standard""#).unwrap();
1784 assert_eq!(cfg.compression_level, CompressionLevel::Standard);
1785 }
1786
1787 #[test]
1788 fn roundtrip_all_levels() {
1789 for level in [
1790 CompressionLevel::Off,
1791 CompressionLevel::Lite,
1792 CompressionLevel::Standard,
1793 CompressionLevel::Max,
1794 ] {
1795 let (ta, od, crp, tm) = level.to_components();
1796 assert!(!crp.is_empty());
1797 if level == CompressionLevel::Off {
1798 assert!(!tm);
1799 assert_eq!(ta, TerseAgent::Off);
1800 assert_eq!(od, OutputDensity::Normal);
1801 } else {
1802 assert!(tm);
1803 }
1804 }
1805 }
1806}
1807
1808#[cfg(test)]
1809mod memory_cleanup_tests {
1810 use super::*;
1811
1812 #[test]
1813 fn default_is_aggressive() {
1814 assert_eq!(MemoryCleanup::default(), MemoryCleanup::Aggressive);
1815 }
1816
1817 #[test]
1818 fn aggressive_ttl_is_300() {
1819 assert_eq!(MemoryCleanup::Aggressive.idle_ttl_secs(), 300);
1820 }
1821
1822 #[test]
1823 fn shared_ttl_is_1800() {
1824 assert_eq!(MemoryCleanup::Shared.idle_ttl_secs(), 1800);
1825 }
1826
1827 #[test]
1828 fn index_retention_multiplier_values() {
1829 assert!(
1830 (MemoryCleanup::Aggressive.index_retention_multiplier() - 1.0).abs() < f64::EPSILON
1831 );
1832 assert!((MemoryCleanup::Shared.index_retention_multiplier() - 3.0).abs() < f64::EPSILON);
1833 }
1834
1835 #[test]
1836 fn deserialization_defaults_to_aggressive() {
1837 let cfg: Config = toml::from_str("").unwrap();
1838 assert_eq!(cfg.memory_cleanup, MemoryCleanup::Aggressive);
1839 }
1840
1841 #[test]
1842 fn deserialization_from_toml() {
1843 let cfg: Config = toml::from_str(r#"memory_cleanup = "shared""#).unwrap();
1844 assert_eq!(cfg.memory_cleanup, MemoryCleanup::Shared);
1845 }
1846
1847 #[test]
1848 fn effective_uses_config_when_no_env() {
1849 let cfg = Config {
1850 memory_cleanup: MemoryCleanup::Shared,
1851 ..Default::default()
1852 };
1853 let eff = MemoryCleanup::effective(&cfg);
1854 assert_eq!(eff, MemoryCleanup::Shared);
1855 }
1856}