Skip to main content

nms_copilot/
config.rs

1//! Configuration file support for NMS Copilot.
2//!
3//! Config file location: `~/.nms-copilot/config.toml`
4//!
5//! All fields are optional -- sensible defaults are used when not specified.
6
7use 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/// Top-level configuration.
17#[derive(Debug, Deserialize, Default)]
18#[serde(default)]
19pub struct Config {
20    /// Save file configuration.
21    pub save: SaveConfig,
22
23    /// Display preferences.
24    pub display: DisplayConfig,
25
26    /// Default values for commands.
27    pub defaults: DefaultsConfig,
28
29    /// Cache settings.
30    pub cache: CacheConfig,
31
32    /// File watcher settings.
33    pub watch: WatchConfig,
34
35    /// MCP server settings.
36    pub mcp: McpConfig,
37}
38
39/// Save file location and format.
40#[derive(Debug, Deserialize)]
41#[serde(default)]
42pub struct SaveConfig {
43    /// DEPRECATED: Old combined path. Treated as `file` for backward compat.
44    pub path: Option<PathBuf>,
45
46    /// Path to the NMS save directory (account dir containing `save*.hg`).
47    pub dir: Option<PathBuf>,
48
49    /// Path to a specific NMS save file.
50    pub file: Option<PathBuf>,
51
52    /// Save format: "auto", "raw", "goatfungus".
53    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/// Display preferences.
68#[derive(Debug, Deserialize)]
69#[serde(default)]
70pub struct DisplayConfig {
71    /// Use emoji for portal glyphs (true) or hex digits (false).
72    pub emoji_glyphs: bool,
73
74    /// Enable ANSI color output.
75    pub color: bool,
76
77    /// Table border style.
78    pub table_style: String,
79
80    /// Custom banner text. `None` = use default embedded banner.
81    /// Empty string = disable banner.
82    pub banner: Option<String>,
83
84    /// Whether to show the art banner at startup (default: true).
85    pub show_banner: bool,
86
87    /// Whether to show the system info line after the banner (default: true).
88    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/// Default values for commands.
105#[derive(Debug, Deserialize)]
106#[serde(default)]
107pub struct DefaultsConfig {
108    /// Default galaxy index (0 = Euclid).
109    pub galaxy: u8,
110
111    /// Default warp range in light-years for routing.
112    pub warp_range: Option<f64>,
113
114    /// Default TSP algorithm: "nearest-neighbor" or "2opt".
115    pub tsp_algorithm: String,
116
117    /// Default number of results for find.
118    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/// Cache settings.
133#[derive(Debug, Deserialize)]
134#[serde(default)]
135pub struct CacheConfig {
136    /// Enable caching (default: true).
137    pub enabled: bool,
138
139    /// Cache file path (default: ~/.nms-copilot/galaxy.rkyv).
140    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/// File watcher settings.
153#[derive(Debug, Deserialize)]
154#[serde(default)]
155pub struct WatchConfig {
156    /// Enable file watching (default: true).
157    pub enabled: bool,
158    /// Debounce duration in milliseconds (default: 500).
159    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/// MCP server settings.
172#[derive(Debug, Deserialize)]
173#[serde(default)]
174pub struct McpConfig {
175    /// Host/IP address for the MCP HTTP server.
176    pub host: IpAddr,
177
178    /// Port for the MCP HTTP server.
179    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    /// Load config from the default path (`~/.nms-copilot/config.toml`).
193    ///
194    /// Returns the default config if the file doesn't exist.
195    /// Returns an error if the file exists but can't be parsed.
196    pub fn load() -> Result<Self, ConfigError> {
197        let path = paths::config_path();
198        Self::load_from(&path)
199    }
200
201    /// Load config from a specific path.
202    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    /// Apply environment variable overrides to the config.
214    ///
215    /// Reads:
216    /// - `NMS_SAVE_DIR` -> `self.save.dir`
217    /// - `NMS_SAVE_FILE` -> `self.save.file`
218    /// - `NMS_SAVE_FORMAT` -> `self.save.format`
219    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    /// Resolve the effective save file path from all configured sources.
232    ///
233    /// Priority:
234    /// 1. `save.file` (explicit file path)
235    /// 2. `save.path` if it points to a file (backward compat)
236    /// 3. `save.dir` — find most recent save in that directory
237    /// 4. `save.path` if it points to a directory (backward compat)
238    /// 5. `None` if nothing is configured or resolvable
239    pub fn effective_save_file(&self) -> Option<PathBuf> {
240        // 1. Explicit file
241        if let Some(ref file) = self.save.file {
242            return Some(file.clone());
243        }
244
245        // 2. Legacy path as file
246        if let Some(ref path) = self.save.path
247            && path.is_file()
248        {
249            return Some(path.clone());
250        }
251
252        // 3. Explicit dir — find most recent save in it
253        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        // 4. Legacy path as directory
260        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    /// Resolve the effective cache path.
271    ///
272    /// If `cache.path` is explicitly set in config, uses that (single override).
273    /// Otherwise, derives a per-save cache path from the save file path:
274    /// `~/.nms-copilot/<account_dir>/<save_stem>/galaxy.rkyv`.
275    /// Falls back to the legacy `~/.nms-copilot/galaxy.rkyv` if no save path given.
276    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    /// Resolve the effective save path (if configured).
287    ///
288    /// Delegates to [`effective_save_file`]. For backward compatibility,
289    /// also checks the legacy `save.path` field.
290    pub fn save_path(&self) -> Option<PathBuf> {
291        self.effective_save_file()
292    }
293
294    /// Whether caching is enabled.
295    pub fn cache_enabled(&self) -> bool {
296        self.cache.enabled
297    }
298
299    /// Whether file watching is enabled.
300    pub fn watch_enabled(&self) -> bool {
301        self.watch.enabled
302    }
303
304    /// The configured debounce duration for file watching.
305    pub fn watch_debounce(&self) -> Duration {
306        Duration::from_millis(self.watch.debounce_ms)
307    }
308
309    /// The configured MCP HTTP bind address.
310    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/// Config loading errors.
327#[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        // Legacy path should be None
441        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        // show_banner defaults to true when not specified
649        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        // banner field defaults to None
671        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        // show_banner defaults to true independently
683        assert!(config.display.show_banner);
684    }
685}