doom-eternal 1.4.0

Rust CLI for the Xylex DOOM Eternal texture and install workflow
Documentation
use std::{
    fs::{self, File},
    io::{self, Write},
    path::{Path, PathBuf},
    process::Command,
};

use reqwest::Client;
use serde::Deserialize;

use crate::{
    config::RepoConfig,
    console::Console,
    error::{DoomError, Result},
    installer::download_injector_zip_to_cache,
    paths::{home_dir, RepoContext},
};

const SAMUEL_RELEASE_API: &str = "https://api.github.com/repos/brongo/SAMUEL/releases/latest";
const DEFAULT_SAMUEL_EXE_NAME: &str = "SAMUEL-v2.1.2.exe";
const DEFAULT_INJECTOR_ZIP_NAME: &str = "EternalModInjector.zip";
const DEFAULT_EXPORTS_DIR_NAME: &str = "exports";
const DEFAULT_MODEL_EXPORTS_DIR_NAME: &str = "modelExports";

#[derive(Debug, Deserialize)]
struct GithubRelease {
    tag_name: String,
    assets: Vec<GithubAsset>,
}

#[derive(Debug, Deserialize)]
struct GithubAsset {
    name: String,
    browser_download_url: String,
}

pub fn discover_idstudio_root(repo: &RepoContext) -> Option<PathBuf> {
    discover_root_from_env(["IDSTUDIO_MODS_ROOT", "IDSTUDIO_ROOT"])
        .or_else(|| RepoConfig::load_or_default(repo).discover_idstudio_root(repo))
}

pub fn discover_activemods_root(repo: &RepoContext) -> Option<PathBuf> {
    discover_root_from_env(["DOOM_ACTIVEMODS_ROOT", "ACTIVEMODS_ROOT"])
        .or_else(|| RepoConfig::load_or_default(repo).discover_activemods_root(repo))
}

pub fn discover_samuel_exe(tool_cache_root: &Path) -> Option<PathBuf> {
    cached_samuel_exe(tool_cache_root).or_else(|| {
        default_downloads_dir()
            .map(|downloads| downloads.join(DEFAULT_SAMUEL_EXE_NAME))
            .filter(|path| path.is_file())
    })
}

pub fn discover_injector_zip(tool_cache_root: &Path) -> Option<PathBuf> {
    cached_injector_zip(tool_cache_root).or_else(|| {
        default_downloads_dir()
            .map(|downloads| downloads.join(DEFAULT_INJECTOR_ZIP_NAME))
            .filter(|path| path.is_file())
    })
}

pub fn discover_samuel_export_root() -> Option<PathBuf> {
    default_downloads_dir()
        .map(|downloads| downloads.join(DEFAULT_EXPORTS_DIR_NAME))
        .filter(|path| path.is_dir())
}

pub fn discover_model_export_root() -> Option<PathBuf> {
    default_downloads_dir()
        .map(|downloads| downloads.join(DEFAULT_MODEL_EXPORTS_DIR_NAME))
        .filter(|path| path.is_dir())
}

pub async fn download_samuel(
    console: &Console,
    tool_cache_root: &Path,
    dry_run: bool,
) -> Result<PathBuf> {
    if let Some(existing) = cached_samuel_exe(tool_cache_root) {
        console.log_info(format!(
            "Using cached {} executable: {}",
            console.format_tool_name("SAMUEL"),
            console.format_path(&existing)
        ));
        return Ok(existing);
    }

    console.log_info(format!(
        "Fetching latest {} release metadata from GitHub...",
        console.format_tool_name("SAMUEL")
    ));
    let client = github_client()?;
    let release = client
        .get(SAMUEL_RELEASE_API)
        .send()
        .await?
        .error_for_status()?
        .json::<GithubRelease>()
        .await?;
    let asset = release
        .assets
        .iter()
        .find(|asset| asset.name.ends_with("Windows.zip"))
        .ok_or_else(|| {
            DoomError::message("Latest SAMUEL release does not expose a Windows zip asset.")
        })?;
    let version_root = tool_cache_root.join("samuel").join(&release.tag_name);
    let zip_path = version_root.join(&asset.name);
    let extract_root = version_root.join("extracted");

    if dry_run {
        console.log_dry_run(format!(
            "download {} -> {}",
            asset.browser_download_url,
            console.format_path(&zip_path)
        ));
        return Ok(extract_root.join("SAMUEL.exe"));
    }

    fs::create_dir_all(&version_root)?;
    if !zip_path.is_file() {
        console.log_info(format!(
            "Downloading {} release archive: {}",
            console.format_tool_name("SAMUEL"),
            console.format_path(&zip_path)
        ));
        download_to_path(&client, &asset.browser_download_url, &zip_path).await?;
    }
    if !extract_root.is_dir() || find_first_named_exe(&extract_root, "SAMUEL").is_none() {
        if extract_root.exists() {
            fs::remove_dir_all(&extract_root)?;
        }
        fs::create_dir_all(&extract_root)?;
        extract_zip(&zip_path, &extract_root)?;
    }
    let exe_path = find_first_named_exe(&extract_root, "SAMUEL").ok_or_else(|| {
        DoomError::message("Downloaded SAMUEL release did not contain a SAMUEL executable.")
    })?;
    console.log_success(format!(
        "Ready {} executable: {}",
        console.format_tool_name("SAMUEL"),
        console.format_path(&exe_path)
    ));
    Ok(exe_path)
}

