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}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct UpdateCheckSettings {
31    #[serde(default = "default_update_check_enabled")]
32    pub enabled: bool,
33}
34
35const fn default_update_check_enabled() -> bool {
36    true
37}
38
39impl Default for UpdateCheckSettings {
40    fn default() -> Self {
41        Self { enabled: true }
42    }
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct GamePath {
47    pub game_id: GameId,
48    pub path: PathBuf,
49}
50
51impl AppSettings {
52    fn config_path() -> PathBuf {
53        crate::paths::modde_config_dir().join("settings.toml")
54    }
55
56    #[must_use]
57    pub fn load() -> Self {
58        let path = Self::config_path();
59        std::fs::read_to_string(&path)
60            .ok()
61            .and_then(|s| toml::from_str(&s).ok())
62            .unwrap_or_default()
63    }
64
65    pub fn save(&self) {
66        let path = Self::config_path();
67        if let Some(parent) = path.parent()
68            && let Err(e) = std::fs::create_dir_all(parent)
69        {
70            tracing::warn!(error = %e, "failed to create config directory");
71        }
72        if let Ok(s) = toml::to_string_pretty(self)
73            && let Err(e) = std::fs::write(&path, s)
74        {
75            tracing::warn!(error = %e, "failed to write settings file");
76        }
77    }
78
79    /// Get the install path for a game, if configured.
80    #[must_use]
81    pub fn game_path(&self, game_id: &GameId) -> Option<&PathBuf> {
82        self.game_paths
83            .iter()
84            .find(|gp| gp.game_id == *game_id)
85            .map(|gp| &gp.path)
86    }
87
88    /// Set the install path for a game (add or update).
89    pub fn set_game_path(&mut self, game_id: &GameId, path: PathBuf) {
90        if let Some(entry) = self.game_paths.iter_mut().find(|gp| gp.game_id == *game_id) {
91            entry.path = path;
92        } else {
93            self.game_paths.push(GamePath {
94                game_id: game_id.clone(),
95                path,
96            });
97        }
98    }
99
100    /// Load settings from a specific file path.
101    #[must_use]
102    pub fn load_from(path: &std::path::Path) -> Self {
103        std::fs::read_to_string(path)
104            .ok()
105            .and_then(|s| toml::from_str(&s).ok())
106            .unwrap_or_default()
107    }
108
109    /// Save settings to a specific file path.
110    pub fn save_to(&self, path: &std::path::Path) {
111        if let Some(parent) = path.parent()
112            && let Err(e) = std::fs::create_dir_all(parent)
113        {
114            tracing::warn!(error = %e, "failed to create config directory");
115        }
116        if let Ok(s) = toml::to_string_pretty(self)
117            && let Err(e) = std::fs::write(path, s)
118        {
119            tracing::warn!(error = %e, "failed to write settings file");
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use std::path::Path;
128
129    #[test]
130    fn default_settings_are_empty() {
131        let s = AppSettings::default();
132        assert!(s.nexus_api_key.is_empty());
133        assert!(s.game_paths.is_empty());
134        assert!(s.download_dir.is_none());
135        assert!(s.theme.is_empty());
136        assert!(s.selected_game.is_none());
137    }
138
139    #[test]
140    fn game_path_lookup_and_set() {
141        let mut s = AppSettings::default();
142        assert!(s.game_path(&GameId::from("cyberpunk2077")).is_none());
143
144        s.set_game_path(
145            &GameId::from("cyberpunk2077"),
146            PathBuf::from("/games/cp2077"),
147        );
148        assert_eq!(
149            s.game_path(&GameId::from("cyberpunk2077")),
150            Some(&PathBuf::from("/games/cp2077"))
151        );
152
153        // Update existing
154        s.set_game_path(&GameId::from("cyberpunk2077"), PathBuf::from("/new/path"));
155        assert_eq!(
156            s.game_path(&GameId::from("cyberpunk2077")),
157            Some(&PathBuf::from("/new/path"))
158        );
159        assert_eq!(s.game_paths.len(), 1, "should update, not duplicate");
160    }
161
162    #[test]
163    fn multiple_game_paths() {
164        let mut s = AppSettings::default();
165        s.set_game_path(&GameId::from("skyrim-se"), PathBuf::from("/games/skyrim"));
166        s.set_game_path(
167            &GameId::from("cyberpunk2077"),
168            PathBuf::from("/games/cp2077"),
169        );
170
171        assert_eq!(s.game_paths.len(), 2);
172        assert_eq!(
173            s.game_path(&GameId::from("skyrim-se")),
174            Some(&PathBuf::from("/games/skyrim"))
175        );
176        assert_eq!(
177            s.game_path(&GameId::from("cyberpunk2077")),
178            Some(&PathBuf::from("/games/cp2077"))
179        );
180        assert!(s.game_path(&GameId::from("fallout4")).is_none());
181    }
182
183    #[test]
184    fn save_and_load_round_trip() {
185        let tmp = tempfile::tempdir().unwrap();
186        let path = tmp.path().join("settings.toml");
187
188        let mut original = AppSettings {
189            nexus_api_key: "test-key-123".into(),
190            ..AppSettings::default()
191        };
192        original.set_game_path(
193            &GameId::from("cyberpunk2077"),
194            PathBuf::from("/games/cp2077"),
195        );
196        original.set_game_path(&GameId::from("skyrim-se"), PathBuf::from("/games/skyrim"));
197        original.selected_game = Some("cyberpunk2077".into());
198        original.theme = "Nord".into();
199        original.download_dir = Some(PathBuf::from("/downloads"));
200
201        original.save_to(&path);
202
203        let loaded = AppSettings::load_from(&path);
204        assert_eq!(loaded.nexus_api_key, "test-key-123");
205        assert_eq!(loaded.selected_game.as_deref(), Some("cyberpunk2077"));
206        assert_eq!(loaded.theme, "Nord");
207        assert_eq!(loaded.download_dir, Some(PathBuf::from("/downloads")));
208        assert_eq!(
209            loaded.game_path(&GameId::from("cyberpunk2077")),
210            Some(&PathBuf::from("/games/cp2077"))
211        );
212        assert_eq!(
213            loaded.game_path(&GameId::from("skyrim-se")),
214            Some(&PathBuf::from("/games/skyrim"))
215        );
216    }
217
218    #[test]
219    fn load_missing_file_returns_default() {
220        let s = AppSettings::load_from(Path::new("/nonexistent/settings.toml"));
221        assert!(s.nexus_api_key.is_empty());
222        assert!(s.game_paths.is_empty());
223    }
224
225    #[test]
226    fn load_partial_toml_fills_defaults() {
227        let tmp = tempfile::tempdir().unwrap();
228        let path = tmp.path().join("settings.toml");
229        std::fs::write(&path, "nexus_api_key = \"mykey\"\n").unwrap();
230
231        let s = AppSettings::load_from(&path);
232        assert_eq!(s.nexus_api_key, "mykey");
233        assert!(s.game_paths.is_empty());
234        assert!(s.selected_game.is_none());
235    }
236
237    #[test]
238    fn load_with_unknown_fields_does_not_fail() {
239        let tmp = tempfile::tempdir().unwrap();
240        let path = tmp.path().join("settings.toml");
241        std::fs::write(
242            &path,
243            "nexus_api_key = \"key\"\nunknown_field = \"value\"\n",
244        )
245        .unwrap();
246
247        let s = AppSettings::load_from(&path);
248        assert_eq!(s.nexus_api_key, "key");
249    }
250
251    #[test]
252    fn toml_format_is_human_readable() {
253        let tmp = tempfile::tempdir().unwrap();
254        let path = tmp.path().join("settings.toml");
255
256        let mut s = AppSettings::default();
257        s.set_game_path(
258            &GameId::from("cyberpunk2077"),
259            PathBuf::from("/games/cp2077"),
260        );
261        s.selected_game = Some("cyberpunk2077".into());
262        s.save_to(&path);
263
264        let content = std::fs::read_to_string(&path).unwrap();
265        assert!(content.contains("selected_game"));
266        assert!(content.contains("cyberpunk2077"));
267        assert!(content.contains("game_id"));
268    }
269}