doom-eternal 1.4.0

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

use image::{ImageBuffer, Rgba};
use serde_json::json;
use tempfile::TempDir;

fn cli_binary() -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_doom-eternal"))
}

#[test]
fn cli_imports_model_exports_into_editable_root() {
    let temp_dir = TempDir::new().expect("tempdir");
    let repo_root = temp_dir.path().join("repo");
    create_repo_scaffold(&repo_root);

    let manifest_path = repo_root
        .join("build")
        .join("rtx-editable-source-set16")
        .join("required-editable-textures.json");
    let editable_texture = repo_root
        .join("build")
        .join("rtx-editable-source-set16")
        .join("models")
        .join("customization")
        .join("characters")
        .join("doomslayer")
        .join("set16")
        .join("doomslayer_game_helmet_set16.png");
    write_manifest(&manifest_path, &editable_texture);

    let model_export_root = temp_dir.path().join("modelExports");
    let export_png = model_export_root
        .join("doomslayer_cine_3p")
        .join("images")
        .join("doomslayer_game_helmet.png");
    write_png(&export_png);

    let output = Command::new(cli_binary())
        .arg("--repo-root")
        .arg(&repo_root)
        .arg("import")
        .arg("--model-export-root")
        .arg(&model_export_root)
        .arg("--manifest")
        .arg(&manifest_path)
        .arg("--editable-root")
        .arg(repo_root.join("build").join("rtx-editable-source-set16"))
        .output()
        .expect("run import");

    assert!(
        output.status.success(),
        "stdout:\n{}\n\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(editable_texture.is_file());
    assert!(
        String::from_utf8_lossy(&output.stdout).contains("Imported 1 texture export(s)."),
        "stdout:\n{}",
        String::from_utf8_lossy(&output.stdout)
    );

    let second_output = Command::new(cli_binary())
        .arg("--repo-root")
        .arg(&repo_root)
        .arg("import")
        .arg("--model-export-root")
        .arg(&model_export_root)
        .arg("--manifest")
        .arg(&manifest_path)
        .arg("--editable-root")
        .arg(repo_root.join("build").join("rtx-editable-source-set16"))
        .output()
        .expect("run import again");

    assert!(
        second_output.status.success(),
        "stdout:\n{}\n\nstderr:\n{}",
        String::from_utf8_lossy(&second_output.stdout),
        String::from_utf8_lossy(&second_output.stderr)
    );
    assert!(
        String::from_utf8_lossy(&second_output.stdout)
            .contains("Reused 1 existing editable texture export(s)."),
        "stdout:\n{}",
        String::from_utf8_lossy(&second_output.stdout)
    );
}

#[test]
fn cli_stages_idstudio_tree_from_temp_output() {
    let temp_dir = TempDir::new().expect("tempdir");
    let repo_root = temp_dir.path().join("repo");
    create_repo_scaffold(&repo_root);

    let output_mod_root = repo_root.join("dist").join("xylex-rtx-slayer-pack");
    let editable_png = output_mod_root
        .join("editable")
        .join("models")
        .join("customization")
        .join("characters")
        .join("doomslayer")
        .join("set16")
        .join("doomslayer_game_helmet_set16.png");
    let generated_decl = output_mod_root
        .join("warehouse")
        .join("generated")
        .join("decls")
        .join("material2")
        .join("sample.decl");
    write_png(&editable_png);
    fs::create_dir_all(
        generated_decl
            .parent()
            .expect("generated decl should have parent"),
    )
    .expect("generated parent");
    fs::write(&generated_decl, "declType( material2 ) {}\n").expect("generated decl");

    let stage_root = temp_dir.path().join("idStudio").join("xylex-stage");
    let output = Command::new(cli_binary())
        .arg("--repo-root")
        .arg(&repo_root)
        .arg("stage-idstudio")
        .arg("--output-mod-root")
        .arg(&output_mod_root)
        .arg("--stage-root")
        .arg(&stage_root)
        .output()
        .expect("run stage-idstudio");

    assert!(
        output.status.success(),
        "stdout:\n{}\n\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(stage_root
        .join("base")
        .join("models")
        .join("customization")
        .join("characters")
        .join("doomslayer")
        .join("set16")
        .join("doomslayer_game_helmet_set16.tga")
        .is_file());
    assert!(stage_root
        .join("base")
        .join("generated")
        .join("decls")
        .join("material2")
        .join("sample.decl")
        .is_file());
    assert!(stage_root.join("xylex-idstudio-stage.json").is_file());
}

#[test]
fn cli_packs_export_mirror_into_chunked_archives() {
    let temp_dir = TempDir::new().expect("tempdir");
    let repo_root = temp_dir.path().join("repo");
    create_repo_scaffold(&repo_root);

    let exports_root = temp_dir.path().join("exports");
    let models_root = temp_dir.path().join("modelExports");
    write_png(&exports_root.join("warehouse").join("one.png"));
    write_png(&exports_root.join("warehouse").join("two.png"));
    write_png(
        &models_root
            .join("doomslayer_cine_3p")
            .join("images")
            .join("three.png"),
    );

    let output_root = repo_root.join("dist").join("samuel-export-mirror");
    let output = Command::new(cli_binary())
        .arg("--repo-root")
        .arg(&repo_root)
        .arg("mirror")
        .arg("pack")
        .arg("--samuel-export-root")
        .arg(&exports_root)
        .arg("--model-export-root")
        .arg(&models_root)
        .arg("--output-root")
        .arg(&output_root)
        .arg("--chunk-size-mb")
        .arg("1")
        .output()
        .expect("run mirror pack");

    assert!(
        output.status.success(),
        "stdout:\n{}\n\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(output_root.join("mirror-manifest.json").is_file());
    assert!(output_root.join("mirror-part-0001.zip").is_file());
}

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");
}

fn write_manifest(manifest_path: &Path, editable_texture: &Path) {
    fs::create_dir_all(
        manifest_path
            .parent()
            .expect("manifest should have parent directory"),
    )
    .expect("manifest parent");
    let manifest = json!({
        "message": "test import",
        "sourceSet": "set52",
        "targetSet": "set16",
        "exports": [
            {
                "target": "third-person-helmet",
                "relativeTexture": "models/customization/characters/doomslayer/set16/doomslayer_game_helmet_set16.tga",
                "sourceTexture": "C:/test/source.tga",
                "editableTexture": editable_texture,
            }
        ]
    });
    fs::write(
        manifest_path,
        serde_json::to_string_pretty(&manifest).expect("serialize manifest"),
    )
    .expect("write manifest");
}

fn write_png(path: &Path) {
    fs::create_dir_all(path.parent().expect("png should have parent")).expect("png parent");
    let image = ImageBuffer::<Rgba<u8>, Vec<u8>>::from_pixel(2, 2, Rgba([255, 255, 255, 255]));
    image.save(path).expect("save png");
}