use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use crate::resolver::GameId;
static DATA_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
pub fn set_data_dir(path: PathBuf) {
let _ = DATA_DIR_OVERRIDE.set(path);
}
#[must_use]
pub fn data_dir() -> PathBuf {
#[cfg(target_os = "linux")]
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
return PathBuf::from(xdg);
}
dirs::data_dir().unwrap_or_else(|| home_dir().join(".local/share"))
}
#[must_use]
pub fn config_dir() -> PathBuf {
#[cfg(target_os = "linux")]
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
return PathBuf::from(xdg);
}
dirs::config_dir().unwrap_or_else(|| home_dir().join(".config"))
}
#[must_use]
pub fn cache_dir() -> PathBuf {
#[cfg(target_os = "linux")]
if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
return PathBuf::from(xdg);
}
dirs::cache_dir().unwrap_or_else(|| home_dir().join(".cache"))
}
#[must_use]
pub fn modde_cache_dir() -> PathBuf {
cache_dir().join("modde")
}
pub fn modde_data_dir() -> PathBuf {
if let Some(dir) = DATA_DIR_OVERRIDE.get() {
return dir.clone();
}
active_instance_data_dir().unwrap_or_else(default_modde_data_dir)
}
fn default_modde_data_dir() -> PathBuf {
data_dir().join("modde")
}
fn active_instance_data_dir() -> Option<PathBuf> {
crate::instance::InstanceRegistry::load()
.active_data_dir()
.map(Path::to_path_buf)
}
#[must_use]
pub fn store_dir() -> PathBuf {
modde_data_dir().join("store")
}
#[must_use]
pub fn staging_dir() -> PathBuf {
modde_data_dir().join("staging")
}
#[must_use]
pub fn profiles_dir() -> PathBuf {
modde_data_dir().join("profiles")
}
#[must_use]
pub fn downloads_dir() -> PathBuf {
modde_data_dir().join("downloads")
}
#[must_use]
pub fn stock_dir() -> PathBuf {
modde_data_dir().join("stock")
}
#[must_use]
pub fn save_vaults_dir() -> PathBuf {
modde_data_dir().join("saves")
}
#[must_use]
pub fn wabbajack_cache_dir() -> PathBuf {
modde_data_dir().join("wabbajack_cache")
}
#[must_use]
pub fn wabbajack_cache_path(manifest_hash: &str) -> PathBuf {
wabbajack_cache_dir().join(format!("{manifest_hash}.wabbajack"))
}
#[must_use]
pub fn save_vault_dir(game_id: &GameId) -> PathBuf {
save_vaults_dir().join(game_id.as_str())
}
fn steam_install_dir() -> PathBuf {
#[cfg(target_os = "linux")]
{
home_dir().join(".local/share/Steam")
}
#[cfg(target_os = "macos")]
{
home_dir().join("Library/Application Support/Steam")
}
#[cfg(target_os = "windows")]
{
steam_install_dir_windows()
}
}
#[cfg(target_os = "windows")]
fn steam_install_dir_windows() -> PathBuf {
use winreg::RegKey;
use winreg::enums::HKEY_CURRENT_USER;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
if let Ok(key) = hkcu.open_subkey(r"Software\Valve\Steam") {
if let Ok(path) = key.get_value::<String, _>("SteamPath") {
return PathBuf::from(path);
}
}
PathBuf::from(r"C:\Program Files (x86)\Steam")
}
#[must_use]
pub fn steam_common() -> PathBuf {
steam_install_dir().join("steamapps/common")
}
#[must_use]
pub fn steam_library_folders() -> Vec<PathBuf> {
let vdf_path = steam_install_dir().join("steamapps/libraryfolders.vdf");
parse_library_folders_vdf(&vdf_path)
}
fn parse_library_folders_vdf(path: &Path) -> Vec<PathBuf> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return vec![],
};
let mut paths = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("\"path\"") {
let rest = rest.trim();
if let Some(val) = extract_vdf_string(rest) {
let lib_path = PathBuf::from(val);
if lib_path.exists() {
paths.push(lib_path);
}
}
}
}
let default_lib = steam_install_dir();
if !paths.iter().any(|p| p == &default_lib) && default_lib.exists() {
paths.insert(0, default_lib);
}
paths
}
fn extract_vdf_string(s: &str) -> Option<&str> {
let s = s.trim();
let s = s.strip_prefix('"')?;
let end = s.find('"')?;
Some(&s[..end])
}
#[must_use]
pub fn heroic_config_dir() -> Option<PathBuf> {
let dir = config_dir().join("heroic");
dir.is_dir().then_some(dir)
}
#[cfg(target_os = "windows")]
pub fn heroic_exe_path() -> Option<PathBuf> {
let local_app = dirs::data_local_dir()?;
let exe = local_app.join(r"Programs\heroic\Heroic.exe");
exe.exists().then_some(exe)
}
#[must_use]
pub fn db_path() -> PathBuf {
modde_data_dir().join("modde.db")
}
#[must_use]
pub fn modde_config_dir() -> PathBuf {
config_dir().join("modde")
}
#[must_use]
pub fn home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
#[cfg(unix)]
{
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()))
}
#[cfg(windows)]
{
PathBuf::from(std::env::var("USERPROFILE").unwrap_or_else(|_| r"C:\Temp".to_string()))
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn instance_registry_can_override_default_data_dir() {
let tmp = tempfile::tempdir().unwrap();
let instance_dir = tmp.path().join("instance-data");
let mut registry = crate::instance::InstanceRegistry::default();
registry.instances.push(crate::instance::Instance {
name: "portable".to_string(),
data_dir: instance_dir.clone(),
is_default: true,
});
registry.active = Some("portable".to_string());
let resolved = registry.active_data_dir().map(Path::to_path_buf);
assert_eq!(resolved, Some(instance_dir));
}
fn write_vdf(dir: &std::path::Path, content: &str) -> PathBuf {
let path = dir.join("libraryfolders.vdf");
std::fs::write(&path, content).unwrap();
path
}
#[test]
fn vdf_single_library() {
let tmp = tempfile::tempdir().unwrap();
let lib = tmp.path().join("steam");
std::fs::create_dir_all(&lib).unwrap();
let vdf = format!(
r#""libraryfolders"
{{
"0"
{{
"path" "{}"
}}
}}"#,
lib.display()
);
let vdf_path = write_vdf(tmp.path(), &vdf);
let paths = parse_library_folders_vdf(&vdf_path);
assert!(paths.contains(&lib), "expected {lib:?} in {paths:?}");
}
#[test]
fn vdf_multiple_libraries() {
let tmp = tempfile::tempdir().unwrap();
let lib1 = tmp.path().join("lib1");
let lib2 = tmp.path().join("lib2");
std::fs::create_dir_all(&lib1).unwrap();
std::fs::create_dir_all(&lib2).unwrap();
let vdf = format!(
"\"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}}",
lib1.display(),
lib2.display()
);
let vdf_path = write_vdf(tmp.path(), &vdf);
let paths = parse_library_folders_vdf(&vdf_path);
assert!(paths.contains(&lib1), "expected {lib1:?} in {paths:?}");
assert!(paths.contains(&lib2), "expected {lib2:?} in {paths:?}");
}
#[test]
fn vdf_nonexistent_paths_excluded() {
let tmp = tempfile::tempdir().unwrap();
let vdf = r#""libraryfolders"
{
"0" { "path" "/nonexistent/path/to/steam" }
}"#;
let vdf_path = write_vdf(tmp.path(), vdf);
let paths = parse_library_folders_vdf(&vdf_path);
assert!(
!paths
.iter()
.any(|p| p.to_string_lossy().contains("nonexistent"))
);
}
#[test]
fn vdf_missing_file_returns_empty() {
let paths =
parse_library_folders_vdf(std::path::Path::new("/nonexistent/libraryfolders.vdf"));
assert!(paths.is_empty());
}
#[test]
fn vdf_extra_fields_ignored() {
let tmp = tempfile::tempdir().unwrap();
let lib = tmp.path().join("steam");
std::fs::create_dir_all(&lib).unwrap();
let vdf = format!(
r#""libraryfolders"
{{
"0"
{{
"path" "{}"
"label" ""
"contentid" "12345"
"totalsize" "0"
"apps"
{{
"730" "12345"
}}
}}
}}"#,
lib.display()
);
let vdf_path = write_vdf(tmp.path(), &vdf);
let paths = parse_library_folders_vdf(&vdf_path);
assert!(paths.contains(&lib));
for p in &paths {
assert!(p.exists(), "all returned paths must exist");
}
}
#[test]
fn extract_vdf_string_basic() {
assert_eq!(extract_vdf_string(r#""hello""#), Some("hello"));
assert_eq!(
extract_vdf_string(r#" "with spaces" "#),
Some("with spaces")
);
assert_eq!(
extract_vdf_string(r#""/path/to/dir""#),
Some("/path/to/dir")
);
}
#[test]
fn extract_vdf_string_no_quotes() {
assert_eq!(extract_vdf_string("no quotes here"), None);
}
#[test]
fn extract_vdf_string_unclosed_quote() {
assert_eq!(extract_vdf_string(r#""unclosed"#), None);
}
}