xzip 0.3.0

ZIP CLI with explicit filename encoding for pack and unpack workflows.
Documentation
use std::fs::File;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::Instant;

use walkdir::WalkDir;
use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipWriter};

use crate::archive::compression_label;
use crate::codec::EncodingKind;
use crate::error::XzipError;
use crate::filter::PathFilter;
use crate::verbosity::Verbosity;

pub fn pack_path(
    input: &Path,
    output_zip: &Path,
    encoding: EncodingKind,
    recursive: bool,
    include: &[String],
    exclude: &[String],
    verbosity: Verbosity,
) -> Result<(), XzipError> {
    let started = Instant::now();
    if !input.exists() {
        return Err(XzipError::InvalidInput(input.to_path_buf()));
    }
    let filter = PathFilter::new(include, exclude)?;

    let out_file = File::create(output_zip)?;
    let mut writer = ZipWriter::new(out_file);
    let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);

    let mut files_added = 0usize;
    let mut dirs_added = 0usize;

    if input.is_file() {
        let base = input
            .file_name()
            .and_then(|x| x.to_str())
            .ok_or_else(|| XzipError::InvalidInput(input.to_path_buf()))?;
        let zip_path = Path::new(base);
        let size = add_file(&mut writer, input, zip_path, options, encoding, verbosity)?;
        files_added += 1;
        if verbosity.is_extra() {
            eprintln!(
                "adding: {} ({} bytes, {})",
                normalize_zip_path(zip_path),
                size,
                compression_label(CompressionMethod::Deflated)
            );
        }
    } else {
        for entry in WalkDir::new(input) {
            let entry = entry.map_err(|e| std::io::Error::other(e.to_string()))?;
            let path = entry.path();
            if path == input {
                continue;
            }
            if !recursive && path.parent() != Some(input) {
                continue;
            }
            let relative = path
                .strip_prefix(input)
                .map_err(|_| XzipError::InvalidInput(input.to_path_buf()))?;
            let zip_path = normalize_zip_path(relative);
            if !filter.allows(&zip_path) {
                continue;
            }
            if entry.file_type().is_dir() {
                writer.add_directory(&zip_path, options)?;
                dirs_added += 1;
                if verbosity.is_verbose() {
                    eprintln!("adding: {zip_path}");
                }
            } else if entry.file_type().is_file() {
                let size = add_file(
                    &mut writer,
                    path,
                    Path::new(&zip_path),
                    options,
                    encoding,
                    verbosity,
                )?;
                files_added += 1;
                if verbosity.is_extra() {
                    eprintln!(
                        "adding: {zip_path} ({size} bytes, {})",
                        compression_label(CompressionMethod::Deflated)
                    );
                }
            }
        }
    }

    writer.finish()?;

    if verbosity.is_verbose() {
        let elapsed = started.elapsed();
        let file_label = if files_added == 1 { "file" } else { "files" };
        eprintln!(
            "{files_added} {file_label} added ({dirs_added} directories) in {:.2}s",
            elapsed.as_secs_f64()
        );
    }

    Ok(())
}

fn add_file<W: Write + std::io::Seek>(
    writer: &mut ZipWriter<W>,
    src_path: &Path,
    zip_relative_path: &Path,
    options: SimpleFileOptions,
    encoding: EncodingKind,
    verbosity: Verbosity,
) -> Result<u64, XzipError> {
    let display_name = normalize_zip_path(zip_relative_path);
    let encoded = encoding.encode(&display_name)?;
    let normalized_name = encoding.decode(&encoded)?;

    if verbosity.is_verbose() && !verbosity.is_extra() {
        eprintln!("adding: {display_name}");
    }

    writer.start_file(normalized_name, options)?;
    let mut input_file = File::open(src_path)?;
    let mut buf = Vec::new();
    input_file.read_to_end(&mut buf)?;
    let size = buf.len() as u64;
    writer.write_all(&buf)?;
    Ok(size)
}

fn normalize_zip_path(path: &Path) -> String {
    let mut normalized = PathBuf::new();
    for component in path.components() {
        normalized.push(component);
    }
    normalized.to_string_lossy().replace('\\', "/")
}