pub async fn download_injector(
    console: &Console,
    tool_cache_root: &Path,
    injector_zip: Option<&Path>,
    item_id: i64,
    dry_run: bool,
) -> Result<PathBuf> {
    let archive_path =
        download_injector_zip_to_cache(console, tool_cache_root, injector_zip, item_id, dry_run)
            .await?;
    console.log_success(format!(
        "Ready {} archive: {}",
        console.format_tool_name("EternalModInjector"),
        console.format_path(&archive_path)
    ));
    Ok(archive_path)
}

pub fn launch_samuel(console: &Console, exe_path: &Path, dry_run: bool) -> Result<()> {
    if !exe_path.is_file() {
        return Err(DoomError::message(format!(
            "Missing SAMUEL executable: {}",
            console.shorten_path(exe_path)
        )));
    }
    if dry_run {
        console.log_dry_run(format!("launch {}", console.format_path(exe_path)));
        return Ok(());
    }
    console.log_info(format!(
        "Launching {}...",
        console.format_tool_name("SAMUEL")
    ));
    Command::new(exe_path)
        .current_dir(
            exe_path
                .parent()
                .ok_or_else(|| DoomError::message("SAMUEL executable has no parent directory."))?,
        )
        .spawn()?;
    Ok(())
}

fn discover_root_from_env<const N: usize>(variables: [&str; N]) -> Option<PathBuf> {
    for variable in variables {
        if let Some(value) = std::env::var_os(variable) {
            return Some(PathBuf::from(value));
        }
    }
    None
}

fn default_downloads_dir() -> Option<PathBuf> {
    home_dir().map(|home| home.join("Downloads"))
}

fn cached_samuel_exe(tool_cache_root: &Path) -> Option<PathBuf> {
    let samuel_root = tool_cache_root.join("samuel");
    let mut versions = fs::read_dir(&samuel_root)
        .ok()?
        .filter_map(|entry| entry.ok())
        .filter(|entry| entry.path().is_dir())
        .collect::<Vec<_>>();
    versions.sort_by_key(|entry| entry.file_name());
    versions.reverse();
    versions
        .into_iter()
        .find_map(|entry| find_first_named_exe(&entry.path(), "SAMUEL"))
}

fn cached_injector_zip(tool_cache_root: &Path) -> Option<PathBuf> {
    let injector_root = tool_cache_root.join("injector");
    let mut zips = list_files_with_extension(&injector_root, "zip");
    zips.sort();
    zips.pop()
}

fn list_files_with_extension(root: &Path, extension: &str) -> Vec<PathBuf> {
    fs::read_dir(root)
        .map(|entries| {
            entries
                .filter_map(|entry| entry.ok())
                .map(|entry| entry.path())
                .filter(|path| {
                    path.is_file()
                        && path
                            .extension()
                            .map(|value| value.to_string_lossy().eq_ignore_ascii_case(extension))
                            .unwrap_or(false)
                })
                .collect::<Vec<_>>()
        })
        .unwrap_or_default()
}

fn find_first_named_exe(root: &Path, stem_prefix: &str) -> Option<PathBuf> {
    if !root.exists() {
        return None;
    }

    let mut stack = vec![root.to_path_buf()];
    while let Some(path) = stack.pop() {
        let entries = fs::read_dir(&path).ok()?;
        for entry in entries.filter_map(|entry| entry.ok()) {
            let child = entry.path();
            if child.is_dir() {
                stack.push(child);
                continue;
            }
            let is_exe = child
                .extension()
                .map(|value| value.to_string_lossy().eq_ignore_ascii_case("exe"))
                .unwrap_or(false);
            let stem_matches = child
                .file_stem()
                .map(|value| value.to_string_lossy().starts_with(stem_prefix))
                .unwrap_or(false);
            if is_exe && stem_matches {
                return Some(child);
            }
        }
    }

    None
}

fn extract_zip(zip_path: &Path, destination: &Path) -> Result<()> {
    let file = File::open(zip_path)?;
    let mut archive = zip::ZipArchive::new(file)?;
    for index in 0..archive.len() {
        let mut item = archive.by_index(index)?;
        let enclosed = item
            .enclosed_name()
            .ok_or_else(|| DoomError::message("Archive contained an invalid path."))?;
        let output_path = destination.join(enclosed);
        if item.name().ends_with('/') {
            fs::create_dir_all(&output_path)?;
            continue;
        }
        if let Some(parent) = output_path.parent() {
            fs::create_dir_all(parent)?;
        }
        let mut output = File::create(&output_path)?;
        io::copy(&mut item, &mut output)?;
        output.flush()?;
    }
    Ok(())
}

fn github_client() -> Result<Client> {
    Ok(Client::builder().user_agent("doom-eternal/0.1.0").build()?)
}

async fn download_to_path(client: &Client, url: &str, destination: &Path) -> Result<()> {
    if let Some(parent) = destination.parent() {
        fs::create_dir_all(parent)?;
    }
    let mut response = client.get(url).send().await?.error_for_status()?;
    let mut file = File::create(destination)?;
    while let Some(chunk) = response.chunk().await? {
        file.write_all(&chunk)?;
    }
    file.flush()?;
    Ok(())
}