1use std::fs;
8use std::net::{IpAddr, Ipv4Addr, SocketAddr};
9use std::path::{Path, PathBuf};
10use std::time::Duration;
11
12use serde::Deserialize;
13
14use crate::paths;
15
16#[derive(Debug, Deserialize, Default)]
18#[serde(default)]
19pub struct Config {
20 pub save: SaveConfig,
22
23 pub display: DisplayConfig,
25
26 pub defaults: DefaultsConfig,
28
29 pub cache: CacheConfig,
31
32 pub watch: WatchConfig,
34
35 pub mcp: McpConfig,
37}
38
39#[derive(Debug, Deserialize)]
41#[serde(default)]
42pub struct SaveConfig {
43 pub path: Option<PathBuf>,
45
46 pub dir: Option<PathBuf>,
48
49 pub file: Option<PathBuf>,
51
52 pub format: String,
54}
55
56impl Default for SaveConfig {
57 fn default() -> Self {
58 Self {
59 path: None,
60 dir: None,
61 file: None,
62 format: "auto".into(),
63 }
64 }
65}
66
67#[derive(Debug, Deserialize)]
69#[serde(default)]
70pub struct DisplayConfig {
71 pub emoji_glyphs: bool,
73
74 pub color: bool,
76
77 pub table_style: String,
79
80 pub banner: Option<String>,
83
84 pub show_banner: bool,
86
87 pub show_system_banner: bool,
89}
90
91impl Default for DisplayConfig {
92 fn default() -> Self {
93 Self {
94 emoji_glyphs: true,
95 color: true,
96 table_style: "rounded".into(),
97 banner: None,
98 show_banner: true,
99 show_system_banner: true,
100 }
101 }
102}
103
104#[derive(Debug, Deserialize)]
106#[serde(default)]
107pub struct DefaultsConfig {
108 pub galaxy: u8,
110
111 pub warp_range: Option<f64>,
113
114 pub tsp_algorithm: String,
116
117 pub find_limit: Option<usize>,
119}
120
121impl Default for DefaultsConfig {
122 fn default() -> Self {
123 Self {
124 galaxy: 0,
125 warp_range: None,
126 tsp_algorithm: "2opt".into(),
127 find_limit: None,
128 }
129 }
130}
131
132#[derive(Debug, Deserialize)]
134#[serde(default)]
135pub struct CacheConfig {
136 pub enabled: bool,
138
139 pub path: Option<PathBuf>,
141}
142
143impl Default for CacheConfig {
144 fn default() -> Self {
145 Self {
146 enabled: true,
147 path: None,
148 }
149 }
150}
151
152#[derive(Debug, Deserialize)]
154#[serde(default)]
155pub struct WatchConfig {
156 pub enabled: bool,
158 pub debounce_ms: u64,
160}
161
162impl Default for WatchConfig {
163 fn default() -> Self {
164 Self {
165 enabled: true,
166 debounce_ms: 500,
167 }
168 }
169}
170
171#[derive(Debug, Deserialize)]
173#[serde(default)]
174pub struct McpConfig {
175 pub host: IpAddr,
177
178 pub port: u16,
180}
181
182impl Default for McpConfig {
183 fn default() -> Self {
184 Self {
185 host: IpAddr::V4(Ipv4Addr::LOCALHOST),
186 port: 5099,
187 }
188 }
189}
190
191impl Config {
192 pub fn load() -> Result<Self, ConfigError> {
197 let path = paths::config_path();
198 Self::load_from(&path)
199 }
200
201 pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
203 let mut config = if !path.exists() {
204 Self::default()
205 } else {
206 let content = fs::read_to_string(path).map_err(ConfigError::Io)?;
207 parse_config(&content).map_err(ConfigError::Parse)?
208 };
209 config.apply_env_overrides();
210 Ok(config)
211 }
212
213 pub fn apply_env_overrides(&mut self) {
220 if let Ok(val) = std::env::var("NMS_SAVE_DIR") {
221 self.save.dir = Some(PathBuf::from(val));
222 }
223 if let Ok(val) = std::env::var("NMS_SAVE_FILE") {
224 self.save.file = Some(PathBuf::from(val));
225 }
226 if let Ok(val) = std::env::var("NMS_SAVE_FORMAT") {
227 self.save.format = val;
228 }
229 }
230
231 pub fn effective_save_file(&self) -> Option<PathBuf> {
240 if let Some(ref file) = self.save.file {
242 return Some(file.clone());
243 }
244
245 if let Some(ref path) = self.save.path
247 && path.is_file()
248 {
249 return Some(path.clone());
250 }
251
252 if let Some(ref dir) = self.save.dir
254 && let Ok(save) = nms_save::locate::find_most_recent_save_in(dir)
255 {
256 return Some(save.path().to_path_buf());
257 }
258
259 if let Some(ref path) = self.save.path
261 && path.is_dir()
262 && let Ok(save) = nms_save::locate::find_most_recent_save_in(path)
263 {
264 return Some(save.path().to_path_buf());
265 }
266
267 None
268 }
269
270 pub fn cache_path_for(&self, save_path: Option<&std::path::Path>) -> PathBuf {
277 if let Some(p) = &self.cache.path {
278 return p.clone();
279 }
280 match save_path {
281 Some(sp) => paths::cache_path_for_save(sp),
282 None => paths::cache_path(),
283 }
284 }
285
286 pub fn save_path(&self) -> Option<PathBuf> {
291 self.effective_save_file()
292 }
293
294 pub fn cache_enabled(&self) -> bool {
296 self.cache.enabled
297 }
298
299 pub fn watch_enabled(&self) -> bool {
301 self.watch.enabled
302 }
303
304 pub fn watch_debounce(&self) -> Duration {
306 Duration::from_millis(self.watch.debounce_ms)
307 }
308
309 pub fn mcp_http_addr(&self) -> SocketAddr {
311 SocketAddr::new(self.mcp.host, self.mcp.port)
312 }
313}
314
315fn parse_config(content: &str) -> Result<Config, toml::de::Error> {
316 match toml::from_str(content) {
317 Ok(config) => Ok(config),
318 Err(document_error) => content
319 .parse::<toml::Value>()
320 .ok()
321 .and_then(|value| value.try_into().ok())
322 .ok_or(document_error),
323 }
324}
325
326#[derive(Debug)]
328pub enum ConfigError {
329 Io(std::io::Error),
330 Parse(toml::de::Error),
331}
332
333impl std::fmt::Display for ConfigError {
334 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335 match self {
336 Self::Io(e) => write!(f, "config I/O error: {e}"),
337 Self::Parse(e) => write!(f, "config parse error: {e}"),
338 }
339 }
340}
341
342impl std::error::Error for ConfigError {
343 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
344 match self {
345 Self::Io(e) => Some(e),
346 Self::Parse(e) => Some(e),
347 }
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 #[test]
356 fn test_default_config() {
357 let config = Config::default();
358 assert!(config.display.emoji_glyphs);
359 assert!(config.display.color);
360 assert_eq!(config.defaults.galaxy, 0);
361 assert!(config.cache.enabled);
362 assert!(config.save.path.is_none());
363 assert!(config.save.dir.is_none());
364 assert!(config.save.file.is_none());
365 assert_eq!(config.mcp_http_addr().to_string(), "127.0.0.1:5099");
366 }
367
368 #[test]
369 fn test_parse_minimal_config() {
370 let toml = "";
371 let config: Config = toml::from_str(toml).unwrap();
372 assert!(config.display.emoji_glyphs);
373 }
374
375 #[test]
376 fn test_parse_full_config() {
377 let toml = r#"
378 [save]
379 path = "/Users/test/NMS"
380 format = "raw"
381
382 [display]
383 emoji_glyphs = false
384 color = false
385 table_style = "ascii"
386 banner = "My Custom Banner"
387 show_banner = false
388 show_system_banner = false
389
390 [defaults]
391 galaxy = 1
392 warp_range = 2500.0
393 tsp_algorithm = "nearest-neighbor"
394 find_limit = 10
395
396 [cache]
397 enabled = false
398 path = "/tmp/nms-cache.rkyv"
399
400 [mcp]
401 host = "127.0.0.1"
402 port = 5055
403 "#;
404 let config: Config = toml::from_str(toml).unwrap();
405 assert_eq!(
406 config.save.path.as_deref().unwrap().to_str().unwrap(),
407 "/Users/test/NMS"
408 );
409 assert_eq!(config.save.format, "raw");
410 assert!(!config.display.emoji_glyphs);
411 assert!(!config.display.color);
412 assert_eq!(config.display.banner.as_deref(), Some("My Custom Banner"));
413 assert!(!config.display.show_banner);
414 assert!(!config.display.show_system_banner);
415 assert_eq!(config.defaults.galaxy, 1);
416 assert_eq!(config.defaults.warp_range, Some(2500.0));
417 assert_eq!(config.defaults.find_limit, Some(10));
418 assert!(!config.cache.enabled);
419 assert_eq!(config.mcp_http_addr().to_string(), "127.0.0.1:5055");
420 }
421
422 #[test]
423 fn test_parse_config_with_new_save_fields() {
424 let toml = r#"
425 [save]
426 dir = "/Users/test/NMS/st_123"
427 file = "/Users/test/NMS/st_123/save.hg"
428 format = "raw"
429 "#;
430 let config: Config = toml::from_str(toml).unwrap();
431 assert_eq!(
432 config.save.dir.as_deref().unwrap().to_str().unwrap(),
433 "/Users/test/NMS/st_123"
434 );
435 assert_eq!(
436 config.save.file.as_deref().unwrap().to_str().unwrap(),
437 "/Users/test/NMS/st_123/save.hg"
438 );
439 assert_eq!(config.save.format, "raw");
440 assert!(config.save.path.is_none());
442 }
443
444 #[test]
445 fn test_parse_inline_save_with_mcp_config() {
446 let toml = r#"
447 save = { dir = "/Users/test/NMS/st_123", file = "/Users/test/NMS/st_123/save.hg", format = "auto" }
448
449 [mcp]
450 host = "127.0.0.1"
451 port = 5055
452 "#;
453 let config: Config = toml::from_str(toml).unwrap();
454 assert_eq!(
455 config.save.file.as_deref().unwrap().to_str().unwrap(),
456 "/Users/test/NMS/st_123/save.hg"
457 );
458 assert_eq!(config.mcp_http_addr().to_string(), "127.0.0.1:5055");
459 }
460
461 #[test]
462 fn test_parse_legacy_inline_value_save_config() {
463 let toml = r#"{ save = { dir = "/Users/test/NMS/st_123", file = "/Users/test/NMS/st_123/save.hg", format = "auto" } }"#;
464 let config = parse_config(toml).unwrap();
465 assert_eq!(
466 config.save.file.as_deref().unwrap().to_str().unwrap(),
467 "/Users/test/NMS/st_123/save.hg"
468 );
469 }
470
471 #[test]
472 fn test_parse_config_backward_compat_path_only() {
473 let toml = r#"
474 [save]
475 path = "/Users/test/NMS/save.hg"
476 "#;
477 let config: Config = toml::from_str(toml).unwrap();
478 assert!(config.save.path.is_some());
479 assert!(config.save.dir.is_none());
480 assert!(config.save.file.is_none());
481 }
482
483 #[test]
484 fn test_effective_save_file_prefers_file_over_path() {
485 let dir = tempfile::tempdir().unwrap();
486 let save_file = dir.path().join("save.hg");
487 let legacy_file = dir.path().join("legacy.hg");
488 fs::write(&save_file, b"data").unwrap();
489 fs::write(&legacy_file, b"data").unwrap();
490
491 let mut config = Config::default();
492 config.save.file = Some(save_file.clone());
493 config.save.path = Some(legacy_file);
494
495 assert_eq!(config.effective_save_file(), Some(save_file));
496 }
497
498 #[test]
499 fn test_effective_save_file_falls_back_to_path_file() {
500 let dir = tempfile::tempdir().unwrap();
501 let save_file = dir.path().join("save.hg");
502 fs::write(&save_file, b"data").unwrap();
503
504 let mut config = Config::default();
505 config.save.path = Some(save_file.clone());
506
507 assert_eq!(config.effective_save_file(), Some(save_file));
508 }
509
510 #[test]
511 fn test_effective_save_file_dir_with_saves() {
512 let dir = tempfile::tempdir().unwrap();
513 fs::write(dir.path().join("save.hg"), b"data").unwrap();
514
515 let mut config = Config::default();
516 config.save.dir = Some(dir.path().to_path_buf());
517
518 let result = config.effective_save_file();
519 assert!(result.is_some());
520 assert!(result.unwrap().ends_with("save.hg"));
521 }
522
523 #[test]
524 fn test_effective_save_file_none_when_empty() {
525 let config = Config::default();
526 assert!(config.effective_save_file().is_none());
527 }
528
529 #[test]
530 fn test_parse_partial_config() {
531 let toml = r#"
532 [defaults]
533 warp_range = 1500.0
534 "#;
535 let config: Config = toml::from_str(toml).unwrap();
536 assert!(config.display.emoji_glyphs);
537 assert!(config.cache.enabled);
538 assert_eq!(config.defaults.warp_range, Some(1500.0));
539 }
540
541 #[test]
542 fn test_load_nonexistent_returns_default() {
543 let config = Config::load_from(Path::new("/nonexistent/config.toml")).unwrap();
544 assert!(config.display.emoji_glyphs);
545 }
546
547 #[test]
548 fn test_load_invalid_toml_errors() {
549 let dir = tempfile::tempdir().unwrap();
550 let path = dir.path().join("bad.toml");
551 fs::write(&path, "not valid toml [[[").unwrap();
552 assert!(Config::load_from(&path).is_err());
553 }
554
555 #[test]
556 fn test_cache_path_default_no_save() {
557 let config = Config::default();
558 let path = config.cache_path_for(None);
559 assert!(path.ends_with("galaxy.rkyv"));
560 }
561
562 #[test]
563 fn test_cache_path_per_save() {
564 let config = Config::default();
565 let save = Path::new("/nms/st_12345/save3.hg");
566 let path = config.cache_path_for(Some(save));
567 assert!(path.ends_with("st_12345/save3/galaxy.rkyv"));
568 }
569
570 #[test]
571 fn test_cache_path_override() {
572 let toml = r#"
573 [cache]
574 path = "/tmp/custom-cache.rkyv"
575 "#;
576 let config: Config = toml::from_str(toml).unwrap();
577 assert_eq!(
578 config.cache_path_for(Some(Path::new("/nms/st_99/save.hg"))),
579 PathBuf::from("/tmp/custom-cache.rkyv")
580 );
581 }
582
583 #[test]
584 fn test_save_path_none_when_unset() {
585 let config = Config::default();
586 assert!(config.save_path().is_none());
587 }
588
589 #[test]
590 fn test_unknown_fields_are_ignored() {
591 let toml = r#"
592 [save]
593 path = "/tmp"
594 unknown_field = "ignored"
595 "#;
596 let config: Config = toml::from_str(toml).unwrap();
597 assert!(config.save.path.is_some());
598 }
599
600 #[test]
601 fn test_watch_config_defaults() {
602 let config = Config::default();
603 assert!(config.watch_enabled());
604 assert_eq!(config.watch_debounce(), Duration::from_millis(500));
605 }
606
607 #[test]
608 fn test_watch_config_from_toml() {
609 let toml = r#"
610 [watch]
611 enabled = false
612 debounce_ms = 1000
613 "#;
614 let config: Config = toml::from_str(toml).unwrap();
615 assert!(!config.watch_enabled());
616 assert_eq!(config.watch_debounce(), Duration::from_millis(1000));
617 }
618
619 #[test]
620 fn test_watch_config_partial_toml() {
621 let toml = r#"
622 [watch]
623 debounce_ms = 250
624 "#;
625 let config: Config = toml::from_str(toml).unwrap();
626 assert!(config.watch_enabled());
627 assert_eq!(config.watch_debounce(), Duration::from_millis(250));
628 }
629
630 #[test]
631 fn test_mcp_config_partial_toml() {
632 let toml = r#"
633 [mcp]
634 port = 5055
635 "#;
636 let config: Config = toml::from_str(toml).unwrap();
637 assert_eq!(config.mcp_http_addr().to_string(), "127.0.0.1:5055");
638 }
639
640 #[test]
641 fn test_parse_config_banner_custom_text() {
642 let toml = r#"
643 [display]
644 banner = "Welcome to NMS!"
645 "#;
646 let config: Config = toml::from_str(toml).unwrap();
647 assert_eq!(config.display.banner.as_deref(), Some("Welcome to NMS!"));
648 assert!(config.display.show_banner);
650 }
651
652 #[test]
653 fn test_parse_config_banner_empty_disables() {
654 let toml = r#"
655 [display]
656 banner = ""
657 "#;
658 let config: Config = toml::from_str(toml).unwrap();
659 assert_eq!(config.display.banner.as_deref(), Some(""));
660 }
661
662 #[test]
663 fn test_parse_config_show_banner_false() {
664 let toml = r#"
665 [display]
666 show_banner = false
667 "#;
668 let config: Config = toml::from_str(toml).unwrap();
669 assert!(!config.display.show_banner);
670 assert!(config.display.banner.is_none());
672 }
673
674 #[test]
675 fn test_parse_config_show_system_banner_false() {
676 let toml = r#"
677 [display]
678 show_system_banner = false
679 "#;
680 let config: Config = toml::from_str(toml).unwrap();
681 assert!(!config.display.show_system_banner);
682 assert!(config.display.show_banner);
684 }
685}