doom-eternal 1.4.0

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

use chrono::Local;
use image::ImageFormat;
use serde::Serialize;

use crate::{
    console::Console,
    context::AppContext,
    error::{DoomError, Result},
    repo_state::{build_repo_state, RepoStateOptions},
    sets::find_set_tokens,
    tools::discover_idstudio_root,
};

#[derive(Debug, Clone)]
pub struct StageIdStudioRequest {
    pub output_mod_root: Option<PathBuf>,
    pub stage_root: Option<PathBuf>,
    pub idstudio_root: Option<PathBuf>,
    pub slug: Option<String>,
    pub dry_run: bool,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct IdStudioStageManifest {
    slug: String,
    source_output_root: String,
    stage_root: String,
    base_root: String,
    editable_texture_count: usize,
    generated_file_count: usize,
    created_at: String,
}

pub fn stage_idstudio_mod(ctx: &AppContext, request: StageIdStudioRequest) -> Result<PathBuf> {
    let state = build_repo_state(
        ctx,
        RepoStateOptions {
            output_mod_root: request.output_mod_root.clone(),
            ..RepoStateOptions::default()
        },
    );
    let output_mod_root = request
        .output_mod_root
        .clone()
        .unwrap_or_else(|| state.output_mod_root.clone());
    let resolved_output_root = ctx.repo().repo_path(&output_mod_root);
    let stage_root = resolve_stage_root(ctx, &request, &resolved_output_root);
    stage_output_tree(
        ctx.console(),
        &resolved_output_root,
        &stage_root,
        request.dry_run,
    )?;
    Ok(stage_root)
}

fn resolve_stage_root(
    ctx: &AppContext,
    request: &StageIdStudioRequest,
    output_root: &Path,
) -> PathBuf {
    if let Some(stage_root) = request.stage_root.as_deref() {
        return ctx.repo().repo_path(stage_root);
    }

    let stage_parent = request
        .idstudio_root
        .as_deref()
        .map(|path| ctx.repo().repo_path(path))
        .or_else(|| discover_idstudio_root(ctx.repo()))
        .unwrap_or_else(|| ctx.repo().root().join("build").join("idstudio"));
    let slug = request
        .slug
        .clone()
        .unwrap_or_else(|| infer_slug(output_root));
    stage_parent.join(slug)
}

fn infer_slug(output_root: &Path) -> String {
    output_root
        .file_name()
        .map(|value| value.to_string_lossy().to_string())
        .filter(|value| !value.trim().is_empty())
        .unwrap_or_else(|| "xylex-rtx-slayer-pack".to_string())
}

fn stage_output_tree(
    console: &Console,
    output_root: &Path,
    stage_root: &Path,
    dry_run: bool,
) -> Result<()> {
    let editable_root = output_root.join("editable");
    if !editable_root.is_dir() {
        return Err(DoomError::message(format!(
            "Missing crafted editable output: {}. Run craft before staging for idStudio.",
            console.shorten_path(&editable_root)
        )));
    }

    let warehouse_root = output_root.join("warehouse");
    if !warehouse_root.is_dir() {
        return Err(DoomError::message(format!(
            "Missing crafted warehouse output: {}. Run craft before staging for idStudio.",
            console.shorten_path(&warehouse_root)
        )));
    }

    let base_root = stage_root.join("base");
    console.log_info(format!(
        "Staging {} base folder: {}",
        console.format_tool_name("idStudio"),
        console.format_path(&base_root)
    ));

    if dry_run {
        console.log_dry_run(format!(
            "reset staged base folder {}",
            console.format_path(&base_root)
        ));
    } else {
        if base_root.exists() {
            fs::remove_dir_all(&base_root)?;
        }
        fs::create_dir_all(&base_root)?;
    }

    let editable_sets = detect_editable_sets(&editable_root);
    let generated_source = warehouse_root.join("generated");
    let generated_dest = base_root.join("generated");
    let generated_file_count = if generated_source.is_dir() {
        copy_tree(
            console,
            &generated_source,
            &generated_dest,
            &editable_sets,
            dry_run,
        )?
    } else {
        0
    };

    let editable_texture_count =
        stage_editable_textures(console, &editable_root, &base_root, dry_run)?;
    if editable_texture_count == 0 {
        return Err(DoomError::message(format!(
            "No staged editable textures were found under {}.",
            console.shorten_path(&editable_root)
        )));
    }

    let manifest = IdStudioStageManifest {
        slug: infer_slug(stage_root),
        source_output_root: output_root.display().to_string(),
        stage_root: stage_root.display().to_string(),
        base_root: base_root.display().to_string(),
        editable_texture_count,
        generated_file_count,
        created_at: Local::now().to_rfc3339(),
    };
    let manifest_path = stage_root.join("xylex-idstudio-stage.json");
    if dry_run {
        console.log_dry_run(format!(
            "write stage manifest {}",
            console.format_path(&manifest_path)
        ));
    } else {
        fs::create_dir_all(stage_root)?;
        fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?)?;
    }

    console.log_success(format!(
        "Staged {} editable texture(s) and {} generated file(s) into {}",
        editable_texture_count,
        generated_file_count,
        console.format_path(&base_root)
    ));
    console.log_info(
        "Next step: open idStudio, package from this base folder, then publish through My Creations in the DOOM Eternal launcher.",
    );
    Ok(())
}

