Skip to main content

modde_core/
paths.rs

1use std::path::{Path, PathBuf};
2use std::sync::OnceLock;
3
4static DATA_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
5
6/// Set a custom data directory. Must be called before any path functions.
7pub fn set_data_dir(path: PathBuf) {
8    DATA_DIR_OVERRIDE
9        .set(path)
10        .expect("data directory already set");
11}
12
13/// Platform-aware base data directory.
14///
15/// - Linux: `$XDG_DATA_HOME` or `~/.local/share`
16/// - macOS: `~/Library/Application Support`
17/// - Windows: `%APPDATA%` (e.g. `C:\Users\X\AppData\Roaming`)
18pub fn data_dir() -> PathBuf {
19    // Honor XDG override on Linux/BSD
20    #[cfg(target_os = "linux")]
21    if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
22        return PathBuf::from(xdg);
23    }
24
25    dirs::data_dir().unwrap_or_else(|| home_dir().join(".local/share"))
26}
27
28/// Platform-aware config directory.
29///
30/// - Linux: `$XDG_CONFIG_HOME` or `~/.config`
31/// - macOS: `~/Library/Application Support`
32/// - Windows: `%APPDATA%`
33pub fn config_dir() -> PathBuf {
34    #[cfg(target_os = "linux")]
35    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
36        return PathBuf::from(xdg);
37    }
38
39    dirs::config_dir().unwrap_or_else(|| home_dir().join(".config"))
40}
41
42/// Root of all modde data: `<data_dir>/modde/` or the overridden path.
43pub fn modde_data_dir() -> PathBuf {
44    if let Some(dir) = DATA_DIR_OVERRIDE.get() {
45        return dir.clone();
46    }
47    data_dir().join("modde")
48}
49
50/// Mod file store: `<modde_data>/store/`.
51pub fn store_dir() -> PathBuf {
52    modde_data_dir().join("store")
53}
54
55/// Staging scratch space: `<modde_data>/staging/`.
56pub fn staging_dir() -> PathBuf {
57    modde_data_dir().join("staging")
58}
59
60/// Profiles root: `<modde_data>/profiles/`.
61pub fn profiles_dir() -> PathBuf {
62    modde_data_dir().join("profiles")
63}
64
65/// Downloads directory: `<modde_data>/downloads/`.
66pub fn downloads_dir() -> PathBuf {
67    modde_data_dir().join("downloads")
68}
69
70/// Stock game snapshots: `<modde_data>/stock/`.
71pub fn stock_dir() -> PathBuf {
72    modde_data_dir().join("stock")
73}
74
75/// Root of all save vaults: `<modde_data>/saves/`.
76pub fn save_vaults_dir() -> PathBuf {
77    modde_data_dir().join("saves")
78}
79
80/// Content-addressed cache of `.wabbajack` manifest source files.
81/// See [`crate::manifest::wabbajack::cache_wabbajack_file`].
82pub fn wabbajack_cache_dir() -> PathBuf {
83    modde_data_dir().join("wabbajack_cache")
84}
85
86/// Path to a cached `.wabbajack` file keyed by its `manifest_hash`.
87pub fn wabbajack_cache_path(manifest_hash: &str) -> PathBuf {
88    wabbajack_cache_dir().join(format!("{manifest_hash}.wabbajack"))
89}
90
91/// Save vault (git repo) for a specific game: `<modde_data>/saves/<game_id>/`.
92pub fn save_vault_dir(game_id: &str) -> PathBuf {
93    save_vaults_dir().join(game_id)
94}
95
96/// Default Steam install directory (platform-aware).
97fn steam_install_dir() -> PathBuf {
98    #[cfg(target_os = "linux")]
99    {
100        home_dir().join(".local/share/Steam")
101    }
102    #[cfg(target_os = "macos")]
103    {
104        home_dir().join("Library/Application Support/Steam")
105    }
106    #[cfg(target_os = "windows")]
107    {
108        steam_install_dir_windows()
109    }
110}
111
112/// Read Steam install path from Windows registry, with fallback.
113#[cfg(target_os = "windows")]
114fn steam_install_dir_windows() -> PathBuf {
115    use winreg::enums::HKEY_CURRENT_USER;
116    use winreg::RegKey;
117
118    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
119    if let Ok(key) = hkcu.open_subkey(r"Software\Valve\Steam") {
120        if let Ok(path) = key.get_value::<String, _>("SteamPath") {
121            return PathBuf::from(path);
122        }
123    }
124    PathBuf::from(r"C:\Program Files (x86)\Steam")
125}
126
127/// Default Steam common library path.
128pub fn steam_common() -> PathBuf {
129    steam_install_dir().join("steamapps/common")
130}
131
132/// Parse Steam's `libraryfolders.vdf` and return all library paths.
133///
134/// Steam supports multiple library folders (e.g. separate drives).
135/// Each entry has a `"path"` key pointing to the Steam library root;
136/// game installs live under `<path>/steamapps/common/<game>/`.
137pub fn steam_library_folders() -> Vec<PathBuf> {
138    let vdf_path = steam_install_dir().join("steamapps/libraryfolders.vdf");
139    parse_library_folders_vdf(&vdf_path)
140}
141
142/// Parse a VDF-format `libraryfolders.vdf` file.
143///
144/// The format is Valve's KeyValues (not JSON). We do a lightweight parse
145/// that extracts `"path"` values from numbered entries.
146fn parse_library_folders_vdf(path: &Path) -> Vec<PathBuf> {
147    let content = match std::fs::read_to_string(path) {
148        Ok(c) => c,
149        Err(_) => return vec![],
150    };
151
152    let mut paths = Vec::new();
153    // Match lines like:   "path"		"/data/nvme0/can/games/steamapps"
154    for line in content.lines() {
155        let trimmed = line.trim();
156        if let Some(rest) = trimmed.strip_prefix("\"path\"") {
157            // Value is the next quoted string
158            let rest = rest.trim();
159            if let Some(val) = extract_vdf_string(rest) {
160                let lib_path = PathBuf::from(val);
161                if lib_path.exists() {
162                    paths.push(lib_path);
163                }
164            }
165        }
166    }
167
168    // Ensure the default library is always included
169    let default_lib = steam_install_dir();
170    if !paths.iter().any(|p| p == &default_lib) && default_lib.exists() {
171        paths.insert(0, default_lib);
172    }
173
174    paths
175}
176
177/// Extract a quoted string value from VDF: `"value"` → `value`
178fn extract_vdf_string(s: &str) -> Option<&str> {
179    let s = s.trim();
180    let s = s.strip_prefix('"')?;
181    let end = s.find('"')?;
182    Some(&s[..end])
183}
184
185/// Heroic Games Launcher config directory (platform-aware).
186///
187/// - Linux: `~/.config/heroic`
188/// - macOS: `~/Library/Application Support/heroic`
189/// - Windows: `%APPDATA%\heroic`
190pub fn heroic_config_dir() -> Option<PathBuf> {
191    let dir = config_dir().join("heroic");
192    dir.is_dir().then_some(dir)
193}
194
195/// Heroic Games Launcher binary location (Windows only).
196#[cfg(target_os = "windows")]
197pub fn heroic_exe_path() -> Option<PathBuf> {
198    let local_app = dirs::data_local_dir()?;
199    let exe = local_app.join(r"Programs\heroic\Heroic.exe");
200    exe.exists().then_some(exe)
201}
202
203/// SQLite database path: `<modde_data>/modde.db`.
204pub fn db_path() -> PathBuf {
205    modde_data_dir().join("modde.db")
206}
207
208/// Modde config directory: `<config_dir>/modde/`.
209pub fn modde_config_dir() -> PathBuf {
210    config_dir().join("modde")
211}
212
213pub fn home_dir() -> PathBuf {
214    dirs::home_dir().unwrap_or_else(|| {
215        #[cfg(unix)]
216        {
217            PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()))
218        }
219        #[cfg(windows)]
220        {
221            PathBuf::from(
222                std::env::var("USERPROFILE").unwrap_or_else(|_| r"C:\Temp".to_string()),
223            )
224        }
225    })
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use std::io::Write as _;
232
233    fn write_vdf(dir: &std::path::Path, content: &str) -> PathBuf {
234        let path = dir.join("libraryfolders.vdf");
235        std::fs::write(&path, content).unwrap();
236        path
237    }
238
239    #[test]
240    fn vdf_single_library() {
241        let tmp = tempfile::tempdir().unwrap();
242        let lib = tmp.path().join("steam");
243        std::fs::create_dir_all(&lib).unwrap();
244
245        let vdf = format!(
246            r#""libraryfolders"
247{{
248    "0"
249    {{
250        "path"		"{}"
251    }}
252}}"#,
253            lib.display()
254        );
255        let vdf_path = write_vdf(tmp.path(), &vdf);
256        let paths = parse_library_folders_vdf(&vdf_path);
257        assert!(paths.contains(&lib), "expected {:?} in {:?}", lib, paths);
258    }
259
260    #[test]
261    fn vdf_multiple_libraries() {
262        let tmp = tempfile::tempdir().unwrap();
263        let lib1 = tmp.path().join("lib1");
264        let lib2 = tmp.path().join("lib2");
265        std::fs::create_dir_all(&lib1).unwrap();
266        std::fs::create_dir_all(&lib2).unwrap();
267
268        // Use the canonical indented format (each "path" key on its own line)
269        let vdf = format!(
270            "\"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}}",
271            lib1.display(),
272            lib2.display()
273        );
274        let vdf_path = write_vdf(tmp.path(), &vdf);
275        let paths = parse_library_folders_vdf(&vdf_path);
276        assert!(paths.contains(&lib1), "expected {:?} in {:?}", lib1, paths);
277        assert!(paths.contains(&lib2), "expected {:?} in {:?}", lib2, paths);
278    }
279
280    #[test]
281    fn vdf_nonexistent_paths_excluded() {
282        let tmp = tempfile::tempdir().unwrap();
283        // Write a VDF pointing to a path that doesn't exist
284        let vdf = r#""libraryfolders"
285{
286    "0" { "path"		"/nonexistent/path/to/steam" }
287}"#;
288        let vdf_path = write_vdf(tmp.path(), vdf);
289        let paths = parse_library_folders_vdf(&vdf_path);
290        // Nonexistent paths should not appear
291        assert!(!paths.iter().any(|p| p.to_string_lossy().contains("nonexistent")));
292    }
293
294    #[test]
295    fn vdf_missing_file_returns_empty() {
296        let paths = parse_library_folders_vdf(std::path::Path::new("/nonexistent/libraryfolders.vdf"));
297        // Should return empty vec, not panic
298        assert!(paths.is_empty());
299    }
300
301    #[test]
302    fn vdf_extra_fields_ignored() {
303        let tmp = tempfile::tempdir().unwrap();
304        let lib = tmp.path().join("steam");
305        std::fs::create_dir_all(&lib).unwrap();
306
307        let vdf = format!(
308            r#""libraryfolders"
309{{
310    "0"
311    {{
312        "path"		"{}"
313        "label"		""
314        "contentid"		"12345"
315        "totalsize"		"0"
316        "apps"
317        {{
318            "730"		"12345"
319        }}
320    }}
321}}"#,
322            lib.display()
323        );
324        let vdf_path = write_vdf(tmp.path(), &vdf);
325        let paths = parse_library_folders_vdf(&vdf_path);
326        // Should only contain the path, not contentid/totalsize/app IDs
327        assert!(paths.contains(&lib));
328        for p in &paths {
329            assert!(p.exists(), "all returned paths must exist");
330        }
331    }
332
333    #[test]
334    fn extract_vdf_string_basic() {
335        assert_eq!(extract_vdf_string(r#""hello""#), Some("hello"));
336        assert_eq!(extract_vdf_string(r#"  "with spaces"  "#), Some("with spaces"));
337        assert_eq!(extract_vdf_string(r#""/path/to/dir""#), Some("/path/to/dir"));
338    }
339
340    #[test]
341    fn extract_vdf_string_no_quotes() {
342        assert_eq!(extract_vdf_string("no quotes here"), None);
343    }
344
345    #[test]
346    fn extract_vdf_string_unclosed_quote() {
347        assert_eq!(extract_vdf_string(r#""unclosed"#), None);
348    }
349}