doom-eternal 0.1.0

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

use image::DynamicImage;

use crate::{
    context::AppContext,
    error::{DoomError, Result},
    logos::ensure_default_logo_assets,
    placements::{composite_logo, load_config},
};

pub fn apply_logos_to_armor(
    ctx: &AppContext,
    armor_texture: impl AsRef<Path>,
    output_path: impl AsRef<Path>,
    placement_config: impl AsRef<Path>,
    logo_dir: impl AsRef<Path>,
    target: &str,
) -> Result<()> {
    let armor_path = ctx.repo().repo_path(armor_texture);
    let config_path = ctx.repo().repo_path(placement_config);
    let logo_root = ctx.repo().repo_path(logo_dir);
    let output_file = ctx.repo().repo_path(output_path);

    if !armor_path.exists() {
        return Err(DoomError::message(format!(
            "Missing armor texture: {}",
            armor_path.display()
        )));
    }

    if !logo_root.exists() {
        ensure_default_logo_assets(ctx, &logo_root)?;
    }

    let config = load_config(ctx, config_path)?;
    let placements = config
        .placements
        .into_iter()
        .filter(|placement| placement.target == target && placement.is_enabled())
        .collect::<Vec<_>>();

    let mut result = image::open(&armor_path)?.to_rgba8();
    for placement in &placements {
        let logo_path = logo_root.join(&placement.logo);
        if !logo_path.exists() {
            return Err(DoomError::message(format!(
                "Missing prepared logo: {}",
                logo_path.display()
            )));
        }
        let logo = image::open(&logo_path)?.to_rgba8();
        composite_logo(&mut result, &logo, placement);
        println!("Applied {} with {}", placement.id, placement.logo);
    }

    if let Some(parent) = output_file.parent() {
        fs::create_dir_all(parent)?;
    }
    DynamicImage::ImageRgba8(result).save(&output_file)?;
    println!("Wrote {}", output_file.display());
    Ok(())
}

pub fn build_workpack(
    ctx: &AppContext,
    output_path: impl AsRef<Path>,
    armor_texture: Option<PathBuf>,
    target: &str,
) -> Result<()> {
    let workpack_dir = ctx.repo().root().join("build").join("workpack");
    let output_file = ctx.repo().repo_path(output_path);

    ensure_default_logo_assets(ctx, PathBuf::from("build/logos"))?;

    if let Some(armor_texture) = armor_texture {
        apply_logos_to_armor(
            ctx,
            armor_texture,
            PathBuf::from("build/textures/slayer_armor_albedo_xylex.png"),
            PathBuf::from("config/armor-logo-placements.json"),
            PathBuf::from("build/logos"),
            target,
        )?;
    }

    if workpack_dir.exists() {
        assert_under_repo(ctx, &workpack_dir)?;
        fs::remove_dir_all(&workpack_dir)?;
    }

    fs::create_dir_all(&workpack_dir)?;
    fs::copy(ctx.repo().root().join("README.md"), workpack_dir.join("README.md"))?;
    fs::copy(
        ctx.repo().root().join("config").join("armor-logo-placements.json"),
        workpack_dir.join("armor-logo-placements.json"),
    )?;
    fs::copy(
        ctx.repo().root().join("mod").join("EternalMod.json"),
        workpack_dir.join("EternalMod.json"),
    )?;
    copy_contents(&ctx.repo().root().join("build").join("logos"), &workpack_dir.join("logos"))?;
    copy_contents(&ctx.repo().root().join("docs"), &workpack_dir.join("docs"))?;
    copy_contents(
        &ctx.repo().root().join("build").join("textures"),
        &workpack_dir.join("textures"),
    )?;

    zip_directory(&workpack_dir, &output_file)?;
    println!("Wrote {}", output_file.display());
    Ok(())
}

fn copy_contents(source_dir: &Path, destination_dir: &Path) -> Result<()> {
    if !source_dir.exists() {
        return Ok(());
    }
    fs::create_dir_all(destination_dir)?;
    for entry in fs::read_dir(source_dir)? {
        let entry = entry?;
        let source_path = entry.path();
        let destination = destination_dir.join(entry.file_name());
        if source_path.is_dir() {
            copy_dir_recursive(&source_path, &destination)?;
        } else {
            fs::copy(&source_path, &destination)?;
        }
    }
    Ok(())
}

fn copy_dir_recursive(source: &Path, destination: &Path) -> Result<()> {
    fs::create_dir_all(destination)?;
    for entry in fs::read_dir(source)? {
        let entry = entry?;
        let child_source = entry.path();
        let child_destination = destination.join(entry.file_name());
        if child_source.is_dir() {
            copy_dir_recursive(&child_source, &child_destination)?;
        } else {
            if let Some(parent) = child_destination.parent() {
                fs::create_dir_all(parent)?;
            }
            fs::copy(&child_source, &child_destination)?;
        }
    }
    Ok(())
}

fn zip_directory(source_dir: &Path, output_file: &Path) -> Result<()> {
    if let Some(parent) = output_file.parent() {
        fs::create_dir_all(parent)?;
    }
    if output_file.exists() {
        fs::remove_file(output_file)?;
    }
    let file = fs::File::create(output_file)?;
    let mut archive = zip::ZipWriter::new(file);
    let options = zip::write::SimpleFileOptions::default()
        .compression_method(zip::CompressionMethod::Deflated);

    for entry in walkdir::WalkDir::new(source_dir).into_iter().filter_map(|entry| entry.ok()) {
        if !entry.file_type().is_file() {
            continue;
        }
        let relative = entry.path().strip_prefix(source_dir)?;
        archive.start_file(relative.to_string_lossy().replace('\\', "/"), options)?;
        archive.write_all(&fs::read(entry.path())?)?;
    }
    archive.finish()?;
    Ok(())
}

fn assert_under_repo(ctx: &AppContext, path: &Path) -> Result<()> {
    let resolved_path = ctx.repo().normalize_path(path)?;
    let resolved_root = ctx.repo().normalize_path(ctx.repo().root())?;
    if !resolved_path.starts_with(&resolved_root) {
        return Err(DoomError::message(format!(
            "Refusing to modify path outside repo: {}",
            resolved_path.display()
        )));
    }
    Ok(())
}