arcthis 0.5.0

An agent-native CLI for accessing and manipulating compressed files
use std::fs::File;
use std::io::{Read, Write};
use std::path::Path;
use std::process::Stdio;

use assert_cmd::cargo::cargo_bin_cmd;
use flate2::Compression;
use flate2::write::GzEncoder;
use serde_json::Value;
use tempfile::TempDir;
use zip::ZipWriter;
use zip::write::SimpleFileOptions;

fn create_zip(path: &Path) {
    let file = File::create(path).expect("create ZIP fixture");
    let mut archive = ZipWriter::new(file);
    let options = SimpleFileOptions::default();
    archive
        .add_directory("src/", options)
        .expect("add ZIP directory");
    archive
        .start_file("README.md", options)
        .expect("add ZIP file");
    archive.write_all(b"# fixture\n").expect("write ZIP file");
    archive
        .start_file("src/lib.rs", options)
        .expect("add nested ZIP file");
    archive
        .write_all(b"pub fn fixture() {}\n")
        .expect("write nested ZIP file");
    archive.finish().expect("finish ZIP fixture");
}

fn create_tar_gzip(path: &Path) {
    let file = File::create(path).expect("create TAR.GZ fixture");
    let gzip = GzEncoder::new(file, Compression::default());
    let mut archive = tar::Builder::new(gzip);
    let content = b"hello from tar\n";
    let mut header = tar::Header::new_gnu();
    header.set_mode(0o644);
    header.set_size(content.len().try_into().expect("fixture size fits u64"));
    header.set_cksum();
    archive
        .append_data(&mut header, "docs/hello.txt", &content[..])
        .expect("append TAR file");
    let gzip = archive.into_inner().expect("finish TAR archive");
    gzip.finish().expect("finish Gzip stream");
}

fn create_large_zip(path: &Path) {
    let file = File::create(path).expect("create large ZIP fixture");
    let mut archive = ZipWriter::new(file);
    archive
        .start_file("large.bin", SimpleFileOptions::default())
        .expect("add large ZIP file");
    let block = vec![b'x'; 65_536];
    for _ in 0..64 {
        archive.write_all(&block).expect("write large ZIP entry");
    }
    archive.finish().expect("finish large ZIP fixture");
}

#[test]
fn list_uses_magic_bytes_and_emits_structured_json() {
    let workspace = TempDir::new().expect("create test directory");
    let archive_path = workspace.path().join("misleading.bin");
    create_zip(&archive_path);

    let output = cargo_bin_cmd!("arcthis")
        .args([
            "list",
            archive_path.to_str().expect("UTF-8 test path"),
            "--json",
        ])
        .output()
        .expect("run arcthis");

    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(output.stderr.is_empty());
    let value: Value = serde_json::from_slice(&output.stdout).expect("parse list JSON");
    assert_eq!(value["schema_version"], "1");
    assert_eq!(value["archive"]["format"], "zip");
    assert_eq!(value["entries"].as_array().expect("entries array").len(), 3);
    assert_eq!(value["entries"][1]["path"], "README.md");
}

#[test]
fn tree_reads_tar_gzip_and_builds_recursive_nodes() {
    let workspace = TempDir::new().expect("create test directory");
    let archive_path = workspace.path().join("bundle.data");
    create_tar_gzip(&archive_path);

    let output = cargo_bin_cmd!("arcthis")
        .args([
            "tree",
            archive_path.to_str().expect("UTF-8 test path"),
            "--json",
        ])
        .output()
        .expect("run arcthis");

    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let value: Value = serde_json::from_slice(&output.stdout).expect("parse tree JSON");
    assert_eq!(value["archive"]["format"], "tar_gzip");
    assert_eq!(value["tree"][0]["name"], "docs");
    assert_eq!(value["tree"][0]["children"][0]["path"], "docs/hello.txt");
}

#[test]
fn machine_error_uses_stderr_and_stable_exit_code() {
    let workspace = TempDir::new().expect("create test directory");
    let input = workspace.path().join("not-an-archive.txt");
    std::fs::write(&input, b"plain text").expect("write invalid fixture");

    let output = cargo_bin_cmd!("arcthis")
        .args(["list", input.to_str().expect("UTF-8 test path"), "--json"])
        .output()
        .expect("run arcthis");

    assert_eq!(output.status.code(), Some(3));
    assert!(output.stdout.is_empty());
    let value: Value = serde_json::from_slice(&output.stderr).expect("parse error JSON");
    assert_eq!(value["schema_version"], "1");
    assert_eq!(value["error"]["code"], "unsupported_format");
}

#[test]
fn read_streams_exact_entry_bytes() {
    let workspace = TempDir::new().expect("create test directory");
    let archive_path = workspace.path().join("source.zip");
    create_zip(&archive_path);

    let output = cargo_bin_cmd!("arcthis")
        .args([
            "read",
            archive_path.to_str().expect("UTF-8 test path"),
            "src/lib.rs",
        ])
        .output()
        .expect("run arcthis");

    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert_eq!(output.stdout, b"pub fn fixture() {}\n");
    assert!(output.stderr.is_empty());
}

#[test]
fn stat_missing_entry_is_a_structured_machine_error() {
    let workspace = TempDir::new().expect("create test directory");
    let archive_path = workspace.path().join("source.zip");
    create_zip(&archive_path);

    let output = cargo_bin_cmd!("arcthis")
        .args([
            "stat",
            archive_path.to_str().expect("UTF-8 test path"),
            "missing.txt",
            "--json",
        ])
        .output()
        .expect("run arcthis");

    assert_eq!(output.status.code(), Some(5));
    assert!(output.stdout.is_empty());
    let value: Value = serde_json::from_slice(&output.stderr).expect("parse stat error JSON");
    assert_eq!(value["error"]["code"], "entry_not_found");
    assert_eq!(value["error"]["details"]["entry"], "missing.txt");
}

#[test]
fn inspect_reports_sequential_tar_gzip_capabilities() {
    let workspace = TempDir::new().expect("create test directory");
    let archive_path = workspace.path().join("source.tar.gz");
    create_tar_gzip(&archive_path);

    let output = cargo_bin_cmd!("arcthis")
        .args([
            "inspect",
            archive_path.to_str().expect("UTF-8 test path"),
            "--json",
        ])
        .output()
        .expect("run arcthis");

    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let value: Value = serde_json::from_slice(&output.stdout).expect("parse inspect JSON");
    assert_eq!(value["random_access"], false);
    assert_eq!(value["capabilities"]["streaming_read"], true);
    assert_eq!(value["warnings"][0]["code"], "sequential_access");
}

#[test]
fn read_treats_broken_pipe_as_success() {
    let workspace = TempDir::new().expect("create test directory");
    let archive_path = workspace.path().join("large.zip");
    create_large_zip(&archive_path);

    let mut child = std::process::Command::new(assert_cmd::cargo::cargo_bin!("arcthis"))
        .args([
            "read",
            archive_path.to_str().expect("UTF-8 test path"),
            "large.bin",
        ])
        .stdout(Stdio::piped())
        .spawn()
        .expect("spawn arcthis");
    let mut stdout = child.stdout.take().expect("capture stdout");
    let mut prefix = [0_u8; 1];
    stdout.read_exact(&mut prefix).expect("read entry prefix");
    drop(stdout);

    let status = child.wait().expect("wait for arcthis");
    assert!(status.success(), "broken pipe exit status: {status}");
}