1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3use std::sync::Mutex;
4use std::time::SystemTime;
5
6#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
8#[serde(rename_all = "lowercase")]
9pub enum TeeMode {
10 Never,
11 #[default]
12 Failures,
13 Always,
14}
15
16#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
18#[serde(rename_all = "lowercase")]
19pub enum TerseAgent {
20 #[default]
21 Off,
22 Lite,
23 Full,
24 Ultra,
25}
26
27impl TerseAgent {
28 pub fn from_env() -> Self {
30 match std::env::var("LEAN_CTX_TERSE_AGENT")
31 .unwrap_or_default()
32 .to_lowercase()
33 .as_str()
34 {
35 "lite" => Self::Lite,
36 "full" => Self::Full,
37 "ultra" => Self::Ultra,
38 _ => Self::Off,
39 }
40 }
41
42 pub fn effective(config_val: &TerseAgent) -> Self {
44 match std::env::var("LEAN_CTX_TERSE_AGENT") {
45 Ok(val) if !val.is_empty() => match val.to_lowercase().as_str() {
46 "lite" => Self::Lite,
47 "full" => Self::Full,
48 "ultra" => Self::Ultra,
49 _ => Self::Off,
50 },
51 _ => config_val.clone(),
52 }
53 }
54
55 pub fn is_active(&self) -> bool {
57 !matches!(self, Self::Off)
58 }
59}
60
61#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
63#[serde(rename_all = "lowercase")]
64pub enum OutputDensity {
65 #[default]
66 Normal,
67 Terse,
68 Ultra,
69}
70
71impl OutputDensity {
72 pub fn from_env() -> Self {
74 match std::env::var("LEAN_CTX_OUTPUT_DENSITY")
75 .unwrap_or_default()
76 .to_lowercase()
77 .as_str()
78 {
79 "terse" => Self::Terse,
80 "ultra" => Self::Ultra,
81 _ => Self::Normal,
82 }
83 }
84
85 pub fn effective(config_val: &OutputDensity) -> Self {
87 let env_val = Self::from_env();
88 if env_val != Self::Normal {
89 return env_val;
90 }
91 let profile_val = crate::core::profiles::active_profile()
92 .compression
93 .output_density
94 .to_lowercase();
95 let profile_density = match profile_val.as_str() {
96 "terse" => Self::Terse,
97 "ultra" => Self::Ultra,
98 _ => Self::Normal,
99 };
100 if profile_density != Self::Normal {
101 return profile_density;
102 }
103 config_val.clone()
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109#[serde(default)]
110pub struct Config {
111 pub ultra_compact: bool,
112 #[serde(default, deserialize_with = "deserialize_tee_mode")]
113 pub tee_mode: TeeMode,
114 #[serde(default)]
115 pub output_density: OutputDensity,
116 pub checkpoint_interval: u32,
117 pub excluded_commands: Vec<String>,
118 pub passthrough_urls: Vec<String>,
119 pub custom_aliases: Vec<AliasEntry>,
120 pub slow_command_threshold_ms: u64,
123 #[serde(default = "default_theme")]
124 pub theme: String,
125 #[serde(default)]
126 pub cloud: CloudConfig,
127 #[serde(default)]
128 pub autonomy: AutonomyConfig,
129 #[serde(default = "default_buddy_enabled")]
130 pub buddy_enabled: bool,
131 #[serde(default)]
132 pub redirect_exclude: Vec<String>,
133 #[serde(default)]
137 pub disabled_tools: Vec<String>,
138 #[serde(default)]
139 pub loop_detection: LoopDetectionConfig,
140 #[serde(default)]
144 pub rules_scope: Option<String>,
145 #[serde(default)]
148 pub extra_ignore_patterns: Vec<String>,
149 #[serde(default)]
153 pub terse_agent: TerseAgent,
154 #[serde(default)]
156 pub archive: ArchiveConfig,
157 #[serde(default)]
161 pub allow_paths: Vec<String>,
162 #[serde(default)]
165 pub content_defined_chunking: bool,
166 #[serde(default)]
169 pub minimal_overhead: bool,
170 #[serde(default)]
173 pub shell_hook_disabled: bool,
174 #[serde(default)]
177 pub update_check_disabled: bool,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
182#[serde(default)]
183pub struct ArchiveConfig {
184 pub enabled: bool,
185 pub threshold_chars: usize,
186 pub max_age_hours: u64,
187 pub max_disk_mb: u64,
188}
189
190impl Default for ArchiveConfig {
191 fn default() -> Self {
192 Self {
193 enabled: true,
194 threshold_chars: 4096,
195 max_age_hours: 48,
196 max_disk_mb: 500,
197 }
198 }
199}
200
201fn default_buddy_enabled() -> bool {
202 true
203}
204
205fn deserialize_tee_mode<'de, D>(deserializer: D) -> Result<TeeMode, D::Error>
206where
207 D: serde::Deserializer<'de>,
208{
209 use serde::de::Error;
210 let v = serde_json::Value::deserialize(deserializer)?;
211 match &v {
212 serde_json::Value::Bool(true) => Ok(TeeMode::Failures),
213 serde_json::Value::Bool(false) => Ok(TeeMode::Never),
214 serde_json::Value::String(s) => match s.as_str() {
215 "never" => Ok(TeeMode::Never),
216 "failures" => Ok(TeeMode::Failures),
217 "always" => Ok(TeeMode::Always),
218 other => Err(D::Error::custom(format!("unknown tee_mode: {other}"))),
219 },
220 _ => Err(D::Error::custom("tee_mode must be string or bool")),
221 }
222}
223
224fn default_theme() -> String {
225 "default".to_string()
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
230#[serde(default)]
231pub struct AutonomyConfig {
232 pub enabled: bool,
233 pub auto_preload: bool,
234 pub auto_dedup: bool,
235 pub auto_related: bool,
236 pub auto_consolidate: bool,
237 pub silent_preload: bool,
238 pub dedup_threshold: usize,
239 pub consolidate_every_calls: u32,
240 pub consolidate_cooldown_secs: u64,
241}
242
243impl Default for AutonomyConfig {
244 fn default() -> Self {
245 Self {
246 enabled: true,
247 auto_preload: true,
248 auto_dedup: true,
249 auto_related: true,
250 auto_consolidate: true,
251 silent_preload: true,
252 dedup_threshold: 8,
253 consolidate_every_calls: 25,
254 consolidate_cooldown_secs: 120,
255 }
256 }
257}
258
259impl AutonomyConfig {
260 pub fn from_env() -> Self {
262 let mut cfg = Self::default();
263 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
264 if v == "false" || v == "0" {
265 cfg.enabled = false;
266 }
267 }
268 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
269 cfg.auto_preload = v != "false" && v != "0";
270 }
271 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
272 cfg.auto_dedup = v != "false" && v != "0";
273 }
274 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
275 cfg.auto_related = v != "false" && v != "0";
276 }
277 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
278 cfg.auto_consolidate = v != "false" && v != "0";
279 }
280 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
281 cfg.silent_preload = v != "false" && v != "0";
282 }
283 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
284 if let Ok(n) = v.parse() {
285 cfg.dedup_threshold = n;
286 }
287 }
288 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
289 if let Ok(n) = v.parse() {
290 cfg.consolidate_every_calls = n;
291 }
292 }
293 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
294 if let Ok(n) = v.parse() {
295 cfg.consolidate_cooldown_secs = n;
296 }
297 }
298 cfg
299 }
300
301 pub fn load() -> Self {
303 let file_cfg = Config::load().autonomy;
304 let mut cfg = file_cfg;
305 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
306 if v == "false" || v == "0" {
307 cfg.enabled = false;
308 }
309 }
310 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
311 cfg.auto_preload = v != "false" && v != "0";
312 }
313 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
314 cfg.auto_dedup = v != "false" && v != "0";
315 }
316 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
317 cfg.auto_related = v != "false" && v != "0";
318 }
319 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
320 cfg.silent_preload = v != "false" && v != "0";
321 }
322 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
323 if let Ok(n) = v.parse() {
324 cfg.dedup_threshold = n;
325 }
326 }
327 cfg
328 }
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize, Default)]
333#[serde(default)]
334pub struct CloudConfig {
335 pub contribute_enabled: bool,
336 pub last_contribute: Option<String>,
337 pub last_sync: Option<String>,
338 pub last_gain_sync: Option<String>,
339 pub last_model_pull: Option<String>,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct AliasEntry {
345 pub command: String,
346 pub alias: String,
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize)]
351#[serde(default)]
352pub struct LoopDetectionConfig {
353 pub normal_threshold: u32,
354 pub reduced_threshold: u32,
355 pub blocked_threshold: u32,
356 pub window_secs: u64,
357 pub search_group_limit: u32,
358}
359
360impl Default for LoopDetectionConfig {
361 fn default() -> Self {
362 Self {
363 normal_threshold: 2,
364 reduced_threshold: 4,
365 blocked_threshold: 6,
366 window_secs: 300,
367 search_group_limit: 10,
368 }
369 }
370}
371
372impl Default for Config {
373 fn default() -> Self {
374 Self {
375 ultra_compact: false,
376 tee_mode: TeeMode::default(),
377 output_density: OutputDensity::default(),
378 checkpoint_interval: 15,
379 excluded_commands: Vec::new(),
380 passthrough_urls: Vec::new(),
381 custom_aliases: Vec::new(),
382 slow_command_threshold_ms: 5000,
383 theme: default_theme(),
384 cloud: CloudConfig::default(),
385 autonomy: AutonomyConfig::default(),
386 buddy_enabled: default_buddy_enabled(),
387 redirect_exclude: Vec::new(),
388 disabled_tools: Vec::new(),
389 loop_detection: LoopDetectionConfig::default(),
390 rules_scope: None,
391 extra_ignore_patterns: Vec::new(),
392 terse_agent: TerseAgent::default(),
393 archive: ArchiveConfig::default(),
394 allow_paths: Vec::new(),
395 content_defined_chunking: false,
396 minimal_overhead: false,
397 shell_hook_disabled: false,
398 update_check_disabled: false,
399 }
400 }
401}
402
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
405pub enum RulesScope {
406 Both,
407 Global,
408 Project,
409}
410
411impl Config {
412 pub fn rules_scope_effective(&self) -> RulesScope {
414 let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
415 .ok()
416 .or_else(|| self.rules_scope.clone())
417 .unwrap_or_default();
418 match raw.trim().to_lowercase().as_str() {
419 "global" => RulesScope::Global,
420 "project" => RulesScope::Project,
421 _ => RulesScope::Both,
422 }
423 }
424
425 fn parse_disabled_tools_env(val: &str) -> Vec<String> {
426 val.split(',')
427 .map(|s| s.trim().to_string())
428 .filter(|s| !s.is_empty())
429 .collect()
430 }
431
432 pub fn disabled_tools_effective(&self) -> Vec<String> {
434 if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
435 Self::parse_disabled_tools_env(&val)
436 } else {
437 self.disabled_tools.clone()
438 }
439 }
440
441 pub fn minimal_overhead_effective(&self) -> bool {
443 std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
444 }
445
446 pub fn shell_hook_disabled_effective(&self) -> bool {
448 std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
449 }
450
451 pub fn update_check_disabled_effective(&self) -> bool {
453 std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
454 }
455}
456
457#[cfg(test)]
458mod disabled_tools_tests {
459 use super::*;
460
461 #[test]
462 fn config_field_default_is_empty() {
463 let cfg = Config::default();
464 assert!(cfg.disabled_tools.is_empty());
465 }
466
467 #[test]
468 fn effective_returns_config_field_when_no_env_var() {
469 if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
471 return;
472 }
473 let cfg = Config {
474 disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
475 ..Default::default()
476 };
477 assert_eq!(
478 cfg.disabled_tools_effective(),
479 vec!["ctx_graph", "ctx_agent"]
480 );
481 }
482
483 #[test]
484 fn parse_env_basic() {
485 let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
486 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
487 }
488
489 #[test]
490 fn parse_env_trims_whitespace_and_skips_empty() {
491 let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
492 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
493 }
494
495 #[test]
496 fn parse_env_single_entry() {
497 let result = Config::parse_disabled_tools_env("ctx_graph");
498 assert_eq!(result, vec!["ctx_graph"]);
499 }
500
501 #[test]
502 fn parse_env_empty_string_returns_empty() {
503 let result = Config::parse_disabled_tools_env("");
504 assert!(result.is_empty());
505 }
506
507 #[test]
508 fn disabled_tools_deserialization_defaults_to_empty() {
509 let cfg: Config = toml::from_str("").unwrap();
510 assert!(cfg.disabled_tools.is_empty());
511 }
512
513 #[test]
514 fn disabled_tools_deserialization_from_toml() {
515 let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
516 assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
517 }
518}
519
520#[cfg(test)]
521mod rules_scope_tests {
522 use super::*;
523
524 #[test]
525 fn default_is_both() {
526 let cfg = Config::default();
527 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
528 }
529
530 #[test]
531 fn config_global() {
532 let cfg = Config {
533 rules_scope: Some("global".to_string()),
534 ..Default::default()
535 };
536 assert_eq!(cfg.rules_scope_effective(), RulesScope::Global);
537 }
538
539 #[test]
540 fn config_project() {
541 let cfg = Config {
542 rules_scope: Some("project".to_string()),
543 ..Default::default()
544 };
545 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
546 }
547
548 #[test]
549 fn unknown_value_falls_back_to_both() {
550 let cfg = Config {
551 rules_scope: Some("nonsense".to_string()),
552 ..Default::default()
553 };
554 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
555 }
556
557 #[test]
558 fn deserialization_none_by_default() {
559 let cfg: Config = toml::from_str("").unwrap();
560 assert!(cfg.rules_scope.is_none());
561 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
562 }
563
564 #[test]
565 fn deserialization_from_toml() {
566 let cfg: Config = toml::from_str(r#"rules_scope = "project""#).unwrap();
567 assert_eq!(cfg.rules_scope.as_deref(), Some("project"));
568 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
569 }
570}
571
572#[cfg(test)]
573mod loop_detection_config_tests {
574 use super::*;
575
576 #[test]
577 fn defaults_are_reasonable() {
578 let cfg = LoopDetectionConfig::default();
579 assert_eq!(cfg.normal_threshold, 2);
580 assert_eq!(cfg.reduced_threshold, 4);
581 assert_eq!(cfg.blocked_threshold, 6);
582 assert_eq!(cfg.window_secs, 300);
583 assert_eq!(cfg.search_group_limit, 10);
584 }
585
586 #[test]
587 fn deserialization_defaults_when_missing() {
588 let cfg: Config = toml::from_str("").unwrap();
589 assert_eq!(cfg.loop_detection.blocked_threshold, 6);
590 assert_eq!(cfg.loop_detection.search_group_limit, 10);
591 }
592
593 #[test]
594 fn deserialization_from_toml() {
595 let cfg: Config = toml::from_str(
596 r"
597 [loop_detection]
598 normal_threshold = 1
599 reduced_threshold = 3
600 blocked_threshold = 5
601 window_secs = 120
602 search_group_limit = 8
603 ",
604 )
605 .unwrap();
606 assert_eq!(cfg.loop_detection.normal_threshold, 1);
607 assert_eq!(cfg.loop_detection.reduced_threshold, 3);
608 assert_eq!(cfg.loop_detection.blocked_threshold, 5);
609 assert_eq!(cfg.loop_detection.window_secs, 120);
610 assert_eq!(cfg.loop_detection.search_group_limit, 8);
611 }
612
613 #[test]
614 fn partial_override_keeps_defaults() {
615 let cfg: Config = toml::from_str(
616 r"
617 [loop_detection]
618 blocked_threshold = 10
619 ",
620 )
621 .unwrap();
622 assert_eq!(cfg.loop_detection.blocked_threshold, 10);
623 assert_eq!(cfg.loop_detection.normal_threshold, 2);
624 assert_eq!(cfg.loop_detection.search_group_limit, 10);
625 }
626}
627
628impl Config {
629 pub fn path() -> Option<PathBuf> {
631 crate::core::data_dir::lean_ctx_data_dir()
632 .ok()
633 .map(|d| d.join("config.toml"))
634 }
635
636 pub fn local_path(project_root: &str) -> PathBuf {
638 PathBuf::from(project_root).join(".lean-ctx.toml")
639 }
640
641 fn find_project_root() -> Option<String> {
642 let cwd = std::env::current_dir().ok();
643
644 if let Some(root) =
645 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
646 {
647 let root_path = std::path::Path::new(&root);
648 let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
649 let has_marker = root_path.join(".git").exists()
650 || root_path.join("Cargo.toml").exists()
651 || root_path.join("package.json").exists()
652 || root_path.join("go.mod").exists()
653 || root_path.join("pyproject.toml").exists()
654 || root_path.join(".lean-ctx.toml").exists();
655
656 if cwd_is_under_root || has_marker {
657 return Some(root);
658 }
659 }
660
661 if let Some(ref cwd) = cwd {
662 let git_root = std::process::Command::new("git")
663 .args(["rev-parse", "--show-toplevel"])
664 .current_dir(cwd)
665 .stdout(std::process::Stdio::piped())
666 .stderr(std::process::Stdio::null())
667 .output()
668 .ok()
669 .and_then(|o| {
670 if o.status.success() {
671 String::from_utf8(o.stdout)
672 .ok()
673 .map(|s| s.trim().to_string())
674 } else {
675 None
676 }
677 });
678 if let Some(root) = git_root {
679 return Some(root);
680 }
681 return Some(cwd.to_string_lossy().to_string());
682 }
683 None
684 }
685
686 pub fn load() -> Self {
688 static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
689
690 let Some(path) = Self::path() else {
691 return Self::default();
692 };
693
694 let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
695
696 let mtime = std::fs::metadata(&path)
697 .and_then(|m| m.modified())
698 .unwrap_or(SystemTime::UNIX_EPOCH);
699
700 let local_mtime = local_path
701 .as_ref()
702 .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
703
704 if let Ok(guard) = CACHE.lock() {
705 if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
706 if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
707 return cfg.clone();
708 }
709 }
710 }
711
712 let mut cfg: Config = match std::fs::read_to_string(&path) {
713 Ok(content) => toml::from_str(&content).unwrap_or_default(),
714 Err(_) => Self::default(),
715 };
716
717 if let Some(ref lp) = local_path {
718 if let Ok(local_content) = std::fs::read_to_string(lp) {
719 cfg.merge_local(&local_content);
720 }
721 }
722
723 if let Ok(mut guard) = CACHE.lock() {
724 *guard = Some((cfg.clone(), mtime, local_mtime));
725 }
726
727 cfg
728 }
729
730 fn merge_local(&mut self, local_toml: &str) {
731 let local: Config = match toml::from_str(local_toml) {
732 Ok(c) => c,
733 Err(_) => return,
734 };
735 if local.ultra_compact {
736 self.ultra_compact = true;
737 }
738 if local.tee_mode != TeeMode::default() {
739 self.tee_mode = local.tee_mode;
740 }
741 if local.output_density != OutputDensity::default() {
742 self.output_density = local.output_density;
743 }
744 if local.checkpoint_interval != 15 {
745 self.checkpoint_interval = local.checkpoint_interval;
746 }
747 if !local.excluded_commands.is_empty() {
748 self.excluded_commands.extend(local.excluded_commands);
749 }
750 if !local.passthrough_urls.is_empty() {
751 self.passthrough_urls.extend(local.passthrough_urls);
752 }
753 if !local.custom_aliases.is_empty() {
754 self.custom_aliases.extend(local.custom_aliases);
755 }
756 if local.slow_command_threshold_ms != 5000 {
757 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
758 }
759 if local.theme != "default" {
760 self.theme = local.theme;
761 }
762 if !local.buddy_enabled {
763 self.buddy_enabled = false;
764 }
765 if !local.redirect_exclude.is_empty() {
766 self.redirect_exclude.extend(local.redirect_exclude);
767 }
768 if !local.disabled_tools.is_empty() {
769 self.disabled_tools.extend(local.disabled_tools);
770 }
771 if !local.extra_ignore_patterns.is_empty() {
772 self.extra_ignore_patterns
773 .extend(local.extra_ignore_patterns);
774 }
775 if local.rules_scope.is_some() {
776 self.rules_scope = local.rules_scope;
777 }
778 if !local.autonomy.enabled {
779 self.autonomy.enabled = false;
780 }
781 if !local.autonomy.auto_preload {
782 self.autonomy.auto_preload = false;
783 }
784 if !local.autonomy.auto_dedup {
785 self.autonomy.auto_dedup = false;
786 }
787 if !local.autonomy.auto_related {
788 self.autonomy.auto_related = false;
789 }
790 if !local.autonomy.auto_consolidate {
791 self.autonomy.auto_consolidate = false;
792 }
793 if local.autonomy.silent_preload {
794 self.autonomy.silent_preload = true;
795 }
796 if !local.autonomy.silent_preload && self.autonomy.silent_preload {
797 self.autonomy.silent_preload = false;
798 }
799 if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
800 self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
801 }
802 if local.autonomy.consolidate_every_calls
803 != AutonomyConfig::default().consolidate_every_calls
804 {
805 self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
806 }
807 if local.autonomy.consolidate_cooldown_secs
808 != AutonomyConfig::default().consolidate_cooldown_secs
809 {
810 self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
811 }
812 if local.terse_agent != TerseAgent::default() {
813 self.terse_agent = local.terse_agent;
814 }
815 if !local.archive.enabled {
816 self.archive.enabled = false;
817 }
818 if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
819 self.archive.threshold_chars = local.archive.threshold_chars;
820 }
821 if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
822 self.archive.max_age_hours = local.archive.max_age_hours;
823 }
824 if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
825 self.archive.max_disk_mb = local.archive.max_disk_mb;
826 }
827 if !local.allow_paths.is_empty() {
828 self.allow_paths.extend(local.allow_paths);
829 }
830 if local.minimal_overhead {
831 self.minimal_overhead = true;
832 }
833 if local.shell_hook_disabled {
834 self.shell_hook_disabled = true;
835 }
836 }
837
838 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
840 let path = Self::path().ok_or_else(|| {
841 super::error::LeanCtxError::Config("cannot determine home directory".into())
842 })?;
843 if let Some(parent) = path.parent() {
844 std::fs::create_dir_all(parent)?;
845 }
846 let content = toml::to_string_pretty(self)
847 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
848 std::fs::write(&path, content)?;
849 Ok(())
850 }
851
852 pub fn show(&self) -> String {
854 let global_path = Self::path().map_or_else(
855 || "~/.lean-ctx/config.toml".to_string(),
856 |p| p.to_string_lossy().to_string(),
857 );
858 let content = toml::to_string_pretty(self).unwrap_or_default();
859 let mut out = format!("Global config: {global_path}\n\n{content}");
860
861 if let Some(root) = Self::find_project_root() {
862 let local = Self::local_path(&root);
863 if local.exists() {
864 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
865 } else {
866 out.push_str(&format!(
867 "\n\nLocal config: not found (create {} to override per-project)\n",
868 local.display()
869 ));
870 }
871 }
872 out
873 }
874}