rete-cli 0.5.3

CLI to build and inspect Rete graph files.
//! `rete convert` says so in the build record: the source's own record is kept
//! as it was, and a `conversions` entry names the engine, the command and the
//! file it started from.

mod common;

use common::rete;
use serde_json::Value;
use std::path::Path;

fn nt() -> String {
    (0..30)
        .map(|i| {
            format!(
                "<http://example.test/e{i}> <http://www.w3.org/2000/01/rdf-schema#label> \"E {i}\" .\n\
                 <http://example.test/e{i}> <http://example.test/next> <http://example.test/e{}> .\n",
                (i + 1) % 30
            )
        })
        .collect()
}

/// A `0x05` file (the test hook), reproducible record, with or without a card.
fn legacy(dir: &Path, card: bool) -> std::path::PathBuf {
    let src = dir.join("in.nt");
    std::fs::write(&src, nt()).unwrap();
    let out = dir.join(if card { "card.rete" } else { "bare.rete" });
    let mut c = rete();
    c.env("RETE_WRITE_FORMAT", "5")
        .env("RETE_BUILD_RECORD", "reproducible")
        .env("SOURCE_DATE_EPOCH", "1700000000")
        .arg("build")
        .arg(&src)
        .arg("-o")
        .arg(&out)
        .arg("--no-pyramid");
    if card {
        c.args(["--card", "--no-card-costs"]);
    }
    assert!(c.output().unwrap().status.success());
    out
}

fn convert(old: &Path, out: &Path) {
    let o = rete()
        .env("RETE_BUILD_RECORD", "reproducible")
        .env("SOURCE_DATE_EPOCH", "1700000100")
        .arg("convert")
        .arg(old)
        .arg("-o")
        .arg(out)
        .output()
        .unwrap();
    assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
}

fn card_json(file: &Path) -> Value {
    common::json(rete().arg("card").arg("--json").arg(file))
}

fn hash_of(file: &Path) -> String {
    let b = std::fs::read(file).unwrap();
    b[8..24].iter().map(|x| format!("{x:02x}")).collect()
}

#[test]
fn a_converted_file_keeps_the_original_record_and_adds_the_conversion() {
    let dir = tempfile::tempdir().unwrap();
    let old = legacy(dir.path(), true);
    let a = dir.path().join("a.rete");
    let b = dir.path().join("b.rete");
    convert(&old, &a);
    convert(&old, &b);
    assert_eq!(std::fs::read(&a).unwrap()[4], 6);

    let before = card_json(&old)["build"].clone();
    let after = card_json(&a)["build"].clone();
    // The original record, intact.
    for k in ["engine", "inputs", "counts", "params", "built_at"] {
        assert_eq!(before[k], after[k], "{k}");
        assert!(!before[k].is_null(), "{k} is in the source record");
    }
    // One conversion, naming the file it started from.
    let c = after["conversions"].as_array().unwrap();
    assert_eq!(c.len(), 1, "{after}");
    assert_eq!(c[0]["source_format"], "0x05");
    assert_eq!(c[0]["source_content_hash"], hash_of(&old).as_str());
    assert_eq!(
        c[0]["source_bytes"].as_u64().unwrap(),
        std::fs::metadata(&old).unwrap().len()
    );
    assert_eq!(c[0]["command"], "rete convert");
    assert_eq!(c[0]["engine"]["name"], "rete-cli");
    assert_eq!(c[0]["at"], "2023-11-14T22:15:00Z");
    assert!(before.get("conversions").is_none());

    // The text card shows it, and the file verifies (the record is outside the hash).
    let text = rete().arg("card").arg(&a).output().unwrap();
    let text = String::from_utf8_lossy(&text.stdout);
    assert!(
        text.contains("converted") && text.contains(&hash_of(&old)),
        "{text}"
    );
    rete().arg("verify").arg(&a).assert().success();

    // Two reproducible converts are byte-identical.
    assert_eq!(std::fs::read(&a).unwrap(), std::fs::read(&b).unwrap());

    // A re-card keeps them.
    let r = dir.path().join("r.rete");
    let o = rete()
        .env("RETE_BUILD_RECORD", "reproducible")
        .args(["recard"])
        .arg(&a)
        .args(["--keep-curated", "-o"])
        .arg(&r)
        .output()
        .unwrap();
    assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
    assert_eq!(
        card_json(&r)["build"]["conversions"]
            .as_array()
            .unwrap()
            .len(),
        1
    );
}

