zarust 0.2.0

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,
    extract::{Totals, measure},
    ui::{Reporter, counted, display_path, format_bytes, format_duration},
};

pub fn list(
    reporter: &mut Reporter,
    archive: &Path,
    path: Option<OsString>,
    options: &Options,
) -> Outcome {
    let reader = super::open_archive(archive)?;
    let (root, prefix) = match &path {
        Some(path) => {
            let text = path.to_string_lossy();
            let node = reader.lookup(text.as_bytes()).ok_or_else(|| {
                Failure::new(format!("`{text}` is not in `{}`", display_path(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).map_err(Failure::from)?;
        reporter.line(format_args!("{:>10}  {prefix}", format_bytes(size as f64)))?;
        return Ok(());
    }

    if options.tree {
        reporter.line(format_args!(
            "{}{}{}",
            reporter.palette().bold,
            if prefix.is_empty() { "." } else { &prefix },
            reporter.palette().reset
        ))?;
        print_tree(reporter, &reader, root, &mut String::new())?;
    } else {
        print_flat(reporter, &reader, root, &prefix)?;
    }

    let mut totals = Totals::default();
    measure(&reader, root, &mut totals)?;
    reporter.info(format_args!(
        "{dim}{}, {} · {}{reset}",
        counted(totals.files, "file", "files"),
        counted(totals.directories, "directory", "directories"),
        format_bytes(totals.bytes as f64),
        dim = reporter.palette().dim,
        reset = reporter.palette().reset,
    ))
}

/// One entry per line with a right-aligned size, so the output stays greppable.
fn print_flat<R: Read + Seek>(
    reporter: &mut Reporter,
    reader: &ArchiveReader<R>,
    directory: NodeHandle,
    prefix: &str,
) -> Outcome {
    let count = reader.directory_len(directory).map_err(Failure::from)?;
    for index in 0..count {
        let entry = reader
            .directory_entry(directory, index)
            .map_err(Failure::from)?;
        let name = String::from_utf8_lossy(entry.name);
        let path = if prefix.is_empty() {
            name.into_owned()
        } else {
            format!("{prefix}/{name}")
        };
        let p = reporter.palette();
        match entry.kind {
            EntryKind::Directory => {
                reporter.line(format_args!(
                    "{d}{:>10}{r}  {c}{path}/{r}",
                    "-",
                    d = p.dim,
                    c = p.cyan,
                    r = p.reset
                ))?;
                print_flat(reporter, reader, entry.handle, &path)?;
            }
            EntryKind::File => reporter.line(format_args!(
                "{y}{:>10}{r}  {path}",
                format_bytes(entry.size as f64),
                y = p.yellow,
                r = p.reset
            ))?,
        }
    }
    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,
) -> Outcome {
    let count = reader.directory_len(directory).map_err(Failure::from)?;
    for index in 0..count {
        let entry = reader
            .directory_entry(directory, index)
            .map_err(Failure::from)?;
        let last = index + 1 == count;
        let branch = if last { "└── " } else { "├── " };
        let name = String::from_utf8_lossy(entry.name);
        let p = reporter.palette();
        match entry.kind {
            EntryKind::Directory => {
                reporter.line(format_args!(
                    "{d}{indent}{branch}{r}{c}{b}{name}/{r}",
                    d = p.dim,
                    c = p.cyan,
                    b = p.bold,
                    r = p.reset
                ))?;
                let width = indent.len();
                indent.push_str(if last { "    " } else { "" });
                print_tree(reporter, reader, entry.handle, indent)?;
                indent.truncate(width);
            }
            EntryKind::File => reporter.line(format_args!(
                "{d}{indent}{branch}{r}{name}  {y}{}{r}",
                format_bytes(entry.size as f64),
                d = p.dim,
                y = p.yellow,
                r = p.reset
            ))?,
        }
    }
    Ok(())
}

pub fn info(reporter: &mut Reporter, archive: &Path) -> Outcome {
    let reader = super::open_archive(archive)?;
    let mut totals = Totals::default();
    measure(&reader, ROOT_NODE, &mut totals)?;

    let stored = reader.archive_size();
    let compressed = reader.compressed_data_size();
    let ratio = if totals.bytes == 0 {
        100.0
    } else {
        compressed as f64 * 100.0 / totals.bytes as f64
    };

    let p = reporter.palette();
    reporter.line(format_args!(
        "{c}{b}{}{r}",
        display_path(archive),
        c = p.cyan,
        b = p.bold,
        r = p.reset
    ))?;
    // `value` carries its own color so numbers stay the emphasis of each row.
    let mut field = |name: &str, value: String| -> Outcome {
        reporter.line(format_args!(
            "  {d}{name:<13}{r}{value}",
            d = p.dim,
            r = p.reset
        ))
    };
    let number = |text: String| format!("{}{text}{}", p.yellow, p.reset);
    field("files", number(totals.files.to_string()))?;
    field("directories", number(totals.directories.to_string()))?;
    field("contents", number(format_bytes(totals.bytes as f64)))?;
    field(
        "compressed",
        format!(
            "{} {d}({ratio:.1}% of contents){r}",
            number(format_bytes(compressed as f64)),
            d = p.dim,
            r = p.reset
        ),
    )?;
    field("archive", number(format_bytes(stored as f64)))?;
    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()
        .map_err(|error| Failure::new(format!("cannot read `{}`: {error}", display_path(archive))));
    reporter.finish_bar();

    let palette = reporter.palette();
    match intact? {
        true => {
            reporter.status(
                palette.green,
                "ok",
                format_args!(
                    "{bold}{}{reset}",
                    display_path(archive),
                    bold = palette.bold,
                    reset = palette.reset
                ),
            )?;
            reporter.sub(format_args!(
                "{} hashed in {}",
                format_bytes(size as f64),
                format_duration(Some(started.elapsed()))
            ))?;
            Ok(())
        }
        false => Err(Failure::new(format!(
            "`{}` does not match its integrity hash",
            display_path(archive)
        ))),
    }
}