mmz 0.5.1

memoized command runner
Documentation
use super::{SCHEMA, humanize_age, report, report_json};

fn write(dir: &std::path::Path, name: &str, body: &str) {
    std::fs::write(dir.join(name), body).expect("write");
}

fn manifest(root: &std::path::Path, body: &str) {
    let dir = root.join(".mmz");
    std::fs::create_dir_all(&dir).expect("mkdir .mmz");
    std::fs::write(dir.join("config.yaml"), body).expect("write manifest");
}

#[test]
fn humanize_age_scales_by_unit() {
    assert_eq!(humanize_age(5), "5s ago");
    assert_eq!(humanize_age(90), "1m ago");
    assert_eq!(humanize_age(3 * 3600), "3h ago");
    assert_eq!(humanize_age(2 * 86_400), "2d ago");
}

#[test]
fn reports_never_then_fresh_then_stale() {
    let dir = tempfile::tempdir().expect("tempdir");
    let base = dir.path();
    write(base, "a.txt", "one");
    manifest(
        base,
        "scopes:\n  src: [\"*.txt\"]\ncommands:\n  - name: sh\n    inputs: [src]\n",
    );

    let never = report(base, &[]).expect("report");
    assert!(never.contains("sh") && never.contains("never"));

    let argv = ["sh".to_owned(), "-c".to_owned(), "exit 0".to_owned()];
    crate::run(&argv, base).expect("recorded run");
    assert!(
        report(base, &[]).expect("report").contains("fresh"),
        "fresh after a recorded run"
    );

    write(base, "a.txt", "two");
    assert!(
        report(base, &[]).expect("report").contains("stale"),
        "stale after an input changes"
    );
}

#[test]
fn text_shows_age_after_a_run_and_json_reports_ran_at() {
    let dir = tempfile::tempdir().expect("tempdir");
    let base = dir.path();
    write(base, "a.txt", "one");
    manifest(
        base,
        "scopes:\n  src: [\"*.txt\"]\ncommands:\n  - name: sh\n    inputs: [src]\n",
    );
    let argv = ["sh".to_owned(), "-c".to_owned(), "exit 0".to_owned()];
    crate::run(&argv, base).expect("recorded run");

    assert!(
        report(base, &[]).expect("report").contains("ago"),
        "table shows a record age once a run is recorded"
    );
    let json: serde_json::Value =
        serde_json::from_str(&report_json(base, &[]).expect("json")).expect("valid json");
    let ran_at = json
        .pointer("/rules/0/cached/ran_at")
        .expect("ran_at present");
    assert!(
        ran_at.as_u64().is_some_and(|secs| secs > 0),
        "ran_at is a unix timestamp"
    );
}

#[test]
fn reports_no_inputs_for_empty_scopes() {
    let dir = tempfile::tempdir().expect("tempdir");
    let base = dir.path();
    manifest(
        base,
        "scopes:\n  none: [\"*.none\"]\ncommands:\n  - name: sh\n    inputs: [none]\n",
    );
    let report = report(base, &[]).expect("report");
    assert!(report.contains("sh") && report.contains("no-inputs"));
}

#[test]
fn missing_manifest_is_an_error() {
    let dir = tempfile::tempdir().expect("tempdir");
    assert!(report(dir.path(), &[]).is_err());
}

#[test]
fn parametric_rule_enumerates_one_row_per_file() {
    let dir = tempfile::tempdir().expect("tempdir");
    let base = dir.path();
    std::fs::create_dir(base.join("src")).expect("mkdir");
    write(&base.join("src"), "a.rs", "a");
    write(&base.join("src"), "b.rs", "b");
    manifest(
        base,
        "scopes:\n  targets: [\"src/**/*.rs\"]\ncommands:\n  - name: \"lint {targets}\"\n",
    );
    let report = report(base, &[]).expect("report");
    assert!(report.contains("lint src/a.rs"), "row for a: {report}");
    assert!(report.contains("lint src/b.rs"), "row for b: {report}");
    assert!(report.contains("never"), "each expansion has a verdict");
}

