Skip to main content

modde_core/
settings.rs

1use std::path::PathBuf;
2
3use serde::{Deserialize, Serialize};
4use smallvec::SmallVec;
5
6use crate::resolver::GameId;
7
8/// Persistent application settings shared between CLI and UI.
9///
10/// Stored as TOML at `<config_dir>/modde/settings.toml`.
11#[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    /// Configured game install paths — typically 1–4 games.
16    /// `SmallVec<[_; 4]>` keeps ≤4 entries inline (no heap allocation).
17    #[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    /// Storage backend selection. Defaults to `SQLite`; omitted from old
28    /// `settings.toml` files, which therefore keep using `SQLite` unchanged.
29    #[serde(default)]
30    pub database: DatabaseSettings,
31}
32
33/// Which database backend modde stores its state in, plus the `PostgreSQL`
34/// connection parameters (used only when `backend = "postgres"`).
35///
36/// Every field is `#[serde(default)]`, so existing config files round-trip and
37/// default to `SQLite`. Environment variables override these at startup
38/// (`MODDE_DATABASE_BACKEND`, `MODDE_DATABASE_URL`, the discrete
39/// `MODDE_DATABASE_HOST`/`PORT`/`NAME`/`USER` fields, and
40/// `MODDE_DB_PASSWORD_FILE`) so the Home Manager module can configure the
41/// backend declaratively without rewriting `settings.toml`. The `PostgreSQL`
42/// password is never stored here — it is read at runtime from `password_file`.
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44pub struct DatabaseSettings {
45    #[serde(default)]
46    pub backend: DbBackend,
47    /// Full connection URL (e.g. `postgres://user@host/db`). Takes precedence
48    /// over the discrete `host`/`port`/`dbname`/`user` fields when set.
49    #[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    /// Database name. The matching environment variable is
56    /// `MODDE_DATABASE_NAME`, not `MODDE_DATABASE_DBNAME`.
57    #[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    /// Path to a file containing the `PostgreSQL` password (sops-nix compatible).
62    /// Read at runtime; never written back to `settings.toml`.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub password_file: Option<PathBuf>,
65}
66
67/// Selectable storage backend.
68#[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    /// Parse a backend name (used for the `MODDE_DATABASE_BACKEND` env var and
78    /// the CLI `--backend` flag). Case-insensitive.
79    #[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    /// Path to the `settings.toml` file backing [`AppSettings`].
121    #[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    /// Get the install path for a game, if configured.
150    #[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    /// Set the install path for a game (add or update).
159    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    /// Load settings from a specific file path.
171    #[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    /// Save settings to a specific file path.
180    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        // Update existing
224        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}