yarp-cli 0.3.1

Prune Pi command output and archive tool calls locally
Documentation
use std::fs;
use std::process::{Command, Output};

use serde_json::Value;
use tempfile::TempDir;

fn yarp(arguments: &[&str]) -> Output {
    Command::new(env!("CARGO_BIN_EXE_yarp"))
        .args(arguments)
        .output()
        .expect("run yarp")
}

fn source_pack(directory: &TempDir, pack_id: &str, unknown_field: bool) {
    fs::create_dir(directory.path().join("rules")).expect("rules directory");
    fs::write(
        directory.path().join("pack.json"),
        format!("{{\"schema_version\":1,\"id\":{pack_id:?},\"rules\":[\"rules/custom.json\"]}}"),
    )
    .expect("manifest");
    let extra = if unknown_field { ",\"argvO\":[]" } else { "" };
    fs::write(
        directory.path().join("rules/custom.json"),
        format!(
            "{{\"id\":\"custom/check\",\"match\":{{\"program\":[\"customcheck\"]{extra}}},\"action\":\"reduce\",\"reducer\":{{\"kind\":\"list_summary\"}},\"success\":{{\"max_line_bytes\":256,\"max_output_bytes\":1024,\"min_savings_bytes\":8,\"min_savings_basis_points\":1000}},\"failure\":{{\"max_line_bytes\":256,\"max_output_bytes\":2048,\"min_savings_bytes\":8,\"min_savings_basis_points\":500}}}}"
        ),
    )
    .expect("rule");
}

#[test]
fn checks_compiles_verifies_lists_and_explains_external_rules() {
    let directory = TempDir::new().expect("temp directory");
    source_pack(&directory, "custom-pack", false);
    let compiled = directory.path().join("custom.yrp");

    let check = yarp(&["rules", "check", directory.path().to_str().expect("path")]);
    assert!(
        check.status.success(),
        "{}",
        String::from_utf8_lossy(&check.stderr)
    );
    assert!(String::from_utf8_lossy(&check.stdout).contains("rules: 1"));

    let compile = yarp(&[
        "rules",
        "compile",
        directory.path().to_str().expect("path"),
        "--output",
        compiled.to_str().expect("path"),
    ]);
    assert!(
        compile.status.success(),
        "{}",
        String::from_utf8_lossy(&compile.stderr)
    );

    let verify = yarp(&["rules", "verify", compiled.to_str().expect("path")]);
    assert!(
        verify.status.success(),
        "{}",
        String::from_utf8_lossy(&verify.stderr)
    );
    assert!(String::from_utf8_lossy(&verify.stdout).contains("compiled_sha256:"));
    assert!(String::from_utf8_lossy(&verify.stdout).contains("rule_pack: ok"));

    let listed = yarp(&[
        "rules",
        "list",
        "--rule-pack",
        compiled.to_str().expect("path"),
        "--json",
    ]);
    assert!(
        listed.status.success(),
        "{}",
        String::from_utf8_lossy(&listed.stderr)
    );
    let list: Value = serde_json::from_slice(&listed.stdout).expect("list JSON");
    assert!(
        list.as_array()
            .is_some_and(|rules| { rules.iter().any(|rule| rule["rule_id"] == "custom/check") })
    );

    let explained = yarp(&[
        "rules",
        "explain",
        "--rule-pack",
        compiled.to_str().expect("path"),
        "--json",
        "--",
        "customcheck",
    ]);
    assert!(explained.status.success());
    let explanation: Value = serde_json::from_slice(&explained.stdout).expect("explain JSON");
    assert_eq!(explanation["outcome"], "reduce");
    assert_eq!(explanation["pack_id"], "custom-pack");
}

#[test]
fn conflicting_external_rule_ids_disable_every_conflicting_pack() {
    let first = TempDir::new().expect("first source");
    let second = TempDir::new().expect("second source");
    source_pack(&first, "first-pack", false);
    source_pack(&second, "second-pack", false);
    let first_pack = first.path().join("first.yrp");
    let second_pack = second.path().join("second.yrp");
    for (source, output) in [(&first, &first_pack), (&second, &second_pack)] {
        assert!(
            yarp(&[
                "rules",
                "compile",
                source.path().to_str().expect("path"),
                "--output",
                output.to_str().expect("path"),
            ])
            .status
            .success()
        );
    }
    let explained = yarp(&[
        "rules",
        "explain",
        "--rule-pack",
        first_pack.to_str().expect("path"),
        "--rule-pack",
        second_pack.to_str().expect("path"),
        "--json",
        "--",
        "customcheck",
    ]);
    assert!(explained.status.success());
    let value: Value = serde_json::from_slice(&explained.stdout).expect("explanation");
    assert_eq!(value["outcome"], "unsupported");
    assert!(value["diagnostics"].as_array().is_some_and(|items| {
        items.iter().any(|item| {
            item.as_str()
                .is_some_and(|text| text.contains("duplicate external rule id"))
        })
    }));
}

