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