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