#[test]
fn external_pack_cannot_claim_the_builtin_pack_id() {
    let directory = TempDir::new().expect("source");
    source_pack(&directory, "yarp-builtins", false);
    let compiled = directory.path().join("custom.yrp");
    assert!(
        yarp(&[
            "rules",
            "compile",
            directory.path().to_str().expect("path"),
            "--output",
            compiled.to_str().expect("path"),
        ])
        .status
        .success()
    );
    let explained = yarp(&[
        "rules",
        "explain",
        "--rule-pack",
        compiled.to_str().expect("path"),
        "--json",
        "--",
        "customcheck",
    ]);
    assert!(explained.status.success());
    let value: Value = serde_json::from_slice(&explained.stdout).expect("explanation");
    assert_eq!(value["outcome"], "unsupported");
    assert!(value["diagnostics"].as_array().is_some_and(|items| {
        items.iter().any(|item| {
            item.as_str()
                .is_some_and(|text| text.contains("conflicts with the built-in pack"))
        })
    }));
}

#[test]
fn strict_validation_rejects_unknown_fields() {
    let directory = TempDir::new().expect("temp directory");
    source_pack(&directory, "custom-pack", true);
    let check = yarp(&["rules", "check", directory.path().to_str().expect("path")]);
    assert_eq!(check.status.code(), Some(65));
    assert!(String::from_utf8_lossy(&check.stderr).contains("unknown field"));
}

#[cfg(unix)]
#[test]
fn rewrite_binds_pack_digest_and_changed_pack_fails_open() {
    use std::os::unix::fs::PermissionsExt as _;

    let directory = TempDir::new().expect("temp directory");
    source_pack(&directory, "custom-pack", false);
    let compiled = directory.path().join("custom.yrp");
    assert!(
        yarp(&[
            "rules",
            "compile",
            directory.path().to_str().expect("path"),
            "--output",
            compiled.to_str().expect("path"),
        ])
        .status
        .success()
    );
    let rewritten = yarp(&[
        "rewrite",
        "--rule-pack",
        compiled.to_str().expect("path"),
        "customcheck",
    ]);
    assert!(
        rewritten.status.success(),
        "{}",
        String::from_utf8_lossy(&rewritten.stderr)
    );
    let wrapper = String::from_utf8(rewritten.stdout).expect("wrapper");
    assert!(wrapper.contains("--selected-pack 'custom-pack'"));
    assert!(wrapper.contains("--rule-pack-digest"));
    assert!(wrapper.contains("--rule-pack-compiled-digest"));

    let digest = wrapper
        .split("--rule-pack-digest '")
        .nth(1)
        .and_then(|value| value.split('\'').next())
        .expect("digest")
        .to_owned();
    let compiled_digest = wrapper
        .split("--rule-pack-compiled-digest '")
        .nth(1)
        .and_then(|value| value.split('\'').next())
        .expect("compiled digest")
        .to_owned();
    let executable = directory.path().join("customcheck");
    fs::write(
        &executable,
        "#!/bin/sh\ni=0; while [ $i -lt 20 ]; do echo line-$i; i=$((i+1)); done\n",
    )
    .expect("script");
    fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).expect("permissions");

    let mut body = fs::read(&compiled).expect("compiled pack");
    let last = body.last_mut().expect("pack byte");
    *last ^= 1;
    fs::write(&compiled, body).expect("corrupt pack");

    let output = Command::new(env!("CARGO_BIN_EXE_yarp"))
        .args([
            "run",
            "--selected-pack",
            "custom-pack",
            "--selected-rule",
            "custom/check",
            "--selected-digest",
            &digest,
            "--rule-pack",
            compiled.to_str().expect("path"),
            "--rule-pack-digest",
            &digest,
            "--rule-pack-compiled-digest",
            &compiled_digest,
            "--",
            "customcheck",
        ])
        .env(
            "PATH",
            format!(
                "{}:{}",
                directory.path().display(),
                std::env::var("PATH").unwrap_or_default()
            ),
        )
        .output()
        .expect("run changed pack");
    assert!(output.status.success());
    assert_eq!(String::from_utf8_lossy(&output.stdout).lines().count(), 20);
    assert!(!String::from_utf8_lossy(&output.stdout).contains("[yarp:"));
}