zarust 0.2.1

Rust implementation of the ZArchive format
Documentation
//! `zarust list`, `info`, and `verify` — read-only queries over an archive.

use std::{
    ffi::OsString,
    io::{Read, Seek},
    path::Path,
    time::Instant,
};

use zarust::{ArchiveReader, EntryKind, NodeHandle, ROOT_NODE};

use crate::cli::{
    Failure, Outcome,
    args::Options,
    path,
    ui::{Palette, Reporter, format_bytes, format_duration},
    walk::{Summary, ratio},
};

pub fn list(
    reporter: &mut Reporter,
    archive: &Path,
    subpath: Option<OsString>,
    options: &Options,
) -> Outcome {
    let reader = super::open_archive(archive)?;
    let (root, prefix) = match &subpath {
        Some(subpath) => {
            let text = subpath.to_string_lossy();
            let node = reader.lookup(text.as_bytes()).ok_or_else(|| {
                Failure::new(format!("`{text}` is not in `{}`", path::display(archive)))
            })?;
            (node, text.trim_matches('/').to_owned())
        }
        None => (ROOT_NODE, String::new()),
    };

    if reader.entry_kind(root) == Some(EntryKind::File) {
        let size = reader.file_size(root)?;
        return reporter.line(format_args!("{:>10}  {prefix}", format_bytes(size as f64)));
    }

    // The listing already visits every entry, so it tallies as it goes rather
    // than walking the whole tree a second time.
    let mut summary = Summary::default();
    if options.tree {
        let Palette { bold, reset, .. } = reporter.palette();
        let root_name = if prefix.is_empty() { "." } else { &prefix };
        reporter.line(format_args!("{bold}{root_name}{reset}"))?;
        print_tree(reporter, &reader, root, &mut String::new(), &mut summary)?;
    } else {
        let mut prefix = prefix;
        print_flat(reporter, &reader, root, &mut prefix, &mut summary)?;
    }

    let Palette { dim, reset, .. } = reporter.palette();
    reporter.info(format_args!("{dim}{}{reset}", summary.describe()))
}

/// One entry per line with a right-aligned size, so the output stays greppable.
///
/// `prefix` is a single buffer pushed and truncated down the recursion, rather
/// than a fresh `String` built for every entry.
fn print_flat<R: Read + Seek>(
    reporter: &mut Reporter,
    reader: &ArchiveReader<R>,
    directory: NodeHandle,
    prefix: &mut String,
    summary: &mut Summary,
) -> Outcome {
    let palette = reporter.palette();
    for entry in reader.directory_entries(directory)? {
        let restore = prefix.len();
        if !prefix.is_empty() {
            prefix.push('/');
        }
        prefix.push_str(&String::from_utf8_lossy(entry.name));
        match entry.kind {
            EntryKind::Directory => {
                summary.directory();
                reporter.line(format_args!(
                    "{d}{:>10}{r}  {c}{prefix}/{r}",
                    "-",
                    d = palette.dim,
                    c = palette.cyan,
                    r = palette.reset
                ))?;
                print_flat(reporter, reader, entry.handle, prefix, summary)?;
            }
            EntryKind::File => {
                summary.file(entry.size);
                reporter.line(format_args!(
                    "{y}{:>10}{r}  {prefix}",
                    format_bytes(entry.size as f64),
                    y = palette.yellow,
                    r = palette.reset
                ))?;
            }
        }
        prefix.truncate(restore);
    }
    Ok(())
}

/// A box-drawing tree for reading at a glance.
fn print_tree<R: Read + Seek>(
    reporter: &mut Reporter,
    reader: &ArchiveReader<R>,
    directory: NodeHandle,
    indent: &mut String,
    summary: &mut Summary,
) -> Outcome {
    let palette = reporter.palette();
    let entries = reader.directory_entries(directory)?;
    let last_index = entries.len().saturating_sub(1);
    for (index, entry) in entries.iter().enumerate() {
        let last = index == last_index;
        let branch = if last { "└── " } else { "├── " };
        let name = String::from_utf8_lossy(entry.name);
        match entry.kind {
            EntryKind::Directory => {
                summary.directory();
                reporter.line(format_args!(
                    "{d}{indent}{branch}{r}{c}{b}{name}/{r}",
                    d = palette.dim,
                    c = palette.cyan,
                    b = palette.bold,
                    r = palette.reset
                ))?;
                let restore = indent.len();
                indent.push_str(if last { "    " } else { "" });
                print_tree(reporter, reader, entry.handle, indent, summary)?;
                indent.truncate(restore);
            }
            EntryKind::File => {
                summary.file(entry.size);
                reporter.line(format_args!(
                    "{d}{indent}{branch}{r}{name}  {y}{}{r}",
                    format_bytes(entry.size as f64),
                    d = palette.dim,
                    y = palette.yellow,
                    r = palette.reset
                ))?;
            }
        }
    }
    Ok(())
}

pub fn info(reporter: &mut Reporter, archive: &Path) -> Outcome {
    let reader = super::open_archive(archive)?;
    let summary = Summary::of(&reader, ROOT_NODE)?;
    let compressed = reader.compressed_data_size();

    let p = reporter.palette();
    reporter.line(format_args!(
        "{c}{b}{}{r}",
        path::display(archive),
        c = p.cyan,
        b = p.bold,
        r = p.reset
    ))?;

    // Values carry their own color so the numbers stay the emphasis of each row.
    let number = |text: String| format!("{}{text}{}", p.yellow, p.reset);
    let rows = [
        ("files", number(summary.files.to_string())),
        ("directories", number(summary.directories.to_string())),
        ("contents", number(format_bytes(summary.bytes as f64))),
        (
            "compressed",
            format!(
                "{} {d}({:.1}% of contents){r}",
                number(format_bytes(compressed as f64)),
                ratio(compressed, summary.bytes),
                d = p.dim,
                r = p.reset
            ),
        ),
        (
            "archive",
            number(format_bytes(reader.archive_size() as f64)),
        ),
    ];
    for (name, value) in rows {
        reporter.line(format_args!(
            "  {d}{name:<13}{r}{value}",
            d = p.dim,
            r = p.reset
        ))?;
    }
    Ok(())
}

pub fn verify(reporter: &mut Reporter, archive: &Path) -> Outcome {
    let mut reader = super::open_archive(archive)?;
    let started = Instant::now();
    let size = reader.archive_size();

    reporter.start_bar("verifying", size);
    let intact = reader.verify_integrity_with(|read| reporter.advance(read));
    reporter.finish_bar();

    if !intact.map_err(|error| Failure::path("read", archive, error))? {
        return Err(Failure::new(format!(
            "`{}` does not match its integrity hash",
            path::display(archive)
        )));
    }
    reporter.success(
        "ok",
        archive,
        &format!(
            "{} hashed in {}",
            format_bytes(size as f64),
            format_duration(Some(started.elapsed()))
        ),
    )
}