fn copy_tree(
    console: &Console,
    source_dir: &Path,
    destination_dir: &Path,
    editable_sets: &[String],
    dry_run: bool,
) -> Result<usize> {
    let mut copied = 0_usize;
    for entry in walkdir::WalkDir::new(source_dir)
        .into_iter()
        .filter_map(|entry| entry.ok())
    {
        if entry.file_type().is_dir() || !is_stageable_generated_file(entry.path(), editable_sets) {
            continue;
        }
        let relative = entry.path().strip_prefix(source_dir)?;
        let destination = destination_dir.join(relative);
        if dry_run {
            console.log_dry_run(format!(
                "copy {} -> {}",
                console.format_path(entry.path()),
                console.format_path(&destination)
            ));
        } else {
            if let Some(parent) = destination.parent() {
                fs::create_dir_all(parent)?;
            }
            fs::copy(entry.path(), &destination)?;
        }
        copied += 1;
    }
    Ok(copied)
}

fn detect_editable_sets(editable_root: &Path) -> Vec<String> {
    let mut sets = std::collections::BTreeSet::new();
    for entry in walkdir::WalkDir::new(editable_root)
        .into_iter()
        .filter_map(|entry| entry.ok())
    {
        if !entry.file_type().is_file() || !is_editable_image(entry.path()) {
            continue;
        }
        let path_text = entry.path().to_string_lossy();
        for set_name in find_set_tokens(&path_text) {
            sets.insert(set_name);
        }
    }
    sets.into_iter().collect()
}

fn stage_editable_textures(
    console: &Console,
    editable_root: &Path,
    base_root: &Path,
    dry_run: bool,
) -> Result<usize> {
    let mut staged = 0_usize;
    for entry in walkdir::WalkDir::new(editable_root)
        .into_iter()
        .filter_map(|entry| entry.ok())
    {
        if !entry.file_type().is_file() || !is_editable_image(entry.path()) {
            continue;
        }
        let relative = entry.path().strip_prefix(editable_root)?;
        let destination = base_root.join(relative).with_extension("tga");
        if dry_run {
            console.log_dry_run(format!(
                "convert {} -> {}",
                console.format_path(entry.path()),
                console.format_path(&destination)
            ));
        } else {
            if let Some(parent) = destination.parent() {
                fs::create_dir_all(parent)?;
            }
            let image = image::open(entry.path())?;
            image.save_with_format(&destination, ImageFormat::Tga)?;
        }
        staged += 1;
    }
    Ok(staged)
}

fn is_editable_image(path: &Path) -> bool {
    path.extension()
        .map(|value| value.to_string_lossy().to_ascii_lowercase())
        .map(|extension| {
            matches!(
                extension.as_str(),
                "png" | "tga" | "dds" | "tif" | "tiff" | "bmp"
            )
        })
        .unwrap_or(false)
}

fn is_stageable_generated_file(path: &Path, editable_sets: &[String]) -> bool {
    let extension_allowed = path
        .extension()
        .map(|value| value.to_string_lossy().to_ascii_lowercase())
        .map(|extension| extension != "code-workspace")
        .unwrap_or(true);
    if !extension_allowed {
        return false;
    }

    let set_tokens = find_set_tokens(path.to_string_lossy());
    if set_tokens.is_empty() {
        return true;
    }

    set_tokens
        .iter()
        .any(|set_name| editable_sets.contains(set_name))
}

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

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

    use crate::{console::Console, error::Result};

    use super::stage_output_tree;

    #[test]
    fn stages_editable_textures_and_generated_files() -> Result<()> {
        let temp_dir = TempDir::new().expect("tempdir");
        let output_root = temp_dir.path().join("dist").join("xylex-rtx-slayer-pack");
        let editable_file = output_root
            .join("editable")
            .join("models")
            .join("customization")
            .join("characters")
            .join("doomslayer")
            .join("set16")
            .join("doomslayer_game_torso_set16.png");
        let generated_file = output_root
            .join("warehouse")
            .join("generated")
            .join("decls")
            .join("material2")
            .join("sample.decl");
        fs::create_dir_all(
            editable_file
                .parent()
                .expect("editable file should have parent"),
        )
        .expect("editable parent");
        fs::create_dir_all(
            generated_file
                .parent()
                .expect("generated file should have parent"),
        )
        .expect("generated parent");
        let image = ImageBuffer::<Rgba<u8>, Vec<u8>>::from_pixel(2, 2, Rgba([255, 0, 0, 255]));
        image.save(&editable_file).expect("png save");
        fs::write(&generated_file, "declType( material2 ) {}\n").expect("decl");

        let console = Console::new(temp_dir.path().to_path_buf());
        let stage_root = temp_dir
            .path()
            .join("id Software")
            .join("idStudio")
            .join("xylex");
        stage_output_tree(&console, &output_root, &stage_root, false)?;

        assert!(stage_root
            .join("base")
            .join("models")
            .join("customization")
            .join("characters")
            .join("doomslayer")
            .join("set16")
            .join("doomslayer_game_torso_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());
        Ok(())
    }
}