Skip to main content

modde_core/
paths.rs

1use std::path::{Path, PathBuf};
2use std::sync::OnceLock;
3
4use crate::resolver::GameId;
5
6static DATA_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
7
8/// Set a custom data directory. Must be called before any path functions.
9pub fn set_data_dir(path: PathBuf) {
10    // First caller wins. In production this is invoked once at startup, so a
11    // repeat is benign; making it idempotent also stops parallel tests — each
12    // of which installs its own tempdir data dir — from racing on the OnceLock
13    // and panicking the loser.
14    let _ = DATA_DIR_OVERRIDE.set(path);
15}
16
17/// Platform-aware base data directory.
18///
19/// - Linux: `$XDG_DATA_HOME` or `~/.local/share`
20/// - macOS: `~/Library/Application Support`
21/// - Windows: `%APPDATA%` (e.g. `C:\Users\X\AppData\Roaming`)
22#[must_use]
23pub fn data_dir() -> PathBuf {
24    // Honor XDG override on Linux/BSD
25    #[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/// Platform-aware config directory.
34///
35/// - Linux: `$XDG_CONFIG_HOME` or `~/.config`
36/// - macOS: `~/Library/Application Support`
37/// - Windows: `%APPDATA%`
38#[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/// Platform-aware cache directory.
49///
50/// - Linux: `$XDG_CACHE_HOME` or `~/.cache`
51/// - macOS: `~/Library/Caches`
52/// - Windows: `%LOCALAPPDATA%`
53#[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/// Root of modde's non-essential cache files: `<cache_dir>/modde/`.
64#[must_use]
65pub fn modde_cache_dir() -> PathBuf {
66    cache_dir().join("modde")
67}
68
69/// Root of all modde data: `<data_dir>/modde/` or the overridden path.
70pub 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/// Mod file store: `<modde_data>/store/`.
88#[must_use]
89pub fn store_dir() -> PathBuf {
90    modde_data_dir().join("store")
91}
92
93/// Staging scratch space: `<modde_data>/staging/`.
94#[must_use]
95pub fn staging_dir() -> PathBuf {
96    modde_data_dir().join("staging")
97}
98
99/// Profiles root: `<modde_data>/profiles/`.
100#[must_use]
101pub fn profiles_dir() -> PathBuf {
102    modde_data_dir().join("profiles")
103}
104
105/// Downloads directory: `<modde_data>/downloads/`.
106#[must_use]
107pub fn downloads_dir() -> PathBuf {
108    modde_data_dir().join("downloads")
109}
110
111/// Stock game snapshots: `<modde_data>/stock/`.
112#[must_use]
113pub fn stock_dir() -> PathBuf {
114    modde_data_dir().join("stock")
115}
116
117/// Root of all save vaults: `<modde_data>/saves/`.
118#[must_use]
119pub fn save_vaults_dir() -> PathBuf {
120    modde_data_dir().join("saves")
121}
122
123/// Content-addressed cache of `.wabbajack` manifest source files.
124/// See [`crate::manifest::wabbajack::cache_wabbajack_file`].
125#[must_use]
126pub fn wabbajack_cache_dir() -> PathBuf {
127    modde_data_dir().join("wabbajack_cache")
128}
129
130/// Path to a cached `.wabbajack` file keyed by its `manifest_hash`.
131#[must_use]
132pub fn wabbajack_cache_path(manifest_hash: &str) -> PathBuf {
133    wabbajack_cache_dir().join(format!("{manifest_hash}.wabbajack"))
134}
135
136/// Save vault (git repo) for a specific game: `<modde_data>/saves/<game_id>/`.
137#[must_use]
138pub fn save_vault_dir(game_id: &GameId) -> PathBuf {
139    save_vaults_dir().join(game_id.as_str())
140}
141
142/// Default Steam install directory (platform-aware).
143fn 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/// Read Steam install path from Windows registry, with fallback.
159#[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/// Default Steam common library path.
174#[must_use]
175pub fn steam_common() -> PathBuf {
176    steam_install_dir().join("steamapps/common")
177}
178
179/// Parse Steam's `libraryfolders.vdf` and return all library paths.
180///
181/// Steam supports multiple library folders (e.g. separate drives).
182/// Each entry has a `"path"` key pointing to the Steam library root;
183/// game installs live under `<path>/steamapps/common/<game>/`.
184#[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
190/// Parse a VDF-format `libraryfolders.vdf` file.
191///
192/// The format is Valve's `KeyValues` (not JSON). We do a lightweight parse
193/// that extracts `"path"` values from numbered entries.
194fn 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    // Match lines like:   "path"		"/data/nvme0/can/games/steamapps"
202    for line in content.lines() {
203        let trimmed = line.trim();
204        if let Some(rest) = trimmed.strip_prefix("\"path\"") {
205            // Value is the next quoted string
206            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    // Ensure the default library is always included
217    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
225/// Extract a quoted string value from VDF: `"value"` → `value`
226fn 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/// Heroic Games Launcher config directory (platform-aware).
234///
235/// - Linux: `~/.config/heroic`
236/// - macOS: `~/Library/Application Support/heroic`
237/// - Windows: `%APPDATA%\heroic`
238#[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/// Heroic Games Launcher binary location (Windows only).
245#[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/// `SQLite` database path: `<modde_data>/modde.db`.
253#[must_use]
254pub fn db_path() -> PathBuf {
255    modde_data_dir().join("modde.db")
256}
257
258/// Modde config directory: `<config_dir>/modde/`.
259#[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        // Use the canonical indented format (each "path" key on its own line)
335        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        // Write a VDF pointing to a path that doesn't exist
350        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        // Nonexistent paths should not appear
357        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        // Should return empty vec, not panic
369        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        // Should only contain the path, not contentid/totalsize/app IDs
398        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}