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}
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 #[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 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 #[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 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 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}