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