#[test]
fn a_file_without_a_record_gets_one_that_holds_the_conversion() {
    let dir = tempfile::tempdir().unwrap();
    // Every 0.5 writer leaves a record, so take it off, as a file from before has none.
    let full = legacy(dir.path(), false);
    let old = dir.path().join("old.rete");
    std::fs::write(
        &old,
        rete_core::attach_build_info(&std::fs::read(&full).unwrap(), &[]).unwrap(),
    )
    .unwrap();
    assert!(rete_core::read_build_info(&std::fs::read(&old).unwrap())
        .unwrap()
        .is_none_or(|r| r.is_empty()));
    let out = dir.path().join("out.rete");
    convert(&old, &out);
    // No card, so `rete card` has nothing to print: read the record itself.
    let raw = rete_core::read_build_info(&std::fs::read(&out).unwrap())
        .unwrap()
        .expect("a record");
    let build: Value = serde_json::from_slice(raw.trim_ascii_end()).unwrap();
    assert!(
        build.get("engine").is_none() && build.get("counts").is_none(),
        "{build}"
    );
    assert_eq!(build["conversions"].as_array().unwrap().len(), 1, "{build}");
    assert_eq!(
        build["conversions"][0]["source_content_hash"],
        hash_of(&old).as_str()
    );
    rete().arg("verify").arg(&out).assert().success();
}

fn raw_record(file: &Path) -> String {
    let raw = rete_core::read_build_info(&std::fs::read(file).unwrap())
        .unwrap()
        .expect("a record");
    String::from_utf8(raw).unwrap().trim_end().to_string()
}

/// "Kept as it was" is literal: the source's record text, up to its closing
/// brace, is the start of the converted record.
#[test]
fn the_source_record_is_kept_byte_for_byte() {
    let dir = tempfile::tempdir().unwrap();
    let old = legacy(dir.path(), true);
    let out = dir.path().join("out.rete");
    convert(&old, &out);
    let (before, after) = (raw_record(&old), raw_record(&out));
    let prefix = before.strip_suffix('}').expect("a JSON object");
    assert!(
        after.starts_with(prefix),
        "{before}
{after}"
    );
    assert!(after.ends_with("]}"), "{after}");
}

