doom-eternal 1.4.0

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

use serde::{Deserialize, Serialize};

use crate::error::Result;

const CRAFT_CACHE_VERSION: u32 = 1;
const FILE_COMPARE_BUFFER_SIZE: usize = 64 * 1024;

#[derive(Debug, Clone, Serialize)]
pub struct FileStamp {
    pub path: String,
    pub size: u64,
    pub modified_unix_ms: u128,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedCraftTarget {
    pub fingerprint: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CraftCacheState {
    pub version: u32,
    pub jobs: BTreeMap<String, CachedCraftTarget>,
}

#[derive(Debug, Clone)]
pub struct TargetCachePaths {
    pub root: PathBuf,
    pub edited_texture: PathBuf,
    pub encoded_texture: PathBuf,
}

impl Default for CraftCacheState {
    fn default() -> Self {
        Self {
            version: CRAFT_CACHE_VERSION,
            jobs: BTreeMap::new(),
        }
    }
}

impl CraftCacheState {
    pub fn load(path: &Path) -> Self {
        let Ok(payload) = fs::read_to_string(path) else {
            return Self::default();
        };
        let Ok(state) = serde_json::from_str::<Self>(&payload) else {
            return Self::default();
        };
        if state.version == CRAFT_CACHE_VERSION {
            state
        } else {
            Self::default()
        }
    }

    pub fn save(&self, path: &Path) -> Result<()> {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)?;
        }
        fs::write(path, serde_json::to_string_pretty(self)?)?;
        Ok(())
    }

    pub fn fingerprint_for(&self, target_name: &str) -> Option<&str> {
        self.jobs
            .get(target_name)
            .map(|entry| entry.fingerprint.as_str())
    }

    pub fn update(&mut self, target_name: &str, fingerprint: String) {
        self.jobs
            .insert(target_name.to_string(), CachedCraftTarget { fingerprint });
    }
}

pub fn capture_file_stamp(path: &Path) -> Result<FileStamp> {
    let metadata = fs::metadata(path)?;
    let modified_unix_ms = metadata
        .modified()
        .ok()
        .and_then(|value| value.duration_since(UNIX_EPOCH).ok())
        .map(|value| value.as_millis())
        .unwrap_or_default();
    Ok(FileStamp {
        path: path.display().to_string(),
        size: metadata.len(),
        modified_unix_ms,
    })
}

pub fn hash_serialized<T: Serialize>(value: &T) -> Result<String> {
    let payload = serde_json::to_vec(value)?;
    Ok(format!("{:x}", md5::compute(payload)))
}

pub fn files_are_identical(left: &Path, right: &Path) -> Result<bool> {
    let left_metadata = fs::metadata(left)?;
    let right_metadata = fs::metadata(right)?;
    if left_metadata.len() != right_metadata.len() {
        return Ok(false);
    }

    let mut left_file = fs::File::open(left)?;
    let mut right_file = fs::File::open(right)?;
    let mut left_buffer = vec![0_u8; FILE_COMPARE_BUFFER_SIZE];
    let mut right_buffer = vec![0_u8; FILE_COMPARE_BUFFER_SIZE];

    loop {
        let left_read = left_file.read(&mut left_buffer)?;
        let right_read = right_file.read(&mut right_buffer)?;
        if left_read != right_read {
            return Ok(false);
        }
        if left_read == 0 {
            return Ok(true);
        }
        if left_buffer[..left_read] != right_buffer[..right_read] {
            return Ok(false);
        }
    }
}

pub fn target_cache_paths(
    cache_root: &Path,
    target_name: &str,
    edited_extension: &str,
    encoded_output_path: &Path,
) -> TargetCachePaths {
    let target_slug = target_name
        .chars()
        .map(|value| {
            if value.is_ascii_alphanumeric() || value == '-' || value == '_' {
                value
            } else {
                '_'
            }
        })
        .collect::<String>();
    let edited_extension = edited_extension.trim_start_matches('.');
    let encoded_extension = encoded_output_path
        .extension()
        .and_then(|value| value.to_str())
        .filter(|value| !value.is_empty())
        .unwrap_or("bin");
    let root = cache_root.join("targets").join(target_slug);
    TargetCachePaths {
        edited_texture: root.join(format!("edited.{edited_extension}")),
        encoded_texture: root.join(format!("encoded.{encoded_extension}")),
        root,
    }
}