portable-network-archive 0.32.2

Portable-Network-Archive cli
Documentation
use crate::utils::{EmbedExt, TestResources, diff::assert_dirs_equal, setup};
use clap::Parser;
use portable_network_archive::cli;
use std::fs;
use std::io::Write;

#[test]
fn split_archive() {
    setup();
    TestResources::extract_in("raw/", "split_archive/in/").unwrap();
    cli::Cli::try_parse_from([
        "pna",
        "--quiet",
        "create",
        "split_archive/split.pna",
        "--overwrite",
        "split_archive/in/",
    ])
    .unwrap()
    .execute()
    .unwrap();
    cli::Cli::try_parse_from([
        "pna",
        "--quiet",
        "split",
        "split_archive/split.pna",
        "--overwrite",
        "--max-size",
        "100kb",
        "--out-dir",
        "split_archive/split/",
    ])
    .unwrap()
    .execute()
    .unwrap();

    // check split file size
    for entry in fs::read_dir("split_archive/split/").unwrap() {
        assert!(fs::metadata(entry.unwrap().path()).unwrap().len() <= 100 * 1000);
    }

    cli::Cli::try_parse_from([
        "pna",
        "--quiet",
        "x",
        "split_archive/split/split.part1.pna",
        "--overwrite",
        "--out-dir",
        "split_archive/out/",
        "--strip-components",
        "2",
    ])
    .unwrap()
    .execute()
    .unwrap();

    // check completely extracted
    assert_dirs_equal("split_archive/in/", "split_archive/out/");
}

/// Test that split works when an entry's first chunk exceeds remaining space in current archive
/// but fits in a fresh archive with full capacity.
///
/// This tests the fix for the case where:
/// 1. Current archive is partially filled
/// 2. Next entry's first (unsplittable) chunk is larger than remaining space
/// 3. But the chunk fits in max_file_size
///
/// Before the fix, this would fail with "A chunk was detected that could not be divided..."
/// After the fix, it correctly creates a new archive part.
#[test]
fn split_archive_first_chunk_exceeds_remaining() {
    setup();

    // Create test directory with multiple files
    let test_dir = "split_first_chunk_test/in/";
    fs::create_dir_all(test_dir).unwrap();

    // Create several files with varying sizes to fill the archive strategically
    // File names affect FHED chunk size (header + name length)
    for i in 0..5 {
        let filename = format!("{}file{}.txt", test_dir, i);
        let mut file = fs::File::create(&filename).unwrap();
        // Write enough content to make splitting necessary
        file.write_all(&[b'A' + i; 20]).unwrap();
    }

    // Create archive from the test files
    cli::Cli::try_parse_from([
        "pna",
        "--quiet",
        "create",
        "split_first_chunk_test/test.pna",
        "--overwrite",
        test_dir,
    ])
    .unwrap()
    .execute()
    .unwrap();

    // Split with a small size to trigger the edge case
    // MIN_SPLIT_PART_BYTES is 80 bytes, so use something just above that
    // This forces multiple splits with tight boundaries
    cli::Cli::try_parse_from([
        "pna",
        "--quiet",
        "split",
        "split_first_chunk_test/test.pna",
        "--overwrite",
        "--max-size",
        "150",
        "--out-dir",
        "split_first_chunk_test/split/",
    ])
    .unwrap()
    .execute()
    .unwrap();

    // Verify split parts were created and are within size limit
    let split_dir = fs::read_dir("split_first_chunk_test/split/").unwrap();
    let mut part_count = 0;
    for entry in split_dir {
        let path = entry.unwrap().path();
        let size = fs::metadata(&path).unwrap().len();
        assert!(
            size <= 150,
            "Split part {} exceeds max size: {} > 150",
            path.display(),
            size
        );
        part_count += 1;
    }
    assert!(
        part_count > 1,
        "Expected multiple split parts, got {}",
        part_count
    );

    // Extract and verify content matches original
    cli::Cli::try_parse_from([
        "pna",
        "--quiet",
        "x",
        "split_first_chunk_test/split/test.part1.pna",
        "--overwrite",
        "--out-dir",
        "split_first_chunk_test/out/",
        "--strip-components",
        "2",
    ])
    .unwrap()
    .execute()
    .unwrap();

    // Verify extracted content matches original
    assert_dirs_equal(test_dir, "split_first_chunk_test/out/");
}

/// Precondition: A valid archive exists.
/// Action: Run `pna split` with `--max-size` below the minimum threshold.
/// Expectation: The command returns an error indicating the minimum size requirement.
#[test]
fn split_fails_with_too_small_max_size() {
    setup();
    TestResources::extract_in("zstd.pna", "split_too_small/").unwrap();

    let result = cli::Cli::try_parse_from([
        "pna",
        "--quiet",
        "split",
        "-f",
        "split_too_small/zstd.pna",
        "--max-size",
        "50",
        "--out-dir",
        "split_too_small/out/",
    ])
    .unwrap()
    .execute();

    let err = result.unwrap_err();
    assert!(
        err.to_string().contains("must be at least"),
        "error should mention minimum size requirement: {err}"
    );
}

/// Precondition: An archive has already been split to an output directory.
/// Action: Run `pna split` again to the same output without `--overwrite`.
/// Expectation: The command returns an error because output files already exist.
#[test]
fn split_fails_without_overwrite_when_output_exists() {
    setup();
    TestResources::extract_in("zstd.pna", "split_no_overwrite/").unwrap();

    // First split succeeds
    cli::Cli::try_parse_from([
        "pna",
        "--quiet",
        "split",
        "-f",
        "split_no_overwrite/zstd.pna",
        "--overwrite",
        "--max-size",
        "100kb",
        "--out-dir",
        "split_no_overwrite/out/",
    ])
    .unwrap()
    .execute()
    .unwrap();

    // Confirm first split produced output files
    assert!(
        fs::read_dir("split_no_overwrite/out/").unwrap().count() > 0,
        "first split should have produced output files"
    );

    // Second split without --overwrite should fail
    let result = cli::Cli::try_parse_from([
        "pna",
        "--quiet",
        "split",
        "-f",
        "split_no_overwrite/zstd.pna",
        "--max-size",
        "100kb",
        "--out-dir",
        "split_no_overwrite/out/",
    ])
    .unwrap()
    .execute();

    assert!(
        result.is_err(),
        "split without --overwrite should fail when output exists"
    );
}

/// Precondition: No archive exists at the specified path.
/// Action: Run `pna split` with a non-existent archive path.
/// Expectation: The command returns an error.
#[test]
fn split_fails_with_missing_archive() {
    setup();

    let result = cli::Cli::try_parse_from([
        "pna",
        "--quiet",
        "split",
        "-f",
        "split_missing/nonexistent.pna",
        "--max-size",
        "100kb",
    ])
    .unwrap()
    .execute();

    assert!(
        result.is_err(),
        "split should fail with non-existent archive"
    );
}