zarust 0.2.0

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,
    args::Options,
    ui::{Reporter, counted, display_path, format_bytes, format_duration},
};

const READ_BUFFER: usize = 256 * 1024;

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",
            display_path(input)
        )));
    }
    let output = match output {
        Some(path) => path,
        None => default_output(input),
    };
    prepare_output(&output, options.force)?;

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

    let file = OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(&output)
        .map_err(|error| {
            Failure::new(format!(
                "cannot create `{}`: {error}",
                display_path(&output)
            ))
        })?;
    let mut writer = ArchiveWriter::new(BufWriter::new(file));

    reporter.start_bar("packing", plan.bytes);
    // A partial archive is worse than none, so remove it on any failure.
    if let Err(failure) = write_entries(reporter, &mut writer, input, &plan.entries) {
        reporter.finish_bar();
        let _ = fs::remove_file(&output);
        return Err(failure);
    }
    let result = writer
        .finish()
        .and_then(|mut inner| inner.flush().map_err(Into::into));
    reporter.finish_bar();
    if let Err(error) = result {
        let _ = fs::remove_file(&output);
        return Err(Failure::new(format!("cannot finalize archive: {error}")));
    }

    let packed = fs::metadata(&output).map(|meta| meta.len()).unwrap_or(0);
    summarize(reporter, &output, &plan, packed, started)
}

fn summarize(
    reporter: &mut Reporter,
    output: &Path,
    plan: &Plan,
    packed: u64,
    started: Instant,
) -> Outcome {
    let palette = reporter.palette();
    reporter.status(
        palette.green,
        "packed",
        format_args!(
            "{bold}{}{reset}",
            display_path(output),
            bold = palette.bold,
            reset = palette.reset
        ),
    )?;
    let ratio = if plan.bytes == 0 {
        100.0
    } else {
        packed as f64 * 100.0 / plan.bytes as f64
    };
    reporter.sub(format_args!(
        "{}, {} · {} → {} ({ratio:.0}%) in {}",
        counted(plan.files, "file", "files"),
        counted(plan.directories, "directory", "directories"),
        format_bytes(plan.bytes as f64),
        format_bytes(packed as f64),
        format_duration(Some(started.elapsed())),
    ))?;
    Ok(())
}

fn default_output(input: &Path) -> PathBuf {
    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");
    input.parent().unwrap_or_else(|| Path::new("")).join(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",
            display_path(output)
        )));
    }
    if output.exists() {
        if !force {
            return Err(Failure::new(format!(
                "`{}` already exists (use --force to overwrite)",
                display_path(output)
            )));
        }
        fs::remove_file(output).map_err(|error| {
            Failure::new(format!(
                "cannot replace `{}`: {error}",
                display_path(output)
            ))
        })?;
    }
    if let Some(parent) = output
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
    {
        fs::create_dir_all(parent).map_err(|error| {
            Failure::new(format!("cannot create `{}`: {error}", display_path(parent)))
        })?;
    }
    Ok(())
}

struct Plan {
    entries: Vec<Entry>,
    files: u64,
    directories: u64,
    bytes: u64,
}

struct Entry {
    source: PathBuf,
    archive_path: Vec<u8>,
    is_directory: bool,
}

/// Identifies the output archive when it is being written inside the tree being
/// packed, so the walk can skip it.
///
/// Canonicalizing is the only reliable comparison, but it is a syscall per
/// entry and dominates the walk on Windows. The file name is a cheap necessary
/// condition, so it gates the expensive check.
struct OutputFilter {
    name: OsString,
    canonical: PathBuf,
}

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

    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)
    }
}

/// Walks `root` once up front so the progress bar has a real total and the
/// archive is written in a single pass afterwards.
fn plan(root: &Path, output: &Path) -> Result<Plan, Failure> {
    let mut plan = Plan {
        entries: Vec::new(),
        files: 0,
        directories: 0,
        bytes: 0,
    };
    walk(root, root, &OutputFilter::new(output), &mut plan)?;
    Ok(plan)
}

fn walk(
    root: &Path,
    directory: &Path,
    output: &OutputFilter,
    plan: &mut Plan,
) -> Result<(), Failure> {
    let listing = fs::read_dir(directory).map_err(|error| {
        Failure::new(format!(
            "cannot read `{}`: {error}",
            display_path(directory)
        ))
    })?;
    for entry in listing {
        let entry = entry.map_err(|error| {
            Failure::new(format!(
                "cannot read `{}`: {error}",
                display_path(directory)
            ))
        })?;
        let source = entry.path();
        if output.matches(&source) {
            continue;
        }
        let kind = entry.file_type().map_err(|error| {
            Failure::new(format!("cannot stat `{}`: {error}", display_path(&source)))
        })?;
        let relative = source.strip_prefix(root).unwrap_or(&source);
        let archive_path = archive_path(relative)?;
        if kind.is_dir() {
            plan.directories += 1;
            plan.entries.push(Entry {
                source: source.clone(),
                archive_path,
                is_directory: true,
            });
            walk(root, &source, output, plan)?;
        } else if kind.is_file() {
            let size = entry
                .metadata()
                .map_err(|error| {
                    Failure::new(format!("cannot stat `{}`: {error}", display_path(&source)))
                })?
                .len();
            plan.files += 1;
            plan.bytes = plan
                .bytes
                .checked_add(size)
                .ok_or_else(|| Failure::new("input exceeds the supported archive size"))?;
            plan.entries.push(Entry {
                source,
                archive_path,
                is_directory: false,
            });
        }
    }
    Ok(())
}

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 shown = entry.source.strip_prefix(root).unwrap_or(&entry.source);
        if entry.is_directory {
            writer
                .make_dir(&entry.archive_path, false)
                .map_err(|error| {
                    Failure::new(format!("cannot add `{}`: {error}", display_path(shown)))
                })?;
            continue;
        }
        reporter.detail(format_args!("{}", display_path(shown)))?;
        let source = File::open(&entry.source).map_err(|error| {
            Failure::new(format!(
                "cannot open `{}`: {error}",
                display_path(&entry.source)
            ))
        })?;
        copy_into(reporter, writer, &entry.archive_path, source, &mut buffer).map_err(|error| {
            Failure::new(format!("cannot add `{}`: {error}", display_path(shown)))
        })?;
    }
    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);
    }
}

/// Converts a relative filesystem path into the archive's `/`-separated form.
fn archive_path(path: &Path) -> Result<Vec<u8>, Failure> {
    #[cfg(unix)]
    {
        use std::os::unix::ffi::OsStrExt;
        Ok(path.as_os_str().as_bytes().to_vec())
    }
    #[cfg(not(unix))]
    {
        let text = path.to_str().ok_or_else(|| {
            Failure::new(format!("`{}` is not valid Unicode", display_path(path)))
        })?;
        Ok(text.replace('\\', "/").into_bytes())
    }
}