zarust 0.2.1

Rust implementation of the ZArchive format
Documentation
//! `zarust pack` — build an archive from a directory tree.

use std::{
    ffi::OsString,
    fs::{self, File, OpenOptions},
    io::{BufReader, BufWriter, Read, Write},
    path::{Path, PathBuf},
    time::Instant,
};

use zarust::ArchiveWriter;

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

pub fn run(
    reporter: &mut Reporter,
    input: &Path,
    output: Option<PathBuf>,
    options: &Options,
) -> Outcome {
    if !input.is_dir() {
        return Err(Failure::new(format!(
            "`{}` is not a directory",
            path::display(input)
        )));
    }
    let output = output.unwrap_or_else(|| default_output(input));
    prepare_output(&output, options.force)?;

    let started = Instant::now();
    let plan = Plan::of(input, &output)?;

    let file = OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(&output)
        .map_err(|error| Failure::path("create", &output, error))?;
    let mut writer = ArchiveWriter::new(BufWriter::new(file));

    reporter.start_bar("packing", plan.summary.bytes);
    let result = write_entries(reporter, &mut writer, input, &plan.entries)
        .and_then(|()| writer.finish().map_err(Failure::from))
        .and_then(|mut inner| inner.flush().map_err(Failure::from));
    reporter.finish_bar();
    if let Err(failure) = result {
        // A partial archive is worse than none.
        let _ = fs::remove_file(&output);
        return Err(failure);
    }

    let packed = fs::metadata(&output).map(|meta| meta.len()).unwrap_or(0);
    reporter.success(
        "packed",
        &output,
        &format!(
            "{}{} ({:.0}%) in {}",
            plan.summary.describe(),
            format_bytes(packed as f64),
            ratio(packed, plan.summary.bytes),
            format_duration(Some(started.elapsed())),
        ),
    )
}

/// `game/` becomes `game.zar` beside it.
fn default_output(input: &Path) -> PathBuf {
    // Canonicalize so a trailing `.` or `..` still yields a real directory name.
    let mut name = input
        .canonicalize()
        .ok()
        .as_deref()
        .and_then(Path::file_name)
        .unwrap_or_else(|| input.file_name().unwrap_or_default())
        .to_os_string();
    name.push(".zar");
    path::sibling(input, name)
}

fn prepare_output(output: &Path, force: bool) -> Result<(), Failure> {
    if output.is_dir() {
        return Err(Failure::new(format!(
            "`{}` is a directory, not an archive path",
            path::display(output)
        )));
    }
    if output.exists() {
        if !force {
            return Err(Failure::new(format!(
                "`{}` already exists (use --force to overwrite)",
                path::display(output)
            )));
        }
        fs::remove_file(output).map_err(|error| Failure::path("replace", output, error))?;
    }
    if let Some(parent) = output
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
    {
        fs::create_dir_all(parent).map_err(|error| Failure::path("create", parent, error))?;
    }
    Ok(())
}

/// Every entry to write, gathered before the archive is opened so the progress
/// bar has a real total and the write itself is a single pass.
struct Plan {
    entries: Vec<Entry>,
    summary: Summary,
}

/// Only the path relative to the pack root is kept: it is what the archive
/// stores, what error messages show, and what rejoins the root to be opened.
struct Entry {
    relative: PathBuf,
    is_directory: bool,
}

impl Plan {
    fn of(root: &Path, output: &Path) -> Result<Self, Failure> {
        let mut plan = Self {
            entries: Vec::new(),
            summary: Summary::default(),
        };
        plan.walk(root, root, &OutputFilter::new(output))?;
        Ok(plan)
    }

    fn walk(
        &mut self,
        root: &Path,
        directory: &Path,
        output: &OutputFilter,
    ) -> Result<(), Failure> {
        let listing =
            fs::read_dir(directory).map_err(|error| Failure::path("read", directory, error))?;
        for entry in listing {
            let entry = entry.map_err(|error| Failure::path("read", directory, error))?;
            let source = entry.path();
            if output.matches(&source) {
                continue;
            }
            let kind = entry
                .file_type()
                .map_err(|error| Failure::path("stat", &source, error))?;
            let relative = source.strip_prefix(root).unwrap_or(&source).to_path_buf();
            if kind.is_dir() {
                self.summary.directory();
                self.entries.push(Entry {
                    relative,
                    is_directory: true,
                });
                self.walk(root, &source, output)?;
            } else if kind.is_file() {
                let size = entry
                    .metadata()
                    .map_err(|error| Failure::path("stat", &source, error))?
                    .len();
                if self.summary.bytes.checked_add(size).is_none() {
                    return Err(Failure::new("input exceeds the supported archive size"));
                }
                self.summary.file(size);
                self.entries.push(Entry {
                    relative,
                    is_directory: false,
                });
            }
        }
        Ok(())
    }
}

/// Identifies the output archive when it would be written inside the tree being
/// packed, so the walk can skip it.
///
/// The walk runs before the archive is created, so today this cannot trigger —
/// it stays as a guard because the alternative, if that ordering ever changes,
/// is an archive that recursively contains itself.
struct OutputFilter {
    name: OsString,
    /// Resolved via the parent, which exists by now; the archive itself does not
    /// yet, and canonicalizing a missing path would leave this relative and
    /// never match.
    canonical: PathBuf,
}

impl OutputFilter {
    fn new(output: &Path) -> Self {
        let name = output.file_name().unwrap_or_default().to_os_string();
        let parent = output.parent().unwrap_or_else(|| Path::new("."));
        let canonical = fs::canonicalize(parent)
            .unwrap_or_else(|_| parent.to_path_buf())
            .join(&name);
        Self { name, canonical }
    }

    /// Canonicalizing is the only reliable comparison, but it is a syscall and
    /// this runs per entry. The file name is a cheap necessary condition, so it
    /// gates the expensive check.
    fn matches(&self, source: &Path) -> bool {
        source.file_name() == Some(self.name.as_os_str())
            && fs::canonicalize(source).is_ok_and(|resolved| resolved == self.canonical)
    }
}

fn write_entries<W: Write>(
    reporter: &mut Reporter,
    writer: &mut ArchiveWriter<W>,
    root: &Path,
    entries: &[Entry],
) -> Result<(), Failure> {
    let mut buffer = vec![0_u8; READ_BUFFER];
    for entry in entries {
        let archive_path = path::to_archive(&entry.relative)?;
        if entry.is_directory {
            writer
                .make_dir(&archive_path, false)
                .map_err(|error| Failure::path("add", &entry.relative, error))?;
            continue;
        }
        // Gated because `Arguments` are built eagerly, and rendering the path
        // allocates for every file packed whether or not it is printed.
        if reporter.is_verbose() {
            reporter.detail(format_args!("{}", path::display(&entry.relative)))?;
        }
        let source = root.join(&entry.relative);
        let file = File::open(&source).map_err(|error| Failure::path("open", &source, error))?;
        copy_into(reporter, writer, &archive_path, file, &mut buffer)
            .map_err(|error| Failure::path("add", &entry.relative, error))?;
    }
    Ok(())
}

fn copy_into<W: Write>(
    reporter: &mut Reporter,
    writer: &mut ArchiveWriter<W>,
    archive_path: &[u8],
    source: File,
    buffer: &mut [u8],
) -> zarust::Result<()> {
    writer.start_file(archive_path)?;
    let mut source = BufReader::with_capacity(READ_BUFFER, source);
    loop {
        let read = source.read(buffer)?;
        if read == 0 {
            return Ok(());
        }
        writer.append_data(&buffer[..read])?;
        reporter.advance(read as u64);
    }
}