zarust 0.2.0

Rust implementation of the ZArchive format
Documentation
//! The `zarust` command-line interface, a filesystem adapter over the library.

pub mod args;
pub mod extract;
pub mod inspect;
pub mod pack;
pub mod ui;

use std::{
    fs::File,
    io::{self, BufReader},
    path::Path,
};

use zarust::ArchiveReader;

use crate::cli::{
    args::{Command, Invocation, UsageError},
    ui::Reporter,
};

/// Buffer size for reading archives; matches the format's 64 KiB block size
/// several times over so block reads rarely hit the syscall boundary.
const READ_BUFFER: usize = 256 * 1024;

/// Anything that stops a command after the command line has been understood.
#[derive(Debug)]
pub struct Failure(pub String);

impl Failure {
    pub fn new(message: impl Into<String>) -> Self {
        Self(message.into())
    }
}

impl From<io::Error> for Failure {
    fn from(error: io::Error) -> Self {
        Self(error.to_string())
    }
}

impl From<zarust::Error> for Failure {
    fn from(error: zarust::Error) -> Self {
        Self(error.to_string())
    }
}

pub type Outcome = Result<(), Failure>;

/// Process exit codes, kept small and positive so shells can act on them.
pub mod exit {
    pub const SUCCESS: u8 = 0;
    pub const FAILURE: u8 = 1;
    pub const USAGE: u8 = 2;
}

pub fn open_archive(path: &Path) -> Result<ArchiveReader<BufReader<File>>, Failure> {
    let file = File::open(path)
        .map_err(|error| Failure::new(format!("cannot open `{}`: {error}", path.display())))?;
    ArchiveReader::new(BufReader::with_capacity(READ_BUFFER, file))
        .map_err(|error| Failure::new(format!("cannot read `{}`: {error}", path.display())))
}

/// Runs one invocation, returning the process exit code.
pub fn run(invocation: Invocation) -> u8 {
    let Invocation { command, options } = invocation;
    let mut reporter = Reporter::new(options.color, options.verbosity, options.progress);

    let outcome = match command {
        Command::Pack { input, output } => pack::run(&mut reporter, &input, output, &options),
        Command::Extract { archive, output } => {
            extract::run(&mut reporter, &archive, output, &options)
        }
        Command::List { archive, path } => inspect::list(&mut reporter, &archive, path, &options),
        Command::Info { archive } => inspect::info(&mut reporter, &archive),
        Command::Verify { archive } => inspect::verify(&mut reporter, &archive),
        Command::Help => help(&mut reporter),
        Command::Version => version(&mut reporter),
    };

    // Flush before reporting so the error is the last thing on screen.
    let flushed = reporter.flush();
    match outcome.and_then(|()| flushed.map_err(Failure::from)) {
        Ok(()) => exit::SUCCESS,
        Err(failure) => {
            report_error(&failure.0);
            exit::FAILURE
        }
    }
}

/// Prints a usage error and the hint to reach real help.
pub fn report_usage(error: &UsageError) -> u8 {
    report_error(&error.0);
    eprintln!("Try `zarust --help` for the list of commands.");
    exit::USAGE
}

fn report_error(message: &str) {
    let palette = ui::stderr_palette(ui::ColorChoice::Auto);
    eprintln!(
        "{}{}error{}: {message}",
        palette.red, palette.bold, palette.reset
    );
}

fn version(reporter: &mut Reporter) -> Outcome {
    reporter.line(format_args!(
        "{} {}",
        env!("CARGO_PKG_NAME"),
        env!("CARGO_PKG_VERSION")
    ))
}

/// Width of the left column in the command and option tables.
const HELP_COLUMN: usize = 26;

fn help(reporter: &mut Reporter) -> Outcome {
    let p = reporter.palette();

    reporter.line(format_args!(
        "{bold}{cyan}zarust{reset} {dim}{}{reset}  —  read and write ZArchive {dim}(.zar / .wua){reset} files",
        env!("CARGO_PKG_VERSION"),
        bold = p.bold,
        cyan = p.cyan,
        dim = p.dim,
        reset = p.reset,
    ))?;

    reporter.line(format_args!("\n{}{}USAGE{}", p.yellow, p.bold, p.reset))?;
    reporter.line(format_args!(
        "  {}zarust{} {}<command>{} {}[options]{}",
        p.magenta, p.reset, p.bold, p.reset, p.dim, p.reset
    ))?;

    reporter.line(format_args!("\n{}{}COMMANDS{}", p.yellow, p.bold, p.reset))?;
    for (name, arguments, description) in [
        (
            "pack",
            "<dir> [archive]",
            "Create an archive from a directory",
        ),
        (
            "extract",
            "<archive> [dir]",
            "Extract an archive into a directory",
        ),
        ("list", "<archive> [path]", "List the entries in an archive"),
        ("info", "<archive>", "Show sizes and the compression ratio"),
        ("verify", "<archive>", "Check the stored integrity hash"),
    ] {
        // Pad by visible width; the escapes around the name have none.
        let pad = HELP_COLUMN.saturating_sub(name.len() + 1 + arguments.len());
        reporter.line(format_args!(
            "  {m}{b}{name}{r} {d}{arguments}{r}{:pad$}{description}",
            "",
            m = p.magenta,
            b = p.bold,
            d = p.dim,
            r = p.reset,
        ))?;
    }

    reporter.line(format_args!("\n{}{}OPTIONS{}", p.yellow, p.bold, p.reset))?;
    for (flag, description) in [
        ("-o, --output <path>", "Output path, instead of positional"),
        ("-f, --force", "Overwrite an existing output path"),
        ("-v, --verbose", "Print every entry as it is processed"),
        ("-q, --quiet", "Print errors only"),
        ("    --tree", "Render `list` as a tree"),
        ("    --check", "Verify integrity before extracting"),
        ("    --no-progress", "Never draw the progress bar"),
        (
            "    --color <when>",
            "auto, always, or never (default auto)",
        ),
        ("-h, --help", "Show this help"),
        ("-V, --version", "Show the version"),
    ] {
        reporter.line(format_args!(
            "  {c}{flag:<HELP_COLUMN$}{r}{description}",
            c = p.cyan,
            r = p.reset,
        ))?;
    }
    reporter.line(format_args!(
        "\n{dim}Given a bare path, zarust packs a directory or extracts an archive, matching{reset}",
        dim = p.dim,
        reset = p.reset,
    ))?;
    reporter.line(format_args!(
        "{dim}the original ZArchive tool: `zarust game/` makes game.zar, and{reset}",
        dim = p.dim,
        reset = p.reset,
    ))?;
    reporter.line(format_args!(
        "{dim}`zarust game.zar` unpacks it into game_extracted/.{reset}",
        dim = p.dim,
        reset = p.reset,
    ))
}