1use std::fs;
8use std::path::{Path, PathBuf};
9
10use serde::Deserialize;
11
12use crate::paths;
13
14#[derive(Debug, Deserialize, Default)]
16#[serde(default)]
17pub struct Config {
18 pub save: SaveConfig,
20
21 pub display: DisplayConfig,
23
24 pub defaults: DefaultsConfig,
26
27 pub cache: CacheConfig,
29}
30
31#[derive(Debug, Deserialize)]
33#[serde(default)]
34pub struct SaveConfig {
35 pub path: Option<PathBuf>,
38
39 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#[derive(Debug, Deserialize)]
54#[serde(default)]
55pub struct DisplayConfig {
56 pub emoji_glyphs: bool,
58
59 pub color: bool,
61
62 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#[derive(Debug, Deserialize)]
78#[serde(default)]
79pub struct DefaultsConfig {
80 pub galaxy: u8,
82
83 pub warp_range: Option<f64>,
85
86 pub tsp_algorithm: String,
88
89 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#[derive(Debug, Deserialize)]
106#[serde(default)]
107pub struct CacheConfig {
108 pub enabled: bool,
110
111 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 pub fn load() -> Result<Self, ConfigError> {
130 let path = paths::config_path();
131 Self::load_from(&path)
132 }
133
134 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 pub fn cache_path(&self) -> PathBuf {
146 self.cache.path.clone().unwrap_or_else(paths::cache_path)
147 }
148
149 pub fn save_path(&self) -> Option<&Path> {
151 self.save.path.as_deref()
152 }
153
154 pub fn cache_enabled(&self) -> bool {
156 self.cache.enabled
157 }
158}
159
160#[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}