#[test]
fn parametric_expansion_inputs_are_shared_pin_union_bound_file() {
    let dir = tempfile::tempdir().expect("tempdir");
    let base = dir.path();
    write(base, "shared.txt", "pin");
    std::fs::create_dir(base.join("src")).expect("mkdir");
    write(&base.join("src"), "a.rs", "a");
    write(&base.join("src"), "b.rs", "b");
    manifest(
        base,
        "scopes:\n  pin: [\"shared.txt\"]\n  targets: [\"src/**/*.rs\"]\ncommands:\n  - name: \"lint {targets}\"\n    inputs: [pin]\n",
    );

    let json: serde_json::Value =
        serde_json::from_str(&report_json(base, &[]).expect("json")).expect("valid json");
    let rules = json
        .pointer("/rules")
        .and_then(serde_json::Value::as_array)
        .expect("rules array");
    let row = rules
        .iter()
        .find(|rule| rule.get("name").and_then(serde_json::Value::as_str) == Some("lint src/a.rs"))
        .expect("row for the a.rs expansion");

    let input_paths: Vec<&str> = row
        .pointer("/inputs")
        .and_then(serde_json::Value::as_array)
        .expect("inputs array")
        .iter()
        .map(|input| {
            input
                .get("path")
                .and_then(serde_json::Value::as_str)
                .expect("path")
        })
        .collect();
    assert_eq!(
        input_paths,
        vec!["shared.txt", "src/a.rs"],
        "the shared pin and the bound file, sorted, and nothing from the \
         sibling expansion's bound file"
    );

    let expected =
        crate::hashing::digest_files(base, &["shared.txt".to_owned(), "src/a.rs".to_owned()])
            .expect("expected digest");
    assert_eq!(
        row.get("digest").and_then(serde_json::Value::as_str),
        Some(expected.as_str()),
        "digest is over the shared pin plus the bound file, in sorted order"
    );
}

#[test]
fn colliding_expansions_are_an_error() {
    let dir = tempfile::tempdir().expect("tempdir");
    let base = dir.path();
    write(base, "a.rs", "x");
    manifest(
        base,
        "scopes:\n  wide: [\"*.rs\"]\n  narrow: [\"a.rs\"]\ncommands:\n  - name: \"do {wide}\"\n  - name: \"do {narrow}\"\n",
    );
    assert!(
        report(base, &[]).is_err(),
        "status surfaces a colliding-identity config proactively"
    );
}

#[test]
fn json_lists_inputs_with_hashes_and_state() {
    let dir = tempfile::tempdir().expect("tempdir");
    let base = dir.path();
    write(base, "a.txt", "one");
    manifest(
        base,
        "scopes:\n  src: [\"*.txt\"]\ncommands:\n  - name: sh\n    inputs: [src]\n",
    );

    let json: serde_json::Value =
        serde_json::from_str(&report_json(base, &[]).expect("json")).expect("valid json");
    let rule = json.pointer("/rules/0").expect("first rule");
    let str_at = |value: &serde_json::Value, key: &str| {
        value
            .get(key)
            .and_then(serde_json::Value::as_str)
            .map(str::to_owned)
    };
    assert_eq!(str_at(rule, "name").as_deref(), Some("sh"));
    assert_eq!(str_at(rule, "state").as_deref(), Some("never"));
    let input = rule.pointer("/inputs/0").expect("first input");
    assert_eq!(str_at(input, "path").as_deref(), Some("a.txt"));
    assert_eq!(
        str_at(input, "hash").as_deref().map(str::len),
        Some(64),
        "per-file blake3 hex is reported"
    );
    assert!(
        rule.get("cached").is_none(),
        "no record yet, cached omitted"
    );
}

#[test]
fn tag_filter_narrows_the_table_and_excludes_untagged_rules() {
    let dir = tempfile::tempdir().expect("tempdir");
    let base = dir.path();
    write(base, "a.txt", "one");
    manifest(
        base,
        "scopes:\n  src: [\"*.txt\"]\ncommands:\n  - name: sh\n    inputs: [src]\n    tags: [gate]\n  - name: cat\n    inputs: [src]\n",
    );

    let tagged = report(base, &["gate".to_owned()]).expect("report");
    assert!(tagged.contains("sh"), "tagged rule reported: {tagged}");
    assert!(
        !tagged.contains("cat"),
        "untagged rule excluded under a --tag filter: {tagged}"
    );

    let json: serde_json::Value =
        serde_json::from_str(&report_json(base, &["gate".to_owned()]).expect("json"))
            .expect("valid json");
    let rules = json.pointer("/rules").and_then(serde_json::Value::as_array);
    assert_eq!(
        rules.map(Vec::len),
        Some(1),
        "json report is filtered the same way"
    );
}

#[test]
fn schema_is_valid_json_describing_the_output() {
    let schema: serde_json::Value = serde_json::from_str(SCHEMA).expect("schema is json");
    assert_eq!(
        schema.get("$schema").and_then(serde_json::Value::as_str),
        Some("https://json-schema.org/draft/2020-12/schema")
    );
    for key in [
        "manifest",
        "rules",
        "state",
        "inputs",
        "no-inputs",
        "ran_at",
    ] {
        assert!(SCHEMA.contains(key), "schema mentions `{key}`");
    }
}