1use std::path::PathBuf;
2
3use serde::{Deserialize, Serialize};
4use smallvec::SmallVec;
5
6use crate::resolver::GameId;
7
8#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct AppSettings {
13 #[serde(default, skip_serializing_if = "String::is_empty")]
14 pub nexus_api_key: String,
15 #[serde(default)]
18 pub game_paths: SmallVec<[GamePath; 4]>,
19 #[serde(default)]
20 pub download_dir: Option<PathBuf>,
21 #[serde(default)]
22 pub theme: String,
23 #[serde(default)]
24 pub selected_game: Option<String>,
25 #[serde(default)]
26 pub update_check: UpdateCheckSettings,
27 #[serde(default)]
30 pub database: DatabaseSettings,
31}
32
33#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44pub struct DatabaseSettings {
45 #[serde(default)]
46 pub backend: DbBackend,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub url: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub host: Option<String>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub port: Option<u16>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub dbname: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub user: Option<String>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub password_file: Option<PathBuf>,
65}
66
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "lowercase")]
70pub enum DbBackend {
71 #[default]
72 Sqlite,
73 Postgres,
74}
75
76impl DbBackend {
77 #[must_use]
80 pub fn parse(s: &str) -> Option<Self> {
81 match s.trim().to_ascii_lowercase().as_str() {
82 "sqlite" => Some(Self::Sqlite),
83 "postgres" | "postgresql" | "pg" => Some(Self::Postgres),
84 _ => None,
85 }
86 }
87
88 #[must_use]
89 pub const fn as_str(self) -> &'static str {
90 match self {
91 Self::Sqlite => "sqlite",
92 Self::Postgres => "postgres",
93 }
94 }
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct UpdateCheckSettings {
99 #[serde(default = "default_update_check_enabled")]
100 pub enabled: bool,
101}
102
103const fn default_update_check_enabled() -> bool {
104 true
105}
106
107impl Default for UpdateCheckSettings {
108 fn default() -> Self {
109 Self { enabled: true }
110 }
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct GamePath {
115 pub game_id: GameId,
116 pub path: PathBuf,
117}
118
119impl AppSettings {
120 #[must_use]
122 pub fn config_path() -> PathBuf {
123 crate::paths::modde_config_dir().join("settings.toml")
124 }
125
126 #[must_use]
127 pub fn load() -> Self {
128 let path = Self::config_path();
129 std::fs::read_to_string(&path)
130 .ok()
131 .and_then(|s| toml::from_str(&s).ok())
132 .unwrap_or_default()
133 }
134
135 pub fn save(&self) {
136 let path = Self::config_path();
137 if let Some(parent) = path.parent()
138 && let Err(e) = std::fs::create_dir_all(parent)
139 {
140 tracing::warn!(error = %e, "failed to create config directory");
141 }
142 if let Ok(s) = toml::to_string_pretty(self)
143 && let Err(e) = std::fs::write(&path, s)
144 {
145 tracing::warn!(error = %e, "failed to write settings file");
146 }
147 }
148
149 #[must_use]
151 pub fn game_path(&self, game_id: &GameId) -> Option<&PathBuf> {
152 self.game_paths
153 .iter()
154 .find(|gp| gp.game_id == *game_id)
155 .map(|gp| &gp.path)
156 }
157
158 pub fn set_game_path(&mut self, game_id: &GameId, path: PathBuf) {
160 if let Some(entry) = self.game_paths.iter_mut().find(|gp| gp.game_id == *game_id) {
161 entry.path = path;
162 } else {
163 self.game_paths.push(GamePath {
164 game_id: game_id.clone(),
165 path,
166 });
167 }
168 }
169
170 #[must_use]
172 pub fn load_from(path: &std::path::Path) -> Self {
173 std::fs::read_to_string(path)
174 .ok()
175 .and_then(|s| toml::from_str(&s).ok())
176 .unwrap_or_default()
177 }
178
179 pub fn save_to(&self, path: &std::path::Path) {
181 if let Some(parent) = path.parent()
182 && let Err(e) = std::fs::create_dir_all(parent)
183 {
184 tracing::warn!(error = %e, "failed to create config directory");
185 }
186 if let Ok(s) = toml::to_string_pretty(self)
187 && let Err(e) = std::fs::write(path, s)
188 {
189 tracing::warn!(error = %e, "failed to write settings file");
190 }
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use std::path::Path;
198
199 #[test]
200 fn default_settings_are_empty() {
201 let s = AppSettings::default();
202 assert!(s.nexus_api_key.is_empty());
203 assert!(s.game_paths.is_empty());
204 assert!(s.download_dir.is_none());
205 assert!(s.theme.is_empty());
206 assert!(s.selected_game.is_none());
207 }
208
209 #[test]
210 fn game_path_lookup_and_set() {
211 let mut s = AppSettings::default();
212 assert!(s.game_path(&GameId::from("cyberpunk2077")).is_none());
213
214 s.set_game_path(
215 &GameId::from("cyberpunk2077"),
216 PathBuf::from("/games/cp2077"),
217 );
218 assert_eq!(
219 s.game_path(&GameId::from("cyberpunk2077")),
220 Some(&PathBuf::from("/games/cp2077"))
221 );
222
223 s.set_game_path(&GameId::from("cyberpunk2077"), PathBuf::from("/new/path"));
225 assert_eq!(
226 s.game_path(&GameId::from("cyberpunk2077")),
227 Some(&PathBuf::from("/new/path"))
228 );
229 assert_eq!(s.game_paths.len(), 1, "should update, not duplicate");
230 }
231
232 #[test]
233 fn multiple_game_paths() {
234 let mut s = AppSettings::default();
235 s.set_game_path(&GameId::from("skyrim-se"), PathBuf::from("/games/skyrim"));
236 s.set_game_path(
237 &GameId::from("cyberpunk2077"),
238 PathBuf::from("/games/cp2077"),
239 );
240
241 assert_eq!(s.game_paths.len(), 2);
242 assert_eq!(
243 s.game_path(&GameId::from("skyrim-se")),
244 Some(&PathBuf::from("/games/skyrim"))
245 );
246 assert_eq!(
247 s.game_path(&GameId::from("cyberpunk2077")),
248 Some(&PathBuf::from("/games/cp2077"))
249 );
250 assert!(s.game_path(&GameId::from("fallout4")).is_none());
251 }
252
253 #[test]
254 fn save_and_load_round_trip() {
255 let tmp = tempfile::tempdir().unwrap();
256 let path = tmp.path().join("settings.toml");
257
258 let mut original = AppSettings {
259 nexus_api_key: "test-key-123".into(),
260 ..AppSettings::default()
261 };
262 original.set_game_path(
263 &GameId::from("cyberpunk2077"),
264 PathBuf::from("/games/cp2077"),
265 );
266 original.set_game_path(&GameId::from("skyrim-se"), PathBuf::from("/games/skyrim"));
267 original.selected_game = Some("cyberpunk2077".into());
268 original.theme = "Nord".into();
269 original.download_dir = Some(PathBuf::from("/downloads"));
270
271 original.save_to(&path);
272
273 let loaded = AppSettings::load_from(&path);
274 assert_eq!(loaded.nexus_api_key, "test-key-123");
275 assert_eq!(loaded.selected_game.as_deref(), Some("cyberpunk2077"));
276 assert_eq!(loaded.theme, "Nord");
277 assert_eq!(loaded.download_dir, Some(PathBuf::from("/downloads")));
278 assert_eq!(
279 loaded.game_path(&GameId::from("cyberpunk2077")),
280 Some(&PathBuf::from("/games/cp2077"))
281 );
282 assert_eq!(
283 loaded.game_path(&GameId::from("skyrim-se")),
284 Some(&PathBuf::from("/games/skyrim"))
285 );
286 }
287
288 #[test]
289 fn load_missing_file_returns_default() {
290 let s = AppSettings::load_from(Path::new("/nonexistent/settings.toml"));
291 assert!(s.nexus_api_key.is_empty());
292 assert!(s.game_paths.is_empty());
293 }
294
295 #[test]
296 fn load_partial_toml_fills_defaults() {
297 let tmp = tempfile::tempdir().unwrap();
298 let path = tmp.path().join("settings.toml");
299 std::fs::write(&path, "nexus_api_key = \"mykey\"\n").unwrap();
300
301 let s = AppSettings::load_from(&path);
302 assert_eq!(s.nexus_api_key, "mykey");
303 assert!(s.game_paths.is_empty());
304 assert!(s.selected_game.is_none());
305 }
306
307 #[test]
308 fn missing_database_table_defaults_to_sqlite() {
309 let settings: AppSettings = toml::from_str(
310 r#"
311 nexus_api_key = "legacy-key"
312 selected_game = "skyrim-se"
313 "#,
314 )
315 .unwrap();
316
317 assert_eq!(settings.database.backend, DbBackend::Sqlite);
318 assert!(settings.database.url.is_none());
319 assert!(settings.database.host.is_none());
320 assert!(settings.database.port.is_none());
321 assert!(settings.database.dbname.is_none());
322 assert!(settings.database.user.is_none());
323 assert!(settings.database.password_file.is_none());
324 }
325
326 #[test]
327 fn db_backend_parse_accepts_aliases_and_round_trips() {
328 assert_eq!(DbBackend::parse("SQLite"), Some(DbBackend::Sqlite));
329 assert_eq!(DbBackend::parse("POSTGRES"), Some(DbBackend::Postgres));
330 assert_eq!(DbBackend::parse(" pg "), Some(DbBackend::Postgres));
331 assert_eq!(DbBackend::parse("postgresql"), Some(DbBackend::Postgres));
332 assert_eq!(DbBackend::parse("mysql"), None);
333
334 for backend in [DbBackend::Sqlite, DbBackend::Postgres] {
335 assert_eq!(DbBackend::parse(backend.as_str()), Some(backend));
336 }
337 }
338
339 #[test]
340 fn database_settings_round_trip_preserves_set_fields_and_skips_none() {
341 let settings = DatabaseSettings {
342 backend: DbBackend::Postgres,
343 url: None,
344 host: Some("db.example.test".to_string()),
345 port: Some(15432),
346 dbname: Some("modde".to_string()),
347 user: Some("modde_user".to_string()),
348 password_file: Some(PathBuf::from("/run/secrets/modde-db-password")),
349 };
350
351 let toml = toml::to_string(&settings).unwrap();
352
353 assert!(toml.contains("backend = \"postgres\""));
354 assert!(toml.contains("host = \"db.example.test\""));
355 assert!(toml.contains("port = 15432"));
356 assert!(toml.contains("dbname = \"modde\""));
357 assert!(toml.contains("user = \"modde_user\""));
358 assert!(toml.contains("password_file = \"/run/secrets/modde-db-password\""));
359 assert!(!toml.contains("url ="));
360
361 let loaded: DatabaseSettings = toml::from_str(&toml).unwrap();
362 assert_eq!(loaded.backend, settings.backend);
363 assert_eq!(loaded.url, settings.url);
364 assert_eq!(loaded.host, settings.host);
365 assert_eq!(loaded.port, settings.port);
366 assert_eq!(loaded.dbname, settings.dbname);
367 assert_eq!(loaded.user, settings.user);
368 assert_eq!(loaded.password_file, settings.password_file);
369 }
370
371 #[test]
372 fn load_with_unknown_fields_does_not_fail() {
373 let tmp = tempfile::tempdir().unwrap();
374 let path = tmp.path().join("settings.toml");
375 std::fs::write(
376 &path,
377 "nexus_api_key = \"key\"\nunknown_field = \"value\"\n",
378 )
379 .unwrap();
380
381 let s = AppSettings::load_from(&path);
382 assert_eq!(s.nexus_api_key, "key");
383 }
384
385 #[test]
386 fn toml_format_is_human_readable() {
387 let tmp = tempfile::tempdir().unwrap();
388 let path = tmp.path().join("settings.toml");
389
390 let mut s = AppSettings::default();
391 s.set_game_path(
392 &GameId::from("cyberpunk2077"),
393 PathBuf::from("/games/cp2077"),
394 );
395 s.selected_game = Some("cyberpunk2077".into());
396 s.save_to(&path);
397
398 let content = std::fs::read_to_string(&path).unwrap();
399 assert!(content.contains("selected_game"));
400 assert!(content.contains("cyberpunk2077"));
401 assert!(content.contains("game_id"));
402 }
403}