use std::{
fs,
path::{Path, PathBuf},
};
use serde::Deserialize;
use crate::paths::{expand_home, home_dir, RepoContext};
pub const CONFIG_FILE_NAME: &str = "config.yaml";
const DEFAULT_GAME_FOLDER: &str = "DOOMEternal";
const DEFAULT_ID_SOFTWARE_DIR: &str = "id Software";
const DEFAULT_STEAM_LIBRARY_ROOTS: &[&str] = &[
"C:/Program Files (x86)/Steam",
"C:/Program Files/Steam",
"D:/SteamLibrary",
"E:/SteamLibrary",
"F:/SteamLibrary",
];
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct RepoConfig {
#[serde(rename = "idSoftware")]
pub id_software: Option<PathBuf>,
pub steam: Option<SteamConfig>,
pub paths: Option<PathOverrides>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct SteamConfig {
pub library_roots: Vec<PathBuf>,
pub game_folder: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct PathOverrides {
pub game_root: Option<PathBuf>,
pub id_studio_root: Option<PathBuf>,
pub activemods_root: Option<PathBuf>,
}
impl RepoConfig {
pub fn load(repo: &RepoContext) -> Option<Self> {
let path = repo.root().join(CONFIG_FILE_NAME);
if !path.is_file() {
return None;
}
let text = fs::read_to_string(&path).ok()?;
serde_yaml::from_str(&text).ok()
}
pub fn load_or_default(repo: &RepoContext) -> Self {
Self::load(repo).unwrap_or_default()
}
pub fn game_root_candidates(&self, repo: &RepoContext) -> Vec<PathBuf> {
if let Some(path) = self
.paths
.as_ref()
.and_then(|paths| paths.game_root.as_ref())
{
return vec![resolve_config_path(repo, path)];
}
let game_folder = self
.steam
.as_ref()
.and_then(|steam| steam.game_folder.as_deref())
.unwrap_or(DEFAULT_GAME_FOLDER);
self.steam_library_roots(repo)
.into_iter()
.map(|root| root.join("steamapps").join("common").join(game_folder))
.collect()
}
pub fn discover_idstudio_root(&self, repo: &RepoContext) -> Option<PathBuf> {
if let Some(path) = self
.paths
.as_ref()
.and_then(|paths| paths.id_studio_root.as_ref())
{
return Some(resolve_config_path(repo, path));
}
self.id_software_root(repo)
.map(|root| root.join("idStudio"))
}
pub fn discover_activemods_root(&self, repo: &RepoContext) -> Option<PathBuf> {
if let Some(path) = self
.paths
.as_ref()
.and_then(|paths| paths.activemods_root.as_ref())
{
return Some(resolve_config_path(repo, path));
}
self.id_software_root(repo)
.map(|root| root.join("activemods"))
}
fn id_software_root(&self, repo: &RepoContext) -> Option<PathBuf> {
if let Some(path) = self.id_software.as_ref() {
return Some(resolve_config_path(repo, path));
}
default_id_software_root()
}
fn steam_library_roots(&self, repo: &RepoContext) -> Vec<PathBuf> {
if let Some(roots) = self
.steam
.as_ref()
.map(|steam| &steam.library_roots)
.filter(|roots| !roots.is_empty())
{
return roots
.iter()
.map(|root| resolve_config_path(repo, root))
.collect();
}
default_steam_library_roots()
}
}
pub fn resolve_config_path(repo: &RepoContext, path: &Path) -> PathBuf {
let expanded = expand_home(path);
if expanded.is_absolute() {
expanded
} else {
repo.root().join(expanded)
}
}
fn default_steam_library_roots() -> Vec<PathBuf> {
DEFAULT_STEAM_LIBRARY_ROOTS
.iter()
.map(|root| PathBuf::from(*root))
.collect()
}
fn default_id_software_root() -> Option<PathBuf> {
let documents = home_dir()?.join("Documents");
let root = documents.join(DEFAULT_ID_SOFTWARE_DIR);
if root.exists() || documents.exists() {
return Some(root);
}
None
}
#[cfg(test)]
mod tests {
use std::{fs, path::Path};
use tempfile::TempDir;
use super::{RepoConfig, CONFIG_FILE_NAME};
use crate::paths::RepoContext;
#[test]
fn derives_steam_game_root_from_library_roots() {
let temp_dir = TempDir::new().expect("tempdir");
let repo_root = temp_dir.path();
create_repo_scaffold(repo_root);
fs::write(
repo_root.join(CONFIG_FILE_NAME),
r#"
idSoftware: ~/Documents/id Software
steam:
libraryRoots:
- D:/SteamLibrary
- E:/Games/Steam
gameFolder: DOOMEternal
"#,
)
.expect("write config");
let repo = RepoContext::from_anchor(repo_root);
let config = RepoConfig::load(&repo).expect("load config");
let candidates = config.game_root_candidates(&repo);
assert_eq!(candidates.len(), 2);
assert_eq!(
normalize_path_text(&candidates[0]),
"D:/SteamLibrary/steamapps/common/DOOMEternal"
);
assert_eq!(
normalize_path_text(&candidates[1]),
"E:/Games/Steam/steamapps/common/DOOMEternal"
);
}
#[test]
fn derives_idstudio_and_activemods_from_id_software_root() {
let temp_dir = TempDir::new().expect("tempdir");
let repo_root = temp_dir.path();
create_repo_scaffold(repo_root);
fs::write(
repo_root.join(CONFIG_FILE_NAME),
"idSoftware: C:/Users/test/Documents/id Software\n",
)
.expect("write config");
let repo = RepoContext::from_anchor(repo_root);
let config = RepoConfig::load(&repo).expect("load config");
assert_eq!(
config
.discover_idstudio_root(&repo)
.as_deref()
.map(normalize_path_text),
Some("C:/Users/test/Documents/id Software/idStudio".to_string())
);
assert_eq!(
config
.discover_activemods_root(&repo)
.as_deref()
.map(normalize_path_text),
Some("C:/Users/test/Documents/id Software/activemods".to_string())
);
}
fn normalize_path_text(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn create_repo_scaffold(repo_root: &Path) {
fs::create_dir_all(repo_root.join("config")).expect("config dir");
fs::create_dir_all(repo_root.join("mod")).expect("mod dir");
fs::create_dir_all(repo_root.join("assets").join("source").join("logos"))
.expect("logos dir");
fs::write(
repo_root
.join("config")
.join("rtx-pack-logo-placements.json"),
"{}\n",
)
.expect("placement config");
fs::write(repo_root.join("mod").join("EternalMod.json"), "{}\n").expect("mod metadata");
}
}