/// A per-graph (kind 5) source is re-encoded rather than copied, and still
/// keeps its own record: engine, inputs, counts, start time.
#[test]
fn a_per_graph_source_keeps_its_record_too() {
    let dir = tempfile::tempdir().unwrap();
    let src = dir.path().join("in.nq");
    let mut nq = nt();
    nq.push_str("<http://example.test/e1> <http://example.test/in> <http://example.test/e2> <http://example.test/g1> .
");
    std::fs::write(&src, nq).unwrap();
    let old = dir.path().join("pg.rete");
    let o = rete()
        .env("RETE_WRITE_FORMAT", "5")
        .env("RETE_BUILD_RECORD", "reproducible")
        .env("SOURCE_DATE_EPOCH", "1700000000")
        .arg("build")
        .arg(&src)
        .arg("-o")
        .arg(&old)
        .args([
            "--no-pyramid",
            "--named-graphs",
            "per-graph",
            "--card",
            "--no-card-costs",
        ])
        .output()
        .unwrap();
    assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
    let out = dir.path().join("out.rete");
    let o = rete()
        .env("RETE_BUILD_RECORD", "reproducible")
        .env("SOURCE_DATE_EPOCH", "1700000100")
        .arg("convert")
        .arg(&old)
        .arg("-o")
        .arg(&out)
        .output()
        .unwrap();
    assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
    assert!(
        String::from_utf8_lossy(&o.stderr).contains("kind 5"),
        "the re-encode path ran"
    );
    let (before, after) = (raw_record(&old), raw_record(&out));
    let prefix = before.strip_suffix('}').unwrap();
    assert!(
        after.starts_with(prefix),
        "{before}
{after}"
    );
    let b = card_json(&out)["build"].clone();
    assert!(b["inputs"].is_array() && !b["started_at"].is_null(), "{b}");
    assert_eq!(b["conversions"].as_array().unwrap().len(), 1);
    assert_eq!(
        b["conversions"][0]["source_content_hash"],
        hash_of(&old).as_str()
    );
    rete().arg("verify").arg(&out).assert().success();
}

/// A `0x05` file whose card has its starter queries measured (as the build does).
fn legacy_costed(dir: &Path) -> std::path::PathBuf {
    let src = dir.join("in.nt");
    std::fs::write(&src, nt()).unwrap();
    let out = dir.join("costed.rete");
    let o = rete()
        .env("RETE_WRITE_FORMAT", "5")
        .env("RETE_BUILD_RECORD", "reproducible")
        .env("SOURCE_DATE_EPOCH", "1700000000")
        .arg("build")
        .arg(&src)
        .arg("-o")
        .arg(&out)
        .args(["--no-pyramid", "--card"])
        .output()
        .unwrap();
    assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
    out
}

fn convert_with(old: &Path, out: &Path, extra: &[&str]) {
    let o = rete()
        .env("RETE_BUILD_RECORD", "reproducible")
        .env("SOURCE_DATE_EPOCH", "1700000100")
        .arg("convert")
        .arg(old)
        .arg("-o")
        .arg(out)
        .args(extra)
        .output()
        .unwrap();
    assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
}

/// The costs a conversion records are measured on the converted file: rows as
/// before, the source's own figures untouched, the hash unchanged, and
/// `card-audit --measure` agrees with what `rete card` shows.
#[test]
fn a_conversion_measures_the_starter_queries_on_the_new_layout() {
    let dir = tempfile::tempdir().unwrap();
    let old = legacy_costed(dir.path());
    let a = dir.path().join("a.rete");
    let b = dir.path().join("b.rete");
    convert_with(&old, &a, &[]);
    convert_with(&old, &b, &[]);

    let before = card_json(&old)["build"].clone();
    let src_costs = before["query_costs"]["queries"].as_array().unwrap().clone();
    assert!(!src_costs.is_empty(), "the source recorded its costs");

    let after = card_json(&a)["build"].clone();
    let entry = &after["conversions"][0]["query_costs"];
    let costs = entry["queries"].as_array().expect("the entry has costs");
    assert_eq!(costs.len(), src_costs.len());
    for (n, s) in costs.iter().zip(&src_costs) {
        assert_eq!(n["id"], s["id"]);
        assert_eq!(n["rows"], s["rows"], "{}", n["id"]);
        assert_eq!(n["debug_ms"], 0, "reproducible");
    }
    assert_eq!(entry["context"]["layout"], "0x06 (converted)");
    // The shown costs are the new ones; the source's are kept beside them.
    assert_eq!(after["query_costs"], *entry);
    assert_eq!(after["source_query_costs"], before["query_costs"]);

    // The source record is kept byte for byte, and the hash is unchanged.
    let (rb, ra) = (raw_record(&old), raw_record(&a));
    assert!(ra.starts_with(rb.strip_suffix('}').unwrap()), "{rb}\n{ra}");
    // The costs are outside the content hash: the same as without them.
    let plain = dir.path().join("plain.rete");
    convert_with(&old, &plain, &["--no-card-costs"]);
    assert_eq!(hash_of(&plain), hash_of(&a));
    rete().arg("verify").arg(&a).assert().success();

    // The text card says which layout the figures are for.
    let text = rete().arg("card").arg(&a).output().unwrap();
    let text = String::from_utf8_lossy(&text.stdout);
    assert!(
        text.contains("measured on") && text.contains("0x06 (converted)"),
        "{text}"
    );

    // card-audit --measure agrees with the record.
    let doc = common::json(
        rete()
            .arg("card-audit")
            .arg(&a)
            .args(["--measure", "--json"]),
    );
    let mut checked = 0;
    for f in doc["findings"].as_array().unwrap() {
        let rec = &f["observed"]["recorded"];
        if rec.is_null() {
            continue;
        }
        checked += 1;
        assert_eq!(rec["agrees"], true, "{f}");
    }
    assert!(checked > 0);

    // Reproducible converts are byte-identical.
    assert_eq!(std::fs::read(&a).unwrap(), std::fs::read(&b).unwrap());
}

#[test]
fn no_card_costs_leaves_the_entry_without_costs() {
    let dir = tempfile::tempdir().unwrap();
    let old = legacy_costed(dir.path());
    let a = dir.path().join("a.rete");
    convert_with(&old, &a, &["--no-card-costs"]);
    let after = card_json(&a)["build"].clone();
    assert!(
        after["conversions"][0].get("query_costs").is_none(),
        "{after}"
    );
    // Nothing newer: the source's figures are all there is.
    assert_eq!(
        after["query_costs"],
        card_json(&old)["build"]["query_costs"]
    );
    assert!(after.get("source_query_costs").is_none());
}

#[test]
fn a_cardless_file_gets_no_costs() {
    let dir = tempfile::tempdir().unwrap();
    let old = legacy(dir.path(), false);
    let a = dir.path().join("a.rete");
    convert_with(&old, &a, &[]);
    let b = card_json_or_build(&a);
    assert!(b["conversions"][0].get("query_costs").is_none(), "{b}");
}

fn card_json_or_build(file: &Path) -> Value {
    let raw = raw_record(file);
    serde_json::from_str(&raw).unwrap()
}