zipatch-rs 1.6.0

Parser for FFXIV ZiPatch patch files
Documentation
#![cfg(feature = "cli")]

use assert_cmd::Command;
use std::io::Write;
use std::path::PathBuf;

fn fixture_patch() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/patches/H2024.05.31.0000.0000j.patch")
}

fn run_dump(args: &[&str]) -> std::process::Output {
    Command::cargo_bin("zipatch")
        .unwrap()
        .args(args)
        .output()
        .unwrap()
}

fn parse_summary(stderr: &str) -> (usize, usize) {
    let line = stderr
        .lines()
        .find(|l| l.contains(" chunks, ") && l.contains(" SQPK"))
        .unwrap_or_else(|| panic!("no summary line in stderr: {stderr:?}"));
    let mut parts = line.split_whitespace();
    let chunks: usize = parts.next().unwrap().parse().unwrap();
    let _ = parts.next();
    let sqpk: usize = parts.next().unwrap().parse().unwrap();
    (chunks, sqpk)
}

#[test]
fn dump_full_patch_summary_matches_stdout_line_count() {
    let path = fixture_patch();
    let output = run_dump(&["dump", path.to_str().unwrap()]);
    assert!(
        output.status.success(),
        "exit code must be 0, got {:?}\nstderr: {}",
        output.status,
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    let (chunks, sqpk) = parse_summary(&stderr);

    assert_eq!(
        stdout.lines().count(),
        chunks,
        "stdout line count must equal summary chunk count"
    );
    assert!(chunks > 0, "fixture must contain at least one chunk");
    assert!(sqpk > 0 && sqpk <= chunks, "summary counts must be sane");
}

#[test]
fn dump_output_lines_match_expected_format() {
    let path = fixture_patch();
    let output = run_dump(&["dump", path.to_str().unwrap()]);
    assert!(output.status.success());

    let stdout = String::from_utf8_lossy(&output.stdout);
    let first_line = stdout.lines().next().expect("at least one output line");

    assert!(
        first_line.starts_with('#'),
        "each line must start with '#', got: {first_line:?}"
    );
    assert!(
        first_line.contains("FHDR"),
        "first chunk must be FHDR, got: {first_line:?}"
    );
}

#[test]
fn dump_sqpk_only_matches_sqpk_count_and_drops_non_sqpk() {
    let path = fixture_patch();

    let full = run_dump(&["dump", path.to_str().unwrap()]);
    let (_, sqpk_full) = parse_summary(&String::from_utf8_lossy(&full.stderr));

    let filtered = run_dump(&["dump", "--sqpk-only", path.to_str().unwrap()]);
    assert!(filtered.status.success());
    let stdout = String::from_utf8_lossy(&filtered.stdout);
    let stderr = String::from_utf8_lossy(&filtered.stderr);
    let (chunks_after, sqpk_after) = parse_summary(&stderr);

    assert_eq!(
        stdout.lines().count(),
        sqpk_full,
        "--sqpk-only must print one line per SQPK chunk"
    );
    assert_eq!(
        sqpk_after, sqpk_full,
        "summary SQPK count is independent of the filter"
    );
    assert_eq!(
        chunks_after,
        sqpk_full + (chunks_after - sqpk_full),
        "summary chunk count remains the full stream length"
    );
    assert!(
        !stdout.contains("FHDR") && !stdout.contains("APLY"),
        "--sqpk-only must suppress non-SQPK lines"
    );
}

#[test]
fn dump_missing_file_exits_nonzero_with_stderr_message() {
    let output = run_dump(&["dump", "/nonexistent/path/no.patch"]);
    assert!(!output.status.success(), "missing file must exit non-zero");

    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.starts_with("failed to open"),
        "stderr must start with 'failed to open', got: {stderr:?}"
    );
}

#[test]
fn dump_truncated_patch_reports_parse_error_chunk_index() {
    let magic: [u8; 12] = [
        0x91, 0x5A, 0x49, 0x50, 0x41, 0x54, 0x43, 0x48, 0x0D, 0x0A, 0x1A, 0x0A,
    ];
    let mut file = tempfile::NamedTempFile::new().unwrap();
    file.write_all(&magic).unwrap();
    file.write_all(&[0x00, 0x00, 0x10, 0x00, b'A', b'D', b'I'])
        .unwrap();
    file.flush().unwrap();

    let output = run_dump(&["dump", file.path().to_str().unwrap()]);
    assert!(
        !output.status.success(),
        "truncated patch must exit non-zero"
    );

    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("parse error at chunk #"),
        "stderr must report parse error with chunk index, got: {stderr:?}"
    );
}

#[test]
fn top_level_help_exits_zero_and_contains_expected_strings() {
    let output = run_dump(&["--help"]);
    assert!(output.status.success(), "--help must exit 0");

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("Usage:"));
    assert!(stdout.contains("dump"));
}

#[test]
fn dump_subcommand_help_exits_zero_and_contains_expected_strings() {
    let output = run_dump(&["dump", "--help"]);
    assert!(output.status.success(), "dump --help must exit 0");

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("Usage:"));
    assert!(stdout.contains("--sqpk-only"));
}