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