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)]
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}
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 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 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 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 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 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}