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