doom-eternal 1.4.0

Rust CLI for the Xylex DOOM Eternal texture and install workflow
Documentation
use std::{
    ffi::OsString,
    fs,
    path::{Path, PathBuf},
};

use crate::{
    console::Console,
    context::AppContext,
    error::{DoomError, Result},
};

const DEFAULT_SOURCE_BINARY: &str = "target/release/doom-eternal.exe";
const DEFAULT_OUTPUT_ROOT: &str = "build/workflow-runtime";
const DEFAULT_RUNTIME_FOLDER: &str = "doom-eternal";
const LEGACY_RUNTIME_FOLDER: &str = "xylex_doom_cli";

#[derive(Debug, Clone)]
pub struct StageWorkflowRuntimeRequest {
    pub source_binary: PathBuf,
    pub output_root: PathBuf,
    pub runtime_folder: String,
    pub dry_run: bool,
}

impl Default for StageWorkflowRuntimeRequest {
    fn default() -> Self {
        Self {
            source_binary: PathBuf::from(DEFAULT_SOURCE_BINARY),
            output_root: PathBuf::from(DEFAULT_OUTPUT_ROOT),
            runtime_folder: DEFAULT_RUNTIME_FOLDER.to_string(),
            dry_run: false,
        }
    }
}

pub fn stage_workflow_runtime(
    ctx: &AppContext,
    request: StageWorkflowRuntimeRequest,
) -> Result<PathBuf> {
    let source_binary = ctx.repo().repo_path(request.source_binary);
    let output_root = ctx.repo().repo_path(request.output_root);
    stage_workflow_runtime_files(
        ctx.console(),
        &source_binary,
        &output_root,
        &request.runtime_folder,
        request.dry_run,
    )
}

fn stage_workflow_runtime_files(
    console: &Console,
    source_binary: &Path,
    output_root: &Path,
    runtime_folder: &str,
    dry_run: bool,
) -> Result<PathBuf> {
    if runtime_folder.trim().is_empty() {
        return Err(DoomError::message(
            "Workflow runtime folder name cannot be empty.",
        ));
    }

    if !source_binary.is_file() {
        return Err(DoomError::message(format!(
            "Missing workflow runtime source binary: {}. Build it first with `cargo build --release -p doom-eternal`.",
            console.shorten_path(source_binary)
        )));
    }

    let runtime_root = output_root.join(runtime_folder);
    let staged_binary = runtime_root.join(binary_name(source_binary));
    let legacy_runtime_root = output_root.join(LEGACY_RUNTIME_FOLDER);

    console.log_info(format!(
        "Staging bundled workflow runtime from {} into {}",
        console.format_path(source_binary),
        console.format_path(&runtime_root)
    ));

    if dry_run {
        if runtime_root.exists() {
            console.log_dry_run(format!(
                "reset runtime folder {}",
                console.format_path(&runtime_root)
            ));
        }
        if legacy_runtime_root.exists() && legacy_runtime_root != runtime_root {
            console.log_dry_run(format!(
                "remove legacy runtime folder {}",
                console.format_path(&legacy_runtime_root)
            ));
        }
        console.log_dry_run(format!(
            "copy {} -> {}",
            console.format_path(source_binary),
            console.format_path(&staged_binary)
        ));
        return Ok(staged_binary);
    }

    if runtime_root.exists() {
        fs::remove_dir_all(&runtime_root)?;
    }
    if legacy_runtime_root.exists() && legacy_runtime_root != runtime_root {
        fs::remove_dir_all(&legacy_runtime_root)?;
    }

    fs::create_dir_all(&runtime_root)?;
    fs::copy(source_binary, &staged_binary)?;
    console.log_success(format!(
        "Bundled workflow runtime: {}",
        console.format_path(&staged_binary)
    ));
    Ok(staged_binary)
}

fn binary_name(source_binary: &Path) -> OsString {
    source_binary
        .file_name()
        .map(OsString::from)
        .unwrap_or_else(|| OsString::from("doom-eternal.exe"))
}

#[cfg(test)]
mod tests {
    use std::fs;

    use tempfile::TempDir;

    use crate::console::Console;

    use super::stage_workflow_runtime_files;

    #[test]
    fn stages_runtime_binary_and_cleans_legacy_folder() {
        let temp_dir = TempDir::new().expect("tempdir");
        let source_binary = temp_dir
            .path()
            .join("target")
            .join("release")
            .join("doom-eternal.exe");
        let source_parent = source_binary
            .parent()
            .expect("source binary should have parent");
        fs::create_dir_all(source_parent).expect("source parent");
        fs::write(&source_binary, b"doom-eternal").expect("source binary");

        let output_root = temp_dir.path().join("build").join("workflow-runtime");
        let legacy_root = output_root.join("xylex_doom_cli");
        fs::create_dir_all(&legacy_root).expect("legacy root");
        fs::write(legacy_root.join("stale.txt"), b"stale").expect("legacy file");

        let console = Console::new(temp_dir.path().to_path_buf());
        let staged_binary = stage_workflow_runtime_files(
            &console,
            &source_binary,
            &output_root,
            "doom-eternal",
            false,
        )
        .expect("stage runtime");

        assert!(staged_binary.is_file());
        assert_eq!(
            fs::read(&staged_binary).expect("staged binary"),
            b"doom-eternal"
        );
        assert!(!legacy_root.exists());
    }

    #[test]
    fn errors_when_source_binary_is_missing() {
        let temp_dir = TempDir::new().expect("tempdir");
        let console = Console::new(temp_dir.path().to_path_buf());
        let missing_binary = temp_dir
            .path()
            .join("target")
            .join("release")
            .join("missing.exe");
        let output_root = temp_dir.path().join("build").join("workflow-runtime");
        let error = stage_workflow_runtime_files(
            &console,
            &missing_binary,
            &output_root,
            "doom-eternal",
            false,
        )
        .expect_err("missing source should fail");

        assert!(error
            .to_string()
            .contains("Missing workflow runtime source binary"));
    }
}