1use std::path::{Path, PathBuf};
2use std::sync::OnceLock;
3
4static DATA_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
5
6pub fn set_data_dir(path: PathBuf) {
8 DATA_DIR_OVERRIDE
9 .set(path)
10 .expect("data directory already set");
11}
12
13pub fn data_dir() -> PathBuf {
19 #[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
28pub 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
42pub 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
50pub fn store_dir() -> PathBuf {
52 modde_data_dir().join("store")
53}
54
55pub fn staging_dir() -> PathBuf {
57 modde_data_dir().join("staging")
58}
59
60pub fn profiles_dir() -> PathBuf {
62 modde_data_dir().join("profiles")
63}
64
65pub fn downloads_dir() -> PathBuf {
67 modde_data_dir().join("downloads")
68}
69
70pub fn stock_dir() -> PathBuf {
72 modde_data_dir().join("stock")
73}
74
75pub fn save_vaults_dir() -> PathBuf {
77 modde_data_dir().join("saves")
78}
79
80pub fn wabbajack_cache_dir() -> PathBuf {
83 modde_data_dir().join("wabbajack_cache")
84}
85
86pub fn wabbajack_cache_path(manifest_hash: &str) -> PathBuf {
88 wabbajack_cache_dir().join(format!("{manifest_hash}.wabbajack"))
89}
90
91pub fn save_vault_dir(game_id: &str) -> PathBuf {
93 save_vaults_dir().join(game_id)
94}
95
96fn 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#[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
127pub fn steam_common() -> PathBuf {
129 steam_install_dir().join("steamapps/common")
130}
131
132pub 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
142fn 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 for line in content.lines() {
155 let trimmed = line.trim();
156 if let Some(rest) = trimmed.strip_prefix("\"path\"") {
157 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 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
177fn 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
185pub fn heroic_config_dir() -> Option<PathBuf> {
191 let dir = config_dir().join("heroic");
192 dir.is_dir().then_some(dir)
193}
194
195#[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
203pub fn db_path() -> PathBuf {
205 modde_data_dir().join("modde.db")
206}
207
208pub 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 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 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 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 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 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}