1use std::path::{Path, PathBuf};
2use std::sync::OnceLock;
3
4use crate::resolver::GameId;
5
6static DATA_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
7
8pub fn set_data_dir(path: PathBuf) {
10 let _ = DATA_DIR_OVERRIDE.set(path);
15}
16
17#[must_use]
23pub fn data_dir() -> PathBuf {
24 #[cfg(target_os = "linux")]
26 if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
27 return PathBuf::from(xdg);
28 }
29
30 dirs::data_dir().unwrap_or_else(|| home_dir().join(".local/share"))
31}
32
33#[must_use]
39pub fn config_dir() -> PathBuf {
40 #[cfg(target_os = "linux")]
41 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
42 return PathBuf::from(xdg);
43 }
44
45 dirs::config_dir().unwrap_or_else(|| home_dir().join(".config"))
46}
47
48#[must_use]
54pub fn cache_dir() -> PathBuf {
55 #[cfg(target_os = "linux")]
56 if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
57 return PathBuf::from(xdg);
58 }
59
60 dirs::cache_dir().unwrap_or_else(|| home_dir().join(".cache"))
61}
62
63#[must_use]
65pub fn modde_cache_dir() -> PathBuf {
66 cache_dir().join("modde")
67}
68
69pub fn modde_data_dir() -> PathBuf {
71 if let Some(dir) = DATA_DIR_OVERRIDE.get() {
72 return dir.clone();
73 }
74 active_instance_data_dir().unwrap_or_else(default_modde_data_dir)
75}
76
77fn default_modde_data_dir() -> PathBuf {
78 data_dir().join("modde")
79}
80
81fn active_instance_data_dir() -> Option<PathBuf> {
82 crate::instance::InstanceRegistry::load()
83 .active_data_dir()
84 .map(Path::to_path_buf)
85}
86
87#[must_use]
89pub fn store_dir() -> PathBuf {
90 modde_data_dir().join("store")
91}
92
93#[must_use]
95pub fn staging_dir() -> PathBuf {
96 modde_data_dir().join("staging")
97}
98
99#[must_use]
101pub fn profiles_dir() -> PathBuf {
102 modde_data_dir().join("profiles")
103}
104
105#[must_use]
107pub fn downloads_dir() -> PathBuf {
108 modde_data_dir().join("downloads")
109}
110
111#[must_use]
113pub fn stock_dir() -> PathBuf {
114 modde_data_dir().join("stock")
115}
116
117#[must_use]
119pub fn save_vaults_dir() -> PathBuf {
120 modde_data_dir().join("saves")
121}
122
123#[must_use]
126pub fn wabbajack_cache_dir() -> PathBuf {
127 modde_data_dir().join("wabbajack_cache")
128}
129
130#[must_use]
132pub fn wabbajack_cache_path(manifest_hash: &str) -> PathBuf {
133 wabbajack_cache_dir().join(format!("{manifest_hash}.wabbajack"))
134}
135
136#[must_use]
138pub fn save_vault_dir(game_id: &GameId) -> PathBuf {
139 save_vaults_dir().join(game_id.as_str())
140}
141
142fn steam_install_dir() -> PathBuf {
144 #[cfg(target_os = "linux")]
145 {
146 home_dir().join(".local/share/Steam")
147 }
148 #[cfg(target_os = "macos")]
149 {
150 home_dir().join("Library/Application Support/Steam")
151 }
152 #[cfg(target_os = "windows")]
153 {
154 steam_install_dir_windows()
155 }
156}
157
158#[cfg(target_os = "windows")]
160fn steam_install_dir_windows() -> PathBuf {
161 use winreg::RegKey;
162 use winreg::enums::HKEY_CURRENT_USER;
163
164 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
165 if let Ok(key) = hkcu.open_subkey(r"Software\Valve\Steam") {
166 if let Ok(path) = key.get_value::<String, _>("SteamPath") {
167 return PathBuf::from(path);
168 }
169 }
170 PathBuf::from(r"C:\Program Files (x86)\Steam")
171}
172
173#[must_use]
175pub fn steam_common() -> PathBuf {
176 steam_install_dir().join("steamapps/common")
177}
178
179#[must_use]
185pub fn steam_library_folders() -> Vec<PathBuf> {
186 let vdf_path = steam_install_dir().join("steamapps/libraryfolders.vdf");
187 parse_library_folders_vdf(&vdf_path)
188}
189
190fn parse_library_folders_vdf(path: &Path) -> Vec<PathBuf> {
195 let content = match std::fs::read_to_string(path) {
196 Ok(c) => c,
197 Err(_) => return vec![],
198 };
199
200 let mut paths = Vec::new();
201 for line in content.lines() {
203 let trimmed = line.trim();
204 if let Some(rest) = trimmed.strip_prefix("\"path\"") {
205 let rest = rest.trim();
207 if let Some(val) = extract_vdf_string(rest) {
208 let lib_path = PathBuf::from(val);
209 if lib_path.exists() {
210 paths.push(lib_path);
211 }
212 }
213 }
214 }
215
216 let default_lib = steam_install_dir();
218 if !paths.iter().any(|p| p == &default_lib) && default_lib.exists() {
219 paths.insert(0, default_lib);
220 }
221
222 paths
223}
224
225fn extract_vdf_string(s: &str) -> Option<&str> {
227 let s = s.trim();
228 let s = s.strip_prefix('"')?;
229 let end = s.find('"')?;
230 Some(&s[..end])
231}
232
233#[must_use]
239pub fn heroic_config_dir() -> Option<PathBuf> {
240 let dir = config_dir().join("heroic");
241 dir.is_dir().then_some(dir)
242}
243
244#[cfg(target_os = "windows")]
246pub fn heroic_exe_path() -> Option<PathBuf> {
247 let local_app = dirs::data_local_dir()?;
248 let exe = local_app.join(r"Programs\heroic\Heroic.exe");
249 exe.exists().then_some(exe)
250}
251
252#[must_use]
254pub fn db_path() -> PathBuf {
255 modde_data_dir().join("modde.db")
256}
257
258#[must_use]
260pub fn modde_config_dir() -> PathBuf {
261 config_dir().join("modde")
262}
263
264#[must_use]
265pub fn home_dir() -> PathBuf {
266 dirs::home_dir().unwrap_or_else(|| {
267 #[cfg(unix)]
268 {
269 PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()))
270 }
271 #[cfg(windows)]
272 {
273 PathBuf::from(std::env::var("USERPROFILE").unwrap_or_else(|_| r"C:\Temp".to_string()))
274 }
275 })
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn instance_registry_can_override_default_data_dir() {
284 let tmp = tempfile::tempdir().unwrap();
285 let instance_dir = tmp.path().join("instance-data");
286
287 let mut registry = crate::instance::InstanceRegistry::default();
288 registry.instances.push(crate::instance::Instance {
289 name: "portable".to_string(),
290 data_dir: instance_dir.clone(),
291 is_default: true,
292 });
293 registry.active = Some("portable".to_string());
294
295 let resolved = registry.active_data_dir().map(Path::to_path_buf);
296 assert_eq!(resolved, Some(instance_dir));
297 }
298
299 fn write_vdf(dir: &std::path::Path, content: &str) -> PathBuf {
300 let path = dir.join("libraryfolders.vdf");
301 std::fs::write(&path, content).unwrap();
302 path
303 }
304
305 #[test]
306 fn vdf_single_library() {
307 let tmp = tempfile::tempdir().unwrap();
308 let lib = tmp.path().join("steam");
309 std::fs::create_dir_all(&lib).unwrap();
310
311 let vdf = format!(
312 r#""libraryfolders"
313{{
314 "0"
315 {{
316 "path" "{}"
317 }}
318}}"#,
319 lib.display()
320 );
321 let vdf_path = write_vdf(tmp.path(), &vdf);
322 let paths = parse_library_folders_vdf(&vdf_path);
323 assert!(paths.contains(&lib), "expected {lib:?} in {paths:?}");
324 }
325
326 #[test]
327 fn vdf_multiple_libraries() {
328 let tmp = tempfile::tempdir().unwrap();
329 let lib1 = tmp.path().join("lib1");
330 let lib2 = tmp.path().join("lib2");
331 std::fs::create_dir_all(&lib1).unwrap();
332 std::fs::create_dir_all(&lib2).unwrap();
333
334 let vdf = format!(
336 "\"libraryfolders\"\n{{\n\t\"0\"\n\t{{\n\t\t\"path\"\t\t\"{}\"\n\t}}\n\t\"1\"\n\t{{\n\t\t\"path\"\t\t\"{}\"\n\t}}\n}}",
337 lib1.display(),
338 lib2.display()
339 );
340 let vdf_path = write_vdf(tmp.path(), &vdf);
341 let paths = parse_library_folders_vdf(&vdf_path);
342 assert!(paths.contains(&lib1), "expected {lib1:?} in {paths:?}");
343 assert!(paths.contains(&lib2), "expected {lib2:?} in {paths:?}");
344 }
345
346 #[test]
347 fn vdf_nonexistent_paths_excluded() {
348 let tmp = tempfile::tempdir().unwrap();
349 let vdf = r#""libraryfolders"
351{
352 "0" { "path" "/nonexistent/path/to/steam" }
353}"#;
354 let vdf_path = write_vdf(tmp.path(), vdf);
355 let paths = parse_library_folders_vdf(&vdf_path);
356 assert!(
358 !paths
359 .iter()
360 .any(|p| p.to_string_lossy().contains("nonexistent"))
361 );
362 }
363
364 #[test]
365 fn vdf_missing_file_returns_empty() {
366 let paths =
367 parse_library_folders_vdf(std::path::Path::new("/nonexistent/libraryfolders.vdf"));
368 assert!(paths.is_empty());
370 }
371
372 #[test]
373 fn vdf_extra_fields_ignored() {
374 let tmp = tempfile::tempdir().unwrap();
375 let lib = tmp.path().join("steam");
376 std::fs::create_dir_all(&lib).unwrap();
377
378 let vdf = format!(
379 r#""libraryfolders"
380{{
381 "0"
382 {{
383 "path" "{}"
384 "label" ""
385 "contentid" "12345"
386 "totalsize" "0"
387 "apps"
388 {{
389 "730" "12345"
390 }}
391 }}
392}}"#,
393 lib.display()
394 );
395 let vdf_path = write_vdf(tmp.path(), &vdf);
396 let paths = parse_library_folders_vdf(&vdf_path);
397 assert!(paths.contains(&lib));
399 for p in &paths {
400 assert!(p.exists(), "all returned paths must exist");
401 }
402 }
403
404 #[test]
405 fn extract_vdf_string_basic() {
406 assert_eq!(extract_vdf_string(r#""hello""#), Some("hello"));
407 assert_eq!(
408 extract_vdf_string(r#" "with spaces" "#),
409 Some("with spaces")
410 );
411 assert_eq!(
412 extract_vdf_string(r#""/path/to/dir""#),
413 Some("/path/to/dir")
414 );
415 }
416
417 #[test]
418 fn extract_vdf_string_no_quotes() {
419 assert_eq!(extract_vdf_string("no quotes here"), None);
420 }
421
422 #[test]
423 fn extract_vdf_string_unclosed_quote() {
424 assert_eq!(extract_vdf_string(r#""unclosed"#), None);
425 }
426}