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::path::{Path, PathBuf};
9
10use serde::Deserialize;
11
12use crate::paths;
13
14/// Top-level configuration.
15#[derive(Debug, Deserialize, Default)]
16#[serde(default)]
17pub struct Config {
18    /// Save file configuration.
19    pub save: SaveConfig,
20
21    /// Display preferences.
22    pub display: DisplayConfig,
23
24    /// Default values for commands.
25    pub defaults: DefaultsConfig,
26
27    /// Cache settings.
28    pub cache: CacheConfig,
29}
30
31/// Save file location and format.
32#[derive(Debug, Deserialize)]
33#[serde(default)]
34pub struct SaveConfig {
35    /// Path to the NMS save directory or specific save file.
36    /// If omitted, auto-detected from platform defaults.
37    pub path: Option<PathBuf>,
38
39    /// Save format: "auto", "raw", "goatfungus".
40    pub format: String,
41}
42
43impl Default for SaveConfig {
44    fn default() -> Self {
45        Self {
46            path: None,
47            format: "auto".into(),
48        }
49    }
50}
51
52/// Display preferences.
53#[derive(Debug, Deserialize)]
54#[serde(default)]
55pub struct DisplayConfig {
56    /// Use emoji for portal glyphs (true) or hex digits (false).
57    pub emoji_glyphs: bool,
58
59    /// Enable ANSI color output.
60    pub color: bool,
61
62    /// Table border style.
63    pub table_style: String,
64}
65
66impl Default for DisplayConfig {
67    fn default() -> Self {
68        Self {
69            emoji_glyphs: true,
70            color: true,
71            table_style: "rounded".into(),
72        }
73    }
74}
75
76/// Default values for commands.
77#[derive(Debug, Deserialize)]
78#[serde(default)]
79pub struct DefaultsConfig {
80    /// Default galaxy index (0 = Euclid).
81    pub galaxy: u8,
82
83    /// Default warp range in light-years for routing.
84    pub warp_range: Option<f64>,
85
86    /// Default TSP algorithm: "nearest-neighbor" or "2opt".
87    pub tsp_algorithm: String,
88
89    /// Default number of results for find.
90    pub find_limit: Option<usize>,
91}
92
93impl Default for DefaultsConfig {
94    fn default() -> Self {
95        Self {
96            galaxy: 0,
97            warp_range: None,
98            tsp_algorithm: "2opt".into(),
99            find_limit: None,
100        }
101    }
102}
103
104/// Cache settings.
105#[derive(Debug, Deserialize)]
106#[serde(default)]
107pub struct CacheConfig {
108    /// Enable caching (default: true).
109    pub enabled: bool,
110
111    /// Cache file path (default: ~/.nms-copilot/galaxy.rkyv).
112    pub path: Option<PathBuf>,
113}
114
115impl Default for CacheConfig {
116    fn default() -> Self {
117        Self {
118            enabled: true,
119            path: None,
120        }
121    }
122}
123
124impl Config {
125    /// Load config from the default path (`~/.nms-copilot/config.toml`).
126    ///
127    /// Returns the default config if the file doesn't exist.
128    /// Returns an error if the file exists but can't be parsed.
129    pub fn load() -> Result<Self, ConfigError> {
130        let path = paths::config_path();
131        Self::load_from(&path)
132    }
133
134    /// Load config from a specific path.
135    pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
136        if !path.exists() {
137            return Ok(Self::default());
138        }
139
140        let content = fs::read_to_string(path).map_err(ConfigError::Io)?;
141        toml::from_str(&content).map_err(ConfigError::Parse)
142    }
143
144    /// Resolve the effective cache path.
145    pub fn cache_path(&self) -> PathBuf {
146        self.cache.path.clone().unwrap_or_else(paths::cache_path)
147    }
148
149    /// Resolve the effective save path (if configured).
150    pub fn save_path(&self) -> Option<&Path> {
151        self.save.path.as_deref()
152    }
153
154    /// Whether caching is enabled.
155    pub fn cache_enabled(&self) -> bool {
156        self.cache.enabled
157    }
158}
159
160/// Config loading errors.
161#[derive(Debug)]
162pub enum ConfigError {
163    Io(std::io::Error),
164    Parse(toml::de::Error),
165}
166
167impl std::fmt::Display for ConfigError {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        match self {
170            Self::Io(e) => write!(f, "config I/O error: {e}"),
171            Self::Parse(e) => write!(f, "config parse error: {e}"),
172        }
173    }
174}
175
176impl std::error::Error for ConfigError {
177    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
178        match self {
179            Self::Io(e) => Some(e),
180            Self::Parse(e) => Some(e),
181        }
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn test_default_config() {
191        let config = Config::default();
192        assert!(config.display.emoji_glyphs);
193        assert!(config.display.color);
194        assert_eq!(config.defaults.galaxy, 0);
195        assert!(config.cache.enabled);
196        assert!(config.save.path.is_none());
197    }
198
199    #[test]
200    fn test_parse_minimal_config() {
201        let toml = "";
202        let config: Config = toml::from_str(toml).unwrap();
203        assert!(config.display.emoji_glyphs);
204    }
205
206    #[test]
207    fn test_parse_full_config() {
208        let toml = r#"
209            [save]
210            path = "/Users/test/NMS"
211            format = "raw"
212
213            [display]
214            emoji_glyphs = false
215            color = false
216            table_style = "ascii"
217
218            [defaults]
219            galaxy = 1
220            warp_range = 2500.0
221            tsp_algorithm = "nearest-neighbor"
222            find_limit = 10
223
224            [cache]
225            enabled = false
226            path = "/tmp/nms-cache.rkyv"
227        "#;
228        let config: Config = toml::from_str(toml).unwrap();
229        assert_eq!(
230            config.save.path.as_deref().unwrap().to_str().unwrap(),
231            "/Users/test/NMS"
232        );
233        assert_eq!(config.save.format, "raw");
234        assert!(!config.display.emoji_glyphs);
235        assert!(!config.display.color);
236        assert_eq!(config.defaults.galaxy, 1);
237        assert_eq!(config.defaults.warp_range, Some(2500.0));
238        assert_eq!(config.defaults.find_limit, Some(10));
239        assert!(!config.cache.enabled);
240    }
241
242    #[test]
243    fn test_parse_partial_config() {
244        let toml = r#"
245            [defaults]
246            warp_range = 1500.0
247        "#;
248        let config: Config = toml::from_str(toml).unwrap();
249        assert!(config.display.emoji_glyphs);
250        assert!(config.cache.enabled);
251        assert_eq!(config.defaults.warp_range, Some(1500.0));
252    }
253
254    #[test]
255    fn test_load_nonexistent_returns_default() {
256        let config = Config::load_from(Path::new("/nonexistent/config.toml")).unwrap();
257        assert!(config.display.emoji_glyphs);
258    }
259
260    #[test]
261    fn test_load_invalid_toml_errors() {
262        let dir = tempfile::tempdir().unwrap();
263        let path = dir.path().join("bad.toml");
264        fs::write(&path, "not valid toml [[[").unwrap();
265        assert!(Config::load_from(&path).is_err());
266    }
267
268    #[test]
269    fn test_cache_path_default() {
270        let config = Config::default();
271        let path = config.cache_path();
272        assert!(path.ends_with("galaxy.rkyv"));
273    }
274
275    #[test]
276    fn test_cache_path_override() {
277        let toml = r#"
278            [cache]
279            path = "/tmp/custom-cache.rkyv"
280        "#;
281        let config: Config = toml::from_str(toml).unwrap();
282        assert_eq!(config.cache_path(), PathBuf::from("/tmp/custom-cache.rkyv"));
283    }
284
285    #[test]
286    fn test_save_path_none_when_unset() {
287        let config = Config::default();
288        assert!(config.save_path().is_none());
289    }
290
291    #[test]
292    fn test_unknown_fields_are_ignored() {
293        let toml = r#"
294            [save]
295            path = "/tmp"
296            unknown_field = "ignored"
297        "#;
298        let config: Config = toml::from_str(toml).unwrap();
299        assert!(config.save.path.is_some());
300    }
301}