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