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