xzip 0.4.0

ZIP CLI with explicit filename encoding for pack and unpack workflows.
Documentation
use std::fs;
use std::io::Write;
use std::process::Command;

use tempfile::tempdir;
use xzip::codec::EncodingKind;
use xzip::pack::pack_path;
use xzip::unpack::unpack_archive;
use xzip::verbosity::Verbosity;
use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipWriter};

#[test]
fn round_trip_pack_unpack_utf8() {
    let temp = tempdir().expect("temp dir");
    let input_dir = temp.path().join("input");
    let output_dir = temp.path().join("output");
    fs::create_dir_all(input_dir.join("sub")).expect("create input subdir");
    fs::write(input_dir.join("sub/hello.txt"), b"hello").expect("write file");
    fs::write(input_dir.join("root.txt"), b"root").expect("write file");

    let archive = temp.path().join("archive.zip");
    pack_path(
        &input_dir,
        &archive,
        EncodingKind::Utf8,
        true,
        &[],
        &[],
        Verbosity::default(),
    )
    .expect("pack");
    unpack_archive(
        &archive,
        &output_dir,
        EncodingKind::Utf8,
        &[],
        &[],
        Verbosity::default(),
        1,
        false,
    )
    .expect("unpack");

    let unpacked = fs::read(output_dir.join("sub/hello.txt")).expect("read unpacked file");
    assert_eq!(unpacked, b"hello");
}

#[test]
fn invalid_encoding_label_rejected() {
    assert!(EncodingKind::from_label("bad-encoding").is_err());
}

#[test]
fn blocks_zip_slip_paths() {
    let temp = tempdir().expect("temp dir");
    let archive_path = temp.path().join("unsafe.zip");
    {
        let file = fs::File::create(&archive_path).expect("create archive");
        let mut zip = ZipWriter::new(file);
        let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
        zip.start_file("../outside.txt", options)
            .expect("start file");
        zip.write_all(b"attack").expect("write attack payload");
        zip.finish().expect("finalize archive");
    }

    let output = temp.path().join("extract");
    let result = unpack_archive(
        &archive_path,
        &output,
        EncodingKind::Utf8,
        &[],
        &[],
        Verbosity::default(),
        1,
        false,
    );
    assert!(result.is_err());
}

#[test]
fn non_recursive_pack_skips_nested_files() {
    let temp = tempdir().expect("temp dir");
    let input_dir = temp.path().join("input");
    fs::create_dir_all(input_dir.join("nested")).expect("create nested dir");
    fs::write(input_dir.join("root.txt"), b"root").expect("write root");
    fs::write(input_dir.join("nested/deep.txt"), b"deep").expect("write nested");

    let archive = temp.path().join("archive.zip");
    pack_path(
        &input_dir,
        &archive,
        EncodingKind::Utf8,
        false,
        &[],
        &[],
        Verbosity::default(),
    )
    .expect("pack");

    let output_dir = temp.path().join("output");
    unpack_archive(
        &archive,
        &output_dir,
        EncodingKind::Utf8,
        &[],
        &[],
        Verbosity::default(),
        1,
        false,
    )
    .expect("unpack");
    assert!(output_dir.join("root.txt").exists());
    assert!(!output_dir.join("nested/deep.txt").exists());
}

#[test]
fn cli_defaults_to_utf8_when_encoding_omitted() {
    let temp = tempdir().expect("temp dir");
    let input_dir = temp.path().join("input");
    fs::create_dir_all(&input_dir).expect("create input dir");
    fs::write(input_dir.join("hello.txt"), b"hello").expect("write input");

    let archive = temp.path().join("archive.zip");
    let output_dir = temp.path().join("output");
    let bin = env!("CARGO_BIN_EXE_xzip");

    let pack_status = Command::new(bin)
        .args([
            "pack",
            "-i",
            input_dir.to_string_lossy().as_ref(),
            "-o",
            archive.to_string_lossy().as_ref(),
            "-r",
        ])
        .status()
        .expect("run pack");
    assert!(pack_status.success());

    let unpack_status = Command::new(bin)
        .args([
            "unpack",
            "-i",
            archive.to_string_lossy().as_ref(),
            "-o",
            output_dir.to_string_lossy().as_ref(),
        ])
        .status()
        .expect("run unpack");
    assert!(unpack_status.success());
    assert_eq!(
        fs::read(output_dir.join("hello.txt")).expect("read output"),
        b"hello"
    );
}

#[test]
fn list_shows_entries() {
    let temp = tempdir().expect("temp dir");
    let input_dir = temp.path().join("input");
    fs::create_dir_all(input_dir.join("aa")).expect("create dir");
    fs::write(input_dir.join("aa/file.txt"), b"content").expect("write file");

    let archive = temp.path().join("archive.zip");
    pack_path(
        &input_dir,
        &archive,
        EncodingKind::Utf8,
        true,
        &[],
        &[],
        Verbosity::default(),
    )
    .expect("pack");

    let bin = env!("CARGO_BIN_EXE_xzip");
    let output = Command::new(bin)
        .args(["list", "-i", archive.to_string_lossy().as_ref()])
        .output()
        .expect("run list");
    assert!(output.status.success(), "list failed: {:?}", output.stderr);
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("Length"));
    assert!(stdout.contains("aa/file.txt"));
    assert!(stdout.contains("files"));
}

