doom-eternal 1.4.0

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

use crate::paths::{is_within, normalize_path};

const ANSI_RESET: &str = "\u{1b}[0m";
const ANSI_BOLD: &str = "\u{1b}[1m";
const ANSI_BLUE: &str = "\u{1b}[34m";
const ANSI_CYAN: &str = "\u{1b}[36m";
const ANSI_GREEN: &str = "\u{1b}[32m";
const ANSI_MAGENTA: &str = "\u{1b}[35m";
const ANSI_RED: &str = "\u{1b}[31m";
const ANSI_WHITE: &str = "\u{1b}[37m";
const ANSI_YELLOW: &str = "\u{1b}[33m";
const PROGRESS_BUCKETS: u64 = 20;
const PROGRESS_UNKNOWN_STEP: u64 = 4 * 1024 * 1024;
const SLAYER_SIGIL_LINES: &[&str] = &[
    r"                 /\",
    r"                /  \",
    r"           /\  / /\ \  /\",
    r"          /  \/ /  \ \/  \",
    r"         / /\  / /\ \  /\ \",
    r"        /_/ /_/ /  \ \_\ \_\",
    r"            / / /\ \ \",
    r"           /_/ /  \ \_\",
    r"              / /\ \",
    r"             /_/  \_\",
];
const XYLEX_WORDMARK_LINES: &[&str] = &[
    r"__   __ __   __  _       _____  __   __",
    r"\ \ / / \ \ / / | |     | ____| \ \ / /",
    r" \ V /   \ V /  | |     |  _|    \ V / ",
    r" /   \    | |   | |___  | |___   /   \ ",
    r"/_/ \_\   |_|   |_____| |_____| /_/ \_\",
];

#[derive(Debug, Clone)]
pub struct Console {
    repo_root: PathBuf,
    ansi_enabled: bool,
}

impl Console {
    pub fn new(repo_root: PathBuf) -> Self {
        let ansi_enabled = std::io::stdout().is_terminal()
            && std::env::var_os("NO_COLOR").is_none()
            && std::env::var("TERM")
                .map(|value| value != "dumb")
                .unwrap_or(true);
        Self {
            repo_root,
            ansi_enabled,
        }
    }

    pub fn print_startup_banner(&self) {
        let lines = self.render_startup_banner();
        if lines.is_empty() {
            return;
        }
        for line in lines {
            println!("{line}");
        }
        println!();
    }

    pub fn shorten_path(&self, path: impl AsRef<Path>) -> String {
        let candidate = path.as_ref();
        let absolute = if candidate.is_absolute() {
            candidate.to_path_buf()
        } else {
            self.repo_root.join(candidate)
        };
        let resolved = normalize_path(&absolute).unwrap_or(absolute);
        if is_within(&resolved, &self.repo_root) {
            if let Ok(relative) = resolved.strip_prefix(&self.repo_root) {
                return format!("./{}", path_to_forward_slashes(relative));
            }
        }
        resolved.display().to_string()
    }

    pub fn format_path(&self, path: impl AsRef<Path>) -> String {
        self.ansi(&self.shorten_path(path), &[ANSI_CYAN])
    }

    pub fn format_tool_name(&self, name: &str) -> String {
        self.ansi(name, &[ANSI_BOLD, ANSI_MAGENTA])
    }

    pub fn format_label(&self, label: &str) -> String {
        self.ansi(label, &[ANSI_BOLD, ANSI_MAGENTA])
    }

    pub fn log_info(&self, message: impl AsRef<str>) {
        self.print_line(
            self.ansi("[install]", &[ANSI_BOLD, ANSI_BLUE]),
            message.as_ref(),
        );
    }

    pub fn log_dry_run(&self, message: impl AsRef<str>) {
        self.print_line(
            self.ansi("[dry-run]", &[ANSI_BOLD, ANSI_YELLOW]),
            message.as_ref(),
        );
    }

    pub fn log_success(&self, message: impl AsRef<str>) {
        self.print_line(
            self.ansi("[done]", &[ANSI_BOLD, ANSI_GREEN]),
            message.as_ref(),
        );
    }

    pub fn log_error(&self, message: impl AsRef<str>) {
        eprintln!(
            "{} {}",
            self.ansi("[error]", &[ANSI_BOLD, ANSI_RED]),
            message.as_ref()
        );
    }

    pub fn ansi(&self, text: &str, codes: &[&str]) -> String {
        if !self.ansi_enabled || codes.is_empty() {
            return text.to_string();
        }
        format!("{}{}{}", codes.join(""), text, ANSI_RESET)
    }

    fn print_line(&self, prefix: String, message: &str) {
        println!("{prefix} {message}");
    }

    fn render_startup_banner(&self) -> Vec<String> {
        if !self.ansi_enabled {
            return Vec::new();
        }
        let mut lines = SLAYER_SIGIL_LINES
            .iter()
            .map(|line| self.ansi(line, &[ANSI_BOLD, ANSI_RED]))
            .collect::<Vec<_>>();
        lines.push(String::new());
        lines.extend(
            XYLEX_WORDMARK_LINES
                .iter()
                .map(|line| self.ansi(line, &[ANSI_BOLD, ANSI_WHITE])),
        );
        lines
    }
}

