verit-cli 0.2.0

The `verit` command-line tool for Exavian Veritate: ls, dump, verify, pack, unpack, compact, id, gen, build.
//! `verit` CLI behaviour. These run the real binary, so they cover the parts a
//! library test cannot: magic-based dispatch, exit codes, and whether the
//! output actually tells an operator what they need to know.
//!
//! The messages matter as much as the mechanics here. A silent rollback after a
//! torn commit, or a "removed" record that is still recoverable, are exactly the
//! things a user must not have to read the spec to discover — so the wording is
//! asserted, not just the exit code.

use std::path::PathBuf;
use std::process::{Command, Output};

fn repo() -> PathBuf {
    // engine/crates/verit-cli -> repo root
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("..")
        .join("..")
        .join("..")
}

fn corpus(sub: &str, name: &str) -> PathBuf {
    repo()
        .join("engine")
        .join("tests")
        .join("files")
        .join(sub)
        .join(format!("{name}.verit"))
}

fn verit(args: &[&str]) -> Output {
    Command::new(env!("CARGO_BIN_EXE_verit"))
        .args(args)
        .output()
        .expect("failed to run verit")
}

fn stdout(args: &[&str]) -> String {
    let out = verit(args);
    assert!(
        out.status.success(),
        "verit {args:?} failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    String::from_utf8(out.stdout).unwrap()
}

fn scratch(name: &str) -> PathBuf {
    let p = std::env::temp_dir().join(format!("verit-cli-{}-{name}", std::process::id()));
    let _ = std::fs::remove_file(&p);
    let _ = std::fs::remove_dir_all(&p);
    p
}

// ---------------------------------------------------------------------------
// dispatch on magic, not on filename
// ---------------------------------------------------------------------------

#[test]
fn dump_handles_both_a_file_and_a_message() {
    // A file: JSON Lines, one record per line, ready for `jq`.
    let file = stdout(&["dump", corpus("good", "mixed").to_str().unwrap()]);
    assert_eq!(file.lines().count(), 4);
    assert!(file.lines().all(|l| l.starts_with('{') && l.ends_with('}')));

    // A bare message, same command: one document.
    let msg = repo().join("engine/tests/vectors/scalars.bin");
    let out = stdout(&["dump", msg.to_str().unwrap()]);
    assert_eq!(out.lines().count(), 1);
    assert!(out.contains("\"name\":\"veritate\""));
}

#[test]
fn a_vertc_container_is_refused_with_an_explanation() {
    // Not "bad magic" — the user gets told what they actually have and why it
    // no longer works.
    let path = scratch("old.vertc");
    std::fs::write(&path, b"VRTC\x01\x00\x00\x00").unwrap();
    let out = verit(&["ls", path.to_str().unwrap()]);
    assert!(!out.status.success());
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(err.contains("deprecated"), "unhelpful error: {err}");
    assert!(
        err.contains("ADR-0002"),
        "error should point somewhere: {err}"
    );
}

#[test]
fn a_non_veritate_file_is_refused_clearly() {
    let path = scratch("random.bin");
    std::fs::write(&path, b"this is not a veritate artifact at all").unwrap();
    let out = verit(&["dump", path.to_str().unwrap()]);
    assert!(!out.status.success());
    assert!(String::from_utf8_lossy(&out.stderr).contains("not a Veritate artifact"));
}

// ---------------------------------------------------------------------------
// ls
// ---------------------------------------------------------------------------

#[test]
fn ls_reports_identity_generation_and_reclaimable_space() {
    let out = stdout(&["ls", corpus("good", "removed").to_str().unwrap()]);
    assert!(out.contains("generation: 3"));
    assert!(out.contains("records:    3"));
    assert!(
        out.contains("next id:    6"),
        "the retired id counter must be visible"
    );
    // Ids with gaps, because two records were removed.
    for id in ["\n    1", "\n    3", "\n    5"] {
        assert!(out.contains(id), "missing id row {id:?} in:\n{out}");
    }
    // Over half this file is dead, so it must say so and say what to do.
    assert!(out.contains("dead"), "no dead-space accounting:\n{out}");
    assert!(out.contains("verit compact"), "no remedy offered:\n{out}");
}

#[test]
fn ls_does_not_nag_about_alignment_padding() {
    // `mixed` has only a few bytes of 8-byte padding, which no compaction can
    // reclaim. Suggesting one would train users to ignore the message.
    let out = stdout(&["ls", corpus("good", "mixed").to_str().unwrap()]);
    assert!(
        !out.contains("verit compact"),
        "nagged over padding:\n{out}"
    );
    assert!(
        out.contains("schemas:    3"),
        "should list all three schemas"
    );
}

// ---------------------------------------------------------------------------
// verify
// ---------------------------------------------------------------------------

#[test]
fn verify_reports_a_rolled_back_commit_loudly() {
    // The operator-facing half of crash safety: recovery is silent in the
    // format by design, so the tool has to be the one that says it happened.
    let out = stdout(&["verify", corpus("torn", "torn-in-index").to_str().unwrap()]);
    assert!(
        out.contains("RECOVERED"),
        "rollback was not surfaced:\n{out}"
    );
    assert!(
        out.contains("rolled back"),
        "no explanation of what happened:\n{out}"
    );
    assert!(out.contains("generation 2"));
}

#[test]
fn verify_fails_with_a_typed_error_on_every_bad_file() {
    let dir = repo().join("engine/tests/files/bad");
    let manifest = std::fs::read_to_string(dir.join("manifest.tsv")).unwrap();
    let mut checked = 0;
    for line in manifest
        .lines()
        .filter(|l| !l.starts_with('#') && !l.trim().is_empty())
    {
        let name = line.split('\t').next().unwrap();
        let out = verit(&["verify", corpus("bad", name).to_str().unwrap()]);
        assert!(!out.status.success(), "bad/{name} was accepted by verify");
        let err = String::from_utf8_lossy(&out.stderr);
        assert!(
            err.starts_with("verit: "),
            "bad/{name}: unformatted error {err:?}"
        );
        assert!(!err.contains("panicked"), "bad/{name} panicked: {err}");
        checked += 1;
    }
    assert!(checked >= 15, "only {checked} bad files checked");
}

// ---------------------------------------------------------------------------
// pack / unpack / compact
// ---------------------------------------------------------------------------

#[test]
fn pack_builds_a_readable_file_from_self_describing_messages() {
    let out_path = scratch("packed.verit");
    let vectors = repo().join("engine/tests/vectors");
    stdout(&[
        "pack",
        vectors.join("scalars.bin").to_str().unwrap(),
        vectors.join("nested.bin").to_str().unwrap(),
        "-o",
        out_path.to_str().unwrap(),
    ]);

    // The packed file stands on its own.
    let listed = stdout(&["ls", out_path.to_str().unwrap()]);
    assert!(listed.contains("records:    2"));
    assert!(listed.contains("schemas:    2"));
    assert!(stdout(&["verify", out_path.to_str().unwrap()]).contains("ok:"));
}

#[test]
fn pack_refuses_hash_only_messages_with_a_reason() {
    // A hash-only message carries no schema, so pack cannot build the file's
    // schema section from it. The error has to say that, not just "failed".
    let msg = scratch("hashonly.bin");
    let path = repo().join("engine/tests/files/good/single.verit");
    let image = std::fs::read(&path).unwrap();
    let f = verit::FileView::open(&image).unwrap();
    std::fs::write(&msg, f.get(0).unwrap()).unwrap();

    let out = verit(&[
        "pack",
        msg.to_str().unwrap(),
        "-o",
        scratch("x.verit").to_str().unwrap(),
    ]);
    assert!(!out.status.success());
    assert!(
        String::from_utf8_lossy(&out.stderr).contains("inline schema"),
        "error should name the cause: {}",
        String::from_utf8_lossy(&out.stderr)
    );
}

#[test]
fn unpack_names_records_by_id_and_keeps_the_schemas() {
    let dir = scratch("unpacked");
    stdout(&[
        "unpack",
        corpus("good", "removed").to_str().unwrap(),
        "-o",
        dir.to_str().unwrap(),
    ]);
    // Named by id, not position — ids 1, 3, 5 survive a removal.
    for id in [1u64, 3, 5] {
        assert!(
            dir.join(format!("{id:06}.bin")).exists(),
            "missing record {id}"
        );
    }
    assert!(!dir.join("000002.bin").exists(), "record 2 was removed");
    assert!(dir.join("schemas.vrsb").exists(), "unpack must be lossless");
}

#[test]
fn compact_reclaims_space_preserves_ids_and_warns_about_erasure() {
    let path = scratch("compact-me.verit");
    std::fs::copy(corpus("good", "removed"), &path).unwrap();
    let before = std::fs::metadata(&path).unwrap().len();

    let out = stdout(&["compact", path.to_str().unwrap()]);
    let after = std::fs::metadata(&path).unwrap().len();
    assert!(
        after < before / 2,
        "reclaimed too little: {before} -> {after}"
    );

    // The two things a user must not learn the hard way.
    assert!(out.contains("ERASED"), "no erasure warning:\n{out}");
    assert!(
        out.contains("ids"),
        "no word on what happens to ids:\n{out}"
    );

    // Ids and the counter survive, so a consumer's checkpoint stays valid.
    let ids = stdout(&["id", path.to_str().unwrap()]);
    let got: Vec<&str> = ids.lines().map(|l| l.split('\t').next().unwrap()).collect();
    assert_eq!(got, vec!["1", "3", "5"]);
    assert!(stdout(&["ls", path.to_str().unwrap()]).contains("next id:    6"));
}

#[test]
fn dump_selects_a_record_by_id_not_by_position() {
    let file = corpus("good", "removed");
    // Position 1 holds id 3 in this file, because id 2 was removed.
    let by_id = stdout(&["dump", file.to_str().unwrap(), "--id", "3"]);
    let by_pos = stdout(&["dump", file.to_str().unwrap(), "--record", "1"]);
    assert_eq!(by_id, by_pos);

    // An id that was removed is gone, and says so rather than silently shifting.
    let out = verit(&["dump", file.to_str().unwrap(), "--id", "2"]);
    assert!(!out.status.success());
    assert!(String::from_utf8_lossy(&out.stderr).contains("no live record"));
}