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