doom-eternal 1.4.0

Rust CLI for the Xylex DOOM Eternal texture and install workflow
Documentation
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");
    }
}