doom-eternal 1.4.0

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

use crate::{
    error::{DoomError, Result},
    paths::RepoContext,
    sets::parse_set_token,
};

pub fn discover_local_source_candidates(repo: &RepoContext) -> Vec<PathBuf> {
    let mut candidates = Vec::new();
    let entries = match fs::read_dir(repo.root()) {
        Ok(entries) => entries,
        Err(_) => return candidates,
    };

    for entry in entries.filter_map(|entry| entry.ok()) {
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        let lower_name = path
            .file_name()
            .map(|value| value.to_string_lossy().to_ascii_lowercase())
            .unwrap_or_default();
        if lower_name == "warehouse" || path.join("warehouse").is_dir() {
            candidates.push(path);
        }
    }

    candidates.sort_by_key(|path| path.to_string_lossy().to_ascii_lowercase());
    candidates.truncate(5);
    candidates
}

pub fn resolve_source_warehouse(
    repo: &RepoContext,
    source_path: impl AsRef<Path>,
) -> Result<(PathBuf, PathBuf)> {
    let resolved = repo.repo_path(source_path);
    if !resolved.exists() {
        let suggestions = discover_local_source_candidates(repo);
        let mut hint = String::new();
        if !suggestions.is_empty() {
            hint.push_str("\nAvailable local source-pack candidates:\n");
            hint.push_str(
                &suggestions
                    .iter()
                    .map(|candidate| format!("- {}", candidate.display()))
                    .collect::<Vec<_>>()
                    .join("\n"),
            );
        }
        return Err(DoomError::message(format!(
            "Missing source path: {}{}",
            resolved.display(),
            hint
        )));
    }

    let warehouse_path = resolved.join("warehouse");
    if warehouse_path.is_dir() {
        return Ok((warehouse_path, resolved));
    }

    if resolved.is_dir()
        && resolved
            .file_name()
            .map(|value| value.to_string_lossy().eq_ignore_ascii_case("warehouse"))
            .unwrap_or(false)
    {
        let parent = resolved
            .parent()
            .map(Path::to_path_buf)
            .unwrap_or_else(|| resolved.clone());
        return Ok((resolved, parent));
    }

    Err(DoomError::message(format!(
        "Expected a folder that contains 'warehouse/' or a direct 'warehouse' folder. Got: {}",
        resolved.display()
    )))
}

pub fn detect_source_set(warehouse_root: &Path) -> Result<String> {
    let slayer_dir = warehouse_root
        .join("models")
        .join("customization")
        .join("characters")
        .join("doomslayer");
    if !slayer_dir.exists() {
        return Err(DoomError::message(format!(
            "Missing Slayer texture root: {}",
            slayer_dir.display()
        )));
    }

    let mut set_dirs = fs::read_dir(&slayer_dir)?
        .filter_map(|entry| entry.ok())
        .map(|entry| entry.path())
        .filter(|path| path.is_dir())
        .collect::<Vec<_>>();
    set_dirs.sort_by_key(|path| path.to_string_lossy().to_ascii_lowercase());

    for set_dir in set_dirs {
        let Some(set_name) = set_dir
            .file_name()
            .and_then(|value| parse_set_token(value.to_string_lossy()))
        else {
            continue;
        };
        let torso = set_dir.join(format!("doomslayer_game_torso_{set_name}.tga"));
        if torso.exists() {
            return Ok(set_name);
        }
    }

    Err(DoomError::message(format!(
        "Could not detect a Slayer set from: {}",
        slayer_dir.display()
    )))
}