#[test]
fn list_respects_encoding() {
    let temp = tempdir().expect("temp dir");
    let cjk_name = "中文.txt";
    let archive = temp.path().join("archive.zip");
    {
        let file = fs::File::create(&archive).expect("create archive");
        let mut zip = ZipWriter::new(file);
        let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
        let gbk_bytes = EncodingKind::Gbk.encode(cjk_name).expect("encode name");
        let raw_name = unsafe { String::from_utf8_unchecked(gbk_bytes) };
        zip.start_file(raw_name, options).expect("start file");
        zip.write_all(b"data").expect("write payload");
        zip.finish().expect("finalize archive");
    }

    let bin = env!("CARGO_BIN_EXE_xzip");
    let output = Command::new(bin)
        .args([
            "list",
            "-i",
            archive.to_string_lossy().as_ref(),
            "-e",
            "gbk",
        ])
        .output()
        .expect("run list");
    assert!(output.status.success(), "list failed: {:?}", output.stderr);
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains(cjk_name));
}

#[test]
fn unpack_verbose_prints_progress() {
    let temp = tempdir().expect("temp dir");
    let input_dir = temp.path().join("input");
    fs::create_dir_all(&input_dir).expect("create dir");
    fs::write(input_dir.join("one.txt"), b"one").expect("write one");
    fs::write(input_dir.join("two.txt"), b"two").expect("write two");

    let archive = temp.path().join("archive.zip");
    let output_dir = temp.path().join("output");
    let bin = env!("CARGO_BIN_EXE_xzip");

    Command::new(bin)
        .args([
            "pack",
            "-i",
            input_dir.to_string_lossy().as_ref(),
            "-o",
            archive.to_string_lossy().as_ref(),
            "-r",
        ])
        .status()
        .expect("run pack");

    let output = Command::new(bin)
        .args([
            "unpack",
            "-v",
            "-i",
            archive.to_string_lossy().as_ref(),
            "-o",
            output_dir.to_string_lossy().as_ref(),
        ])
        .output()
        .expect("run unpack");
    assert!(
        output.status.success(),
        "unpack failed: {:?}",
        output.stderr
    );
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(stderr.contains("extracting:"));
    assert!(stderr.contains("extracted"));
    assert!(stderr.contains("[jobs="));
}

#[test]
fn list_include_exclude() {
    let temp = tempdir().expect("temp dir");
    let input_dir = temp.path().join("input");
    fs::create_dir_all(input_dir.join("keep")).expect("create keep dir");
    fs::create_dir_all(input_dir.join("skip")).expect("create skip dir");
    fs::write(input_dir.join("keep/yes.txt"), b"yes").expect("write keep");
    fs::write(input_dir.join("skip/no.txt"), b"no").expect("write skip");

    let archive = temp.path().join("archive.zip");
    pack_path(
        &input_dir,
        &archive,
        EncodingKind::Utf8,
        true,
        &[],
        &[],
        Verbosity::default(),
    )
    .expect("pack");

    let bin = env!("CARGO_BIN_EXE_xzip");
    let output = Command::new(bin)
        .args([
            "list",
            "-i",
            archive.to_string_lossy().as_ref(),
            "--include",
            "keep/**",
            "--exclude",
            "**/skip/**",
        ])
        .output()
        .expect("run list");
    assert!(output.status.success(), "list failed: {:?}", output.stderr);
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("keep/yes.txt"));
    assert!(!stdout.contains("skip/no.txt"));
    assert!(stdout.contains("2 files"));
}

#[test]
fn list_ls_alias_works() {
    let temp = tempdir().expect("temp dir");
    let input_dir = temp.path().join("input");
    fs::create_dir_all(&input_dir).expect("create dir");
    fs::write(input_dir.join("only.txt"), b"x").expect("write file");

    let archive = temp.path().join("archive.zip");
    pack_path(
        &input_dir,
        &archive,
        EncodingKind::Utf8,
        false,
        &[],
        &[],
        Verbosity::default(),
    )
    .expect("pack");

    let bin = env!("CARGO_BIN_EXE_xzip");
    let output = Command::new(bin)
        .args(["ls", "-i", archive.to_string_lossy().as_ref()])
        .output()
        .expect("run ls");
    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("only.txt"));
}

#[test]
fn parallel_unpack_matches_sequential() {
    let temp = tempdir().expect("temp dir");
    let input_dir = temp.path().join("input");
    for i in 0..12 {
        let sub = input_dir.join(format!("dir{i}"));
        fs::create_dir_all(&sub).expect("create subdir");
        fs::write(sub.join("file.txt"), format!("payload-{i}")).expect("write file");
    }

    let archive = temp.path().join("archive.zip");
    pack_path(
        &input_dir,
        &archive,
        EncodingKind::Utf8,
        true,
        &[],
        &[],
        Verbosity::default(),
    )
    .expect("pack");

    let sequential_dir = temp.path().join("sequential");
    let parallel_dir = temp.path().join("parallel");
    unpack_archive(
        &archive,
        &sequential_dir,
        EncodingKind::Utf8,
        &[],
        &[],
        Verbosity::default(),
        1,
        false,
    )
    .expect("sequential unpack");
    unpack_archive(
        &archive,
        &parallel_dir,
        EncodingKind::Utf8,
        &[],
        &[],
        Verbosity::default(),
        4,
        false,
    )
    .expect("parallel unpack");

    for i in 0..12 {
        let rel = format!("dir{i}/file.txt");
        let sequential = fs::read(sequential_dir.join(&rel)).expect("read sequential");
        let parallel = fs::read(parallel_dir.join(&rel)).expect("read parallel");
        assert_eq!(sequential, parallel);
    }
}