#[derive(Debug, Clone, Copy)]
pub enum ProgressFormat {
    Raw,
    Bytes,
    FileCount,
}

impl ProgressFormat {
    fn render(self, value: u64) -> String {
        match self {
            Self::Raw => value.to_string(),
            Self::Bytes => format_bytes(value),
            Self::FileCount => format_file_count(value),
        }
    }
}

#[derive(Debug)]
pub struct ProgressReporter<'a> {
    console: &'a Console,
    label: String,
    total: u64,
    current: u64,
    last_bucket: i64,
    last_unknown_mark: u64,
    format: ProgressFormat,
    unknown_step: u64,
}

impl<'a> ProgressReporter<'a> {
    pub fn new(
        console: &'a Console,
        label: impl Into<String>,
        total: u64,
        format: ProgressFormat,
    ) -> Self {
        Self {
            console,
            label: label.into(),
            total,
            current: 0,
            last_bucket: -1,
            last_unknown_mark: 0,
            format,
            unknown_step: PROGRESS_UNKNOWN_STEP,
        }
    }

    pub fn update(&mut self, current: u64, force: bool) {
        self.current = current;
        self.emit(force);
    }

    pub fn advance(&mut self, amount: u64) {
        self.update(self.current.saturating_add(amount), false);
    }

    pub fn finish(&mut self) {
        if self.total > 0 {
            if self.current < self.total || self.last_bucket < PROGRESS_BUCKETS as i64 {
                self.update(self.total, true);
            }
            return;
        }
        self.emit(true);
    }

    fn emit(&mut self, force: bool) {
        if self.total > 0 {
            let bucket = self
                .current
                .saturating_mul(PROGRESS_BUCKETS)
                .checked_div(self.total)
                .unwrap_or(0)
                .min(PROGRESS_BUCKETS);
            if !force && bucket as i64 <= self.last_bucket && self.current < self.total {
                return;
            }
            self.last_bucket = bucket as i64;
            let percent = if self.total == 0 {
                0.0
            } else {
                (self.current as f64 / self.total as f64) * 100.0
            };
            let filled = bucket as usize;
            let empty = PROGRESS_BUCKETS as usize - filled;
            let bar = format!("{}{}", "#".repeat(filled), "-".repeat(empty));
            let completed = self.current >= self.total;
            let bar_color = if completed { ANSI_GREEN } else { ANSI_CYAN };
            self.console.log_info(format!(
                "{}: [{}] {} ({} / {})",
                self.console.format_label(&self.label),
                self.console.ansi(&bar, &[bar_color]),
                self.console.ansi(&format!("{percent:5.1}%"), &[bar_color]),
                self.format.render(self.current),
                self.format.render(self.total),
            ));
            return;
        }

        if !force && self.current.saturating_sub(self.last_unknown_mark) < self.unknown_step {
            return;
        }

        self.last_unknown_mark = self.current;
        self.console.log_info(format!(
            "{}: {}",
            self.console.format_label(&self.label),
            self.format.render(self.current),
        ));
    }
}

pub fn format_bytes(size: u64) -> String {
    let mut value = size as f64;
    let units = ["B", "KiB", "MiB", "GiB"];
    for unit in units {
        if value < 1024.0 || unit == *units.last().unwrap() {
            if unit == "B" {
                return format!("{} {}", value as u64, unit);
            }
            return format!("{value:.1} {unit}");
        }
        value /= 1024.0;
    }
    format!("{size} B")
}

pub fn format_file_count(count: u64) -> String {
    if count == 1 {
        "1 file".to_string()
    } else {
        format!("{count} files")
    }
}

fn path_to_forward_slashes(path: &Path) -> String {
    path.components()
        .map(|component| component.as_os_str().to_string_lossy().to_string())
        .collect::<Vec<_>>()
        .join("/")
}

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

    use super::{
        Console, ANSI_BOLD, ANSI_RED, ANSI_WHITE, SLAYER_SIGIL_LINES, XYLEX_WORDMARK_LINES,
    };

    #[test]
    fn startup_banner_renders_sigil_and_wordmark_when_ansi_is_enabled() {
        let console = Console {
            repo_root: PathBuf::from("C:/repo"),
            ansi_enabled: true,
        };
        let lines = console.render_startup_banner();

        assert_eq!(
            lines.len(),
            SLAYER_SIGIL_LINES.len() + 1 + XYLEX_WORDMARK_LINES.len()
        );
        assert!(lines[..SLAYER_SIGIL_LINES.len()]
            .iter()
            .all(|line| line.starts_with(ANSI_BOLD) && line.contains(ANSI_RED)));
        assert!(lines[SLAYER_SIGIL_LINES.len()].is_empty());
        assert!(lines[(SLAYER_SIGIL_LINES.len() + 1)..]
            .iter()
            .all(|line| line.starts_with(ANSI_BOLD) && line.contains(ANSI_WHITE)));
        assert!(lines.iter().any(|line| line.contains("/\\")));
        assert!(lines.iter().any(|line| line.contains("_____")));
    }

    #[test]
    fn startup_banner_is_suppressed_without_ansi() {
        let console = Console {
            repo_root: PathBuf::from("C:/repo"),
            ansi_enabled: false,
        };

        assert!(console.render_startup_banner().is_empty());
    }
}