tests/cli_scorecard.rs
//! End-to-end CLI test for the `modgunn scorecard` verb — the flag-parsing +
//! `parse_scorecard` fold + findings-print + `append_scorecard` persistence path in
//! `src/main.rs` that the library-level `provenance`/`warehouse` unit tests never
//! touch as one flow.
//!
//! Writes a real-shaped `scorecard --format json` fixture to a scratch dir, then
//! DRIVES the compiled binary (`CARGO_BIN_EXE_modgunn`): first a pure parse+print
//! (no `--warehouse`), then a persisting run, then reads the persisted signals back
//! through `warehouse provenance-log` — the append→load round-trip THROUGH the CLI.
//! Fully offline, zero-shell.
//!
//! Gated on `warehouse` (the binary's `required-features`, without which
//! `CARGO_BIN_EXE_modgunn` is not built). Run:
//! `cargo test --features "warehouse manifests test-support" --test cli_scorecard`.
#![cfg(feature = "warehouse")]
use std::path::PathBuf;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
/// A unique scratch directory under the system temp root.
fn temp_dir(tag: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("modgunn-scorecard-{tag}-{nanos}"));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Run the compiled `modgunn` binary with `args`; return `(stdout, stderr, code)`.
fn run(args: &[&str]) -> (String, String, i32) {
let out = Command::new(env!("CARGO_BIN_EXE_modgunn"))
.args(args)
.output()
.expect("spawn modgunn binary");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.code().unwrap_or(-1),
)
}
/// A `scorecard --format json` report: low aggregate, a FOUND dangerous workflow
/// (score 0 on the inverted scale), a failing branch-protection, an unmaintained
/// project. Mirrors the shape `provenance::parse_scorecard` folds.
const REPORT: &str = r#"{
"date": "2026-07-14",
"repo": { "name": "github.com/acme/widget", "commit": "deadbeef" },
"scorecard": { "version": "v5.0.0" },
"score": 3.4,
"checks": [
{ "name": "Maintained", "score": 1, "reason": "no commits in 180d" },
{ "name": "Branch-Protection", "score": 2, "reason": "no required reviews" },
{ "name": "Signed-Releases", "score": -1, "reason": "no releases" },
{ "name": "Dangerous-Workflow", "score": 0, "reason": "script injection in CI" }
]
}"#;
/// Without `--warehouse` the verb is a pure parse+print: it folds the report, prints
/// the signals line + every advisory finding, exit 0. RED-when-broken: the **High**
/// `MODGUNN-SCORECARD-DANGEROUS-WORKFLOW` finding must appear — if the inverted
/// dangerous-workflow scale or the severity fold breaks, this line is absent.
#[test]
fn scorecard_parses_and_prints_findings() {
let dir = temp_dir("print");
let report = dir.join("report.json");
std::fs::write(&report, REPORT).unwrap();
let (stdout, stderr, code) = run(&["scorecard", "--path", report.to_str().unwrap()]);
assert_eq!(code, 0, "exit 0; stderr={stderr}");
assert!(
stdout.contains("github.com/acme/widget"),
"subject line missing:\n{stdout}"
);
assert!(
stdout.contains("dangerous_workflow=true"),
"dangerous-workflow flag missing:\n{stdout}"
);
// The load-bearing High finding (RED-when-broken).
assert!(
stdout.contains("High") && stdout.contains("MODGUNN-SCORECARD-DANGEROUS-WORKFLOW"),
"High dangerous-workflow finding missing:\n{stdout}"
);
assert!(
stdout.contains("MODGUNN-SCORECARD-LOW"),
"low-score finding missing:\n{stdout}"
);
assert!(
stdout.contains("MODGUNN-SCORECARD-UNMAINTAINED"),
"unmaintained finding missing:\n{stdout}"
);
assert!(
stdout.contains("MODGUNN-SCORECARD-NO-BRANCH-PROTECTION"),
"no-branch-protection finding missing:\n{stdout}"
);
assert!(
stdout.contains("4 scorecard finding(s)"),
"finding-count footer missing:\n{stdout}"
);
assert!(
stdout.contains("worst High"),
"worst-severity footer missing:\n{stdout}"
);
// Not persisted without --warehouse.
assert!(
!stdout.contains("persisted"),
"no --warehouse ⇒ no persistence:\n{stdout}"
);
std::fs::remove_dir_all(&dir).ok();
}
/// `--warehouse <root>` PERSISTS the signals via `append_scorecard`; a subsequent
/// `warehouse provenance-log` reads them back (the append→load round-trip through the
/// CLI). `--subject` overrides the report's `repo.name`. RED-when-broken: the persisted
/// subject + its High/Medium/Low findings must reappear from the SECOND process, which
/// only sees them via the warehouse table — so a broken append or load fails here.
#[test]
fn scorecard_persists_and_round_trips_through_provenance_log() {
let dir = temp_dir("persist");
let report = dir.join("report.json");
std::fs::write(&report, REPORT).unwrap();
let root = dir.join("catalog");
std::fs::create_dir_all(&root).unwrap();
let root_s = root.to_str().unwrap().to_string();
// Persist with a subject override.
let (out, err, code) = run(&[
"scorecard",
"--path",
report.to_str().unwrap(),
"--subject",
"acme/widget",
"--warehouse",
&root_s,
]);
assert_eq!(code, 0, "persisting scorecard exits 0; stderr={err}");
assert!(
out.contains("acme/widget"),
"subject override not applied:\n{out}"
);
assert!(
out.contains("persisted into scorecard_signals"),
"persist footer missing:\n{out}"
);
// Round-trip: a FRESH process reads the persisted signals back out of the table.
let (log, log_err, log_code) = run(&["warehouse", "provenance-log", "--warehouse", &root_s]);
assert_eq!(log_code, 0, "provenance-log exits 0; stderr={log_err}");
assert!(
log.contains("acme/widget"),
"persisted subject not read back:\n{log}"
);
assert!(
log.contains("MODGUNN-SCORECARD-DANGEROUS-WORKFLOW"),
"persisted High finding not read back:\n{log}"
);
assert!(
log.contains("MODGUNN-SCORECARD-UNMAINTAINED"),
"persisted unmaintained finding not read back:\n{log}"
);
assert!(
log.contains("1 scorecard subject(s)"),
"provenance-log scorecard tally missing:\n{log}"
);
std::fs::remove_dir_all(&dir).ok();
}
/// A missing report file is a hard error (non-zero exit) — the negative,
/// RED-when-broken assertion that the verb doesn't silently succeed on bad input.
#[test]
fn scorecard_missing_file_errors() {
let dir = temp_dir("missing");
let missing = dir.join("nope.json");
let (_stdout, stderr, code) = run(&["scorecard", "--path", missing.to_str().unwrap()]);
assert_ne!(code, 0, "a missing report must fail");
assert!(
stderr.contains("read"),
"expected a read error naming the file:\n{stderr}"
);
std::fs::remove_dir_all(&dir).ok();
}