ref:7566df2892f2dc2e017ce511fcc900ede147f6e9

feat(cli): --json on requirement/standard read + status commands (#330)

Wire the already-global `--json` flag into the requirement/standard read and status surface an agent scripts against, mirroring the existing `matrix` pattern: - `requirement list` (requirement + standard) and `applicability list` re-emit the server response verbatim. - `requirement view` (REQ-* and STD-*) echoes the raw requirement/standard object; view_requirement now fetches a Value and only deserializes for the human render. - `requirement status` / `--strict` emit a computed summary `{repo, strict, passed, counts{...}, failing[...]}` while PRESERVING the non-zero exit so `set -e` gating still works — machine payload on stdout, error + exit on stderr. - `requirement status --strict-standards` emits `{passed, uncovered[...]}` and preserves the non-zero exit on 422. Gate logic is factored into a pure `summarize_status` so human and JSON modes can never disagree. Human (non-`--json`) output is unchanged. Tests: unit tests for the summary/gate builder; subprocess integration tests (tests/json_output.rs) that run the real binary against a wiremock server and assert stdout parses as JSON with the expected shape, including that `status --strict` exits non-zero with clean JSON still on stdout. Part of epic fangorn/anvil#329 (Workstream B). Closes fangorn/anvil#330. Out of scope (follow-up): mutation confirmations, label/board/agent, seed --clear stub, #239 link/unlink/import/export. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SHA: 7566df2892f2dc2e017ce511fcc900ede147f6e9
Author: CI <ci@anvil.test>
Date: 2026-07-03 05:43
Parents: 3b842f2
3 files changed +628 -50
Type
src/commands/mod.rs +3 −2
@@ -26,7 +26,8 @@
version = include_str!(concat!(env!("OUT_DIR"), "/version.txt"))
)]
pub struct Cli {
/// Output JSON instead of human-readable tables/details. Applies to
/// list and view subcommands. Useful for scripting.
/// Output JSON instead of human-readable tables/details. Honored by read
/// and status subcommands (list, view, matrix, requirement status). Useful
/// for scripting and agent tooling.
#[arg(long, global = true)]
pub json: bool,
src/commands/requirement.rs +271 −48
@@ -429,6 +429,10 @@
let resp: serde_json::Value = client
.get_with_query(&format!("/{org}/{name}/requirements"), &query)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let reqs: Vec<Requirement> = resp
.get("requirements")
.and_then(|v| serde_json::from_value(v.clone()).ok())
@@ -472,6 +476,10 @@
let resp: serde_json::Value = client
.get_with_query(&format!("/{org}/standards"), &query)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let stds = resp
.get("standards")
.and_then(|v| v.as_array())
@@ -529,9 +537,15 @@
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let req: RequirementDetail = client
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/requirements/{req_id}"))
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let req: RequirementDetail = serde_json::from_value(resp)
.map_err(|e| format!("unexpected requirement response shape: {e}"))?;
output::header(&format!(
"Requirement {} — {}",
req.requirement_id.as_deref().unwrap_or("?"),
@@ -568,6 +582,11 @@
let client = Client::from_config()?;
let std: serde_json::Value = client.get(&format!("/{org}/standards/{std_id}")).await?;
if output::is_json() {
output::print_json(&std);
return Ok(());
}
output::header(&format!(
"Standard {} — {}",
std["requirement_id"].as_str().unwrap_or("?"),
@@ -716,6 +735,10 @@
"/{organization}/standards/{std_id}/applicabilities"
))
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let repos = resp
.get("repositories")
.and_then(|v| v.as_array())
@@ -1238,7 +1261,11 @@
Ok(body) => {
// 2xx with status=ok — coverage gate passed.
if body["status"] == "ok" {
output::success("Standards strict coverage gate passed");
if output::is_json() {
output::print_json(&serde_json::json!({"passed": true, "uncovered": []}));
} else {
output::success("Standards strict coverage gate passed");
}
Ok(())
} else {
// Unexpected envelope.
@@ -1253,8 +1280,17 @@
let body: serde_json::Value = serde_json::from_str(&message).map_err(|_| {
format!("standards gate failed with 422 but body was not JSON: {message}")
})?;
print_uncovered(&body);
let count = body["uncovered"].as_array().map(|a| a.len()).unwrap_or(0);
if output::is_json() {
// Machine payload on stdout, then preserve the non-zero exit.
let uncovered = body
.get("uncovered")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
output::print_json(&serde_json::json!({"passed": false, "uncovered": uncovered}));
} else {
print_uncovered(&body);
}
Err(format!("{count} uncovered mandatory standard(s)").into())
}
Err(other) => Err(Box::new(other)),
@@ -1282,8 +1318,121 @@
.collect();
output::print_table(&["STANDARD", "TITLE", "REPO", "STATUS"], &rows);
println!();
}
/// Coverage tallies derived from the traceability matrix.
#[derive(Debug, Default, PartialEq)]
struct StatusCounts {
covered: usize,
partial: usize,
uncovered: usize,
no_tests: usize,
total: usize,
}
/// One requirement that trips the coverage gate.
#[derive(Debug, PartialEq)]
struct FailingReq {
requirement_id: String,
title: String,
coverage_status: String,
}
/// Machine-readable rollup of requirement coverage. Drives both the `--json`
/// payload and the pass/fail gate, so the two can never disagree.
struct StatusSummary {
counts: StatusCounts,
/// Requirements that trip the gate: uncovered always, plus partial/no_tests
/// under `--strict`.
failing: Vec<FailingReq>,
strict: bool,
}
impl StatusSummary {
/// The gate passes exactly when nothing is failing — this is the process
/// exit contract, identical in human and JSON modes.
fn passed(&self) -> bool {
self.failing.is_empty()
}
fn to_json(&self, org: &str, name: &str) -> serde_json::Value {
serde_json::json!({
"repo": format!("{org}/{name}"),
"strict": self.strict,
"passed": self.passed(),
"counts": {
"covered": self.counts.covered,
"partial": self.counts.partial,
"uncovered": self.counts.uncovered,
"no_tests": self.counts.no_tests,
"total": self.counts.total,
},
"failing": self
.failing
.iter()
.map(|f| {
serde_json::json!({
"requirement_id": f.requirement_id,
"title": f.title,
"coverage_status": f.coverage_status,
})
})
.collect::<Vec<_>>(),
})
}
}
/// Roll up matrix entries into counts + the failing list. Pure (no I/O) so the
/// gate logic is unit-testable without a server.
fn summarize_status(entries: &[serde_json::Value], strict: bool) -> StatusSummary {
let mut counts = StatusCounts {
total: entries.len(),
..Default::default()
};
let mut failing = Vec::new();
for entry in entries {
let status = entry["coverage_status"].as_str().unwrap_or("unknown");
match status {
"covered" => counts.covered += 1,
"partial" => counts.partial += 1,
"uncovered" => counts.uncovered += 1,
"no_tests" => counts.no_tests += 1,
_ => {}
}
let is_failing =
status == "uncovered" || (strict && (status == "partial" || status == "no_tests"));
if is_failing {
failing.push(FailingReq {
requirement_id: entry["requirement"]["requirement_id"]
.as_str()
.unwrap_or("?")
.to_string(),
title: entry["requirement"]["title"]
.as_str()
.unwrap_or("?")
.to_string(),
coverage_status: status.to_string(),
});
}
}
StatusSummary {
counts,
failing,
strict,
}
}
/// The gate's process-exit contract: `Ok` when passing, otherwise an `Err`
/// whose message names the uncovered count (matching the historical wording).
/// Shared by the human and JSON paths so both exit identically.
fn gate_result(summary: &StatusSummary) -> Result<(), Box<dyn std::error::Error>> {
if summary.passed() {
Ok(())
} else {
Err(format!("{} uncovered requirement(s)", summary.counts.uncovered).into())
}
}
async fn status_requirements(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(args.repo.as_deref())?;
@@ -1297,62 +1446,40 @@
.cloned()
.unwrap_or_default();
let summary = summarize_status(&entries, args.strict);
if output::is_json() {
// Machine payload on stdout, then preserve the gate's exit contract so
// `set -e` scripts still fail on uncovered coverage.
output::print_json(&summary.to_json(&org, &name));
return gate_result(&summary);
}
if entries.is_empty() {
output::warn("No requirements found. Create requirements first.");
return Ok(());
}
let mut covered = 0usize;
let mut partial = 0usize;
let mut uncovered = 0usize;
let mut no_tests = 0usize;
for entry in &entries {
match entry["coverage_status"].as_str().unwrap_or("unknown") {
"covered" => covered += 1,
"partial" => partial += 1,
"uncovered" => uncovered += 1,
"no_tests" => no_tests += 1,
_ => {}
}
}
let total = entries.len();
output::header("Requirement Coverage Status");
println!(" Covered: {} ✓", covered);
println!(" Partial: {} ◐", partial);
println!(" Uncovered: {} ✗", uncovered);
println!(" No tests: {} ○", no_tests);
println!(" Total: {}", total);
println!(" Covered: {} ✓", summary.counts.covered);
println!(" Partial: {} ◐", summary.counts.partial);
println!(" Uncovered: {} ✗", summary.counts.uncovered);
println!(" No tests: {} ○", summary.counts.no_tests);
println!(" Total: {}", summary.counts.total);
println!();
if summary.passed() {
output::success("Requirement coverage check passed");
let fail = if args.strict {
uncovered > 0 || partial > 0 || no_tests > 0
Ok(())
} else {
uncovered > 0
};
if fail {
output::error("Requirement coverage check FAILED");
println!();
for entry in &entries {
let status = entry["coverage_status"].as_str().unwrap_or("");
let req_id = entry["requirement"]["requirement_id"]
.as_str()
.unwrap_or("?");
let title = entry["requirement"]["title"].as_str().unwrap_or("?");
let should_list = status == "uncovered"
|| (args.strict && (status == "partial" || status == "no_tests"));
if should_list {
output::detail(req_id, &format!("{} — {}", title, status));
}
for f in &summary.failing {
output::detail(
&f.requirement_id,
&format!("{} — {}", f.title, f.coverage_status),
);
}
Err(format!("{} uncovered requirement(s)", uncovered).into())
} else {
output::success("Requirement coverage check passed");
Ok(())
gate_result(&summary)
}
}
@@ -1854,6 +1981,102 @@
let mut inputs = base_inputs("REQ-X", "t");
inputs.effective_date = Some("2026-01-01");
assert!(any_standards_flags(&inputs));
}
fn matrix_entry(id: &str, status: &str) -> serde_json::Value {
serde_json::json!({
"coverage_status": status,
"requirement": {"requirement_id": id, "title": format!("title {id}")}
})
}
#[test]
fn summarize_status_counts_all_categories() {
let entries = vec![
matrix_entry("REQ-A", "covered"),
matrix_entry("REQ-B", "covered"),
matrix_entry("REQ-C", "partial"),
matrix_entry("REQ-D", "uncovered"),
matrix_entry("REQ-E", "no_tests"),
];
let s = summarize_status(&entries, false);
assert_eq!(
s.counts,
StatusCounts {
covered: 2,
partial: 1,
uncovered: 1,
no_tests: 1,
total: 5,
}
);
}
#[test]
fn summarize_status_non_strict_fails_only_on_uncovered() {
let entries = vec![
matrix_entry("REQ-A", "partial"),
matrix_entry("REQ-B", "no_tests"),
matrix_entry("REQ-C", "uncovered"),
];
let s = summarize_status(&entries, false);
assert!(!s.passed());
assert_eq!(s.failing.len(), 1);
assert_eq!(s.failing[0].requirement_id, "REQ-C");
assert_eq!(s.failing[0].coverage_status, "uncovered");
}
#[test]
fn summarize_status_non_strict_passes_with_partials() {
let entries = vec![
matrix_entry("REQ-A", "covered"),
matrix_entry("REQ-B", "partial"),
];
let s = summarize_status(&entries, false);
assert!(s.passed());
assert!(s.failing.is_empty());
}
#[test]
fn summarize_status_strict_fails_on_partial_and_no_tests() {
let entries = vec![
matrix_entry("REQ-A", "covered"),
matrix_entry("REQ-B", "partial"),
matrix_entry("REQ-C", "no_tests"),
];
let s = summarize_status(&entries, true);
assert!(!s.passed());
let ids: Vec<&str> = s
.failing
.iter()
.map(|f| f.requirement_id.as_str())
.collect();
assert_eq!(ids, vec!["REQ-B", "REQ-C"]);
}
#[test]
fn summarize_status_empty_passes_in_both_modes() {
assert!(summarize_status(&[], false).passed());
let s = summarize_status(&[], true);
assert!(s.passed());
assert_eq!(s.counts.total, 0);
}
#[test]
fn status_summary_to_json_shape() {
let entries = vec![
matrix_entry("REQ-A", "covered"),
matrix_entry("REQ-B", "uncovered"),
];
let v = summarize_status(&entries, false).to_json("test-org", "test-repo");
assert_eq!(v["repo"], "test-org/test-repo");
assert_eq!(v["strict"], false);
assert_eq!(v["passed"], false);
assert_eq!(v["counts"]["covered"], 1);
assert_eq!(v["counts"]["uncovered"], 1);
assert_eq!(v["counts"]["total"], 2);
assert_eq!(v["failing"][0]["requirement_id"], "REQ-B");
assert_eq!(v["failing"][0]["coverage_status"], "uncovered");
}
// The create() routing rules — these are unit tests on validation
tests/json_output.rs +354 −0
@@ -1,0 +1,354 @@
//! End-to-end `--json` tests for the requirement/standard read + status surface.
//!
//! Each test boots a wiremock server, points the real `anvil` binary at it via
//! `ANVIL_SERVER_URL`/`ANVIL_TOKEN`, runs a command with `--json`, and asserts
//! that **stdout parses as JSON** with the expected shape — i.e. that an agent
//! scripting against the CLI gets structured output it can consume, and that the
//! status gate still exits non-zero while emitting a clean machine payload.
//! (fangorn/anvil#330, epic #329 Workstream B.)
use serde_json::{json, Value};
use std::process::Output;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// Run the built `anvil` binary with `--json` against `server_uri`, capturing
/// its output. Callers pass `--repo`/`--organization` explicitly so repo
/// resolution never falls through to the developer's real config or git remote.
fn run_json(server_uri: &str, args: &[&str]) -> Output {
std::process::Command::new(env!("CARGO_BIN_EXE_anvil"))
.arg("--json")
.args(args)
.env("ANVIL_SERVER_URL", server_uri)
.env("ANVIL_TOKEN", "test-token")
.output()
.expect("failed to run anvil binary")
}
/// Parse stdout as JSON, surfacing stdout+stderr on failure so a regression
/// (e.g. a stray human-readable line leaking onto stdout) is easy to diagnose.
fn stdout_json(out: &Output) -> Value {
serde_json::from_slice(&out.stdout).unwrap_or_else(|e| {
panic!(
"stdout was not valid JSON: {e}\n--- stdout ---\n{}\n--- stderr ---\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
)
})
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn requirement_list_emits_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"requirements": [{
"requirement_id": "REQ-ACCT-001",
"title": "Users can register",
"category": "functional",
"priority": "high",
"status": "active",
"test_count": 2
}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["requirement", "list", "--repo", "test-org/test-repo"],
);
assert!(
out.status.success(),
"expected success; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert!(v["requirements"].is_array(), "got: {v}");
assert_eq!(v["requirements"][0]["requirement_id"], "REQ-ACCT-001");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn standard_list_emits_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/standards"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"standards": [{
"requirement_id": "STD-GDPR-017",
"title": "Right to erasure",
"framework": "GDPR",
"citation": "Art. 17",
"mandatory": true,
"status": "active"
}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"list",
"--kind",
"standard",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["standards"][0]["requirement_id"], "STD-GDPR-017");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn requirement_view_emits_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements/REQ-ACCT-001"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"requirement_id": "REQ-ACCT-001",
"title": "Users can register",
"category": "functional",
"priority": "high",
"status": "active"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"view",
"REQ-ACCT-001",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["requirement_id"], "REQ-ACCT-001");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn standard_view_emits_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/standards/STD-GDPR-017"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"requirement_id": "STD-GDPR-017",
"title": "Right to erasure",
"framework": "GDPR",
"mandatory": true,
"status": "active"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"view",
"STD-GDPR-017",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["requirement_id"], "STD-GDPR-017");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn status_emits_json_and_passes_when_covered() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements/matrix"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"matrix": [
{"coverage_status": "covered",
"requirement": {"requirement_id": "REQ-A", "title": "A"}}
]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["requirement", "status", "--repo", "test-org/test-repo"],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["passed"], true);
assert_eq!(v["strict"], false);
assert_eq!(v["counts"]["covered"], 1);
assert_eq!(v["counts"]["total"], 1);
assert!(v["failing"].as_array().unwrap().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn status_strict_emits_json_and_exits_nonzero_when_failing() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements/matrix"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"matrix": [
{"coverage_status": "covered",
"requirement": {"requirement_id": "REQ-A", "title": "A"}},
{"coverage_status": "partial",
"requirement": {"requirement_id": "REQ-B", "title": "B"}},
{"coverage_status": "uncovered",
"requirement": {"requirement_id": "REQ-C", "title": "C"}}
]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"status",
"--strict",
"--repo",
"test-org/test-repo",
],
);
// Gate must still fail the process even though we emitted JSON.
assert!(
!out.status.success(),
"strict status with uncovered/partial must exit non-zero"
);
// ...and stdout must still be clean, parseable JSON for the agent.
let v = stdout_json(&out);
assert_eq!(v["passed"], false);
assert_eq!(v["strict"], true);
assert_eq!(v["counts"]["covered"], 1);
assert_eq!(v["counts"]["partial"], 1);
assert_eq!(v["counts"]["uncovered"], 1);
assert_eq!(v["counts"]["total"], 3);
// Strict mode reports both the uncovered and the partial as failing.
let failing = v["failing"].as_array().unwrap();
assert_eq!(failing.len(), 2, "got: {v}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn strict_standards_emits_json_and_passes() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/standards/strict"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"status": "ok"})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"status",
"--strict-standards",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["passed"], true);
assert!(v["uncovered"].as_array().unwrap().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn strict_standards_emits_json_and_exits_nonzero_when_uncovered() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/standards/strict"))
.respond_with(ResponseTemplate::new(422).set_body_json(json!({
"status": "uncovered",
"uncovered": [{
"standard": {"requirement_id": "STD-GDPR-017", "title": "Right to erasure"},
"repo": {"slug": "customer-app"},
"status": "uncovered"
}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"status",
"--strict-standards",
"--organization",
"test-org",
],
);
assert!(
!out.status.success(),
"uncovered mandatory standards must exit non-zero"
);
let v = stdout_json(&out);
assert_eq!(v["passed"], false);
let uncovered = v["uncovered"].as_array().unwrap();
assert_eq!(uncovered.len(), 1, "got: {v}");
assert_eq!(uncovered[0]["standard"]["requirement_id"], "STD-GDPR-017");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn applicability_list_emits_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(
"/api/v1/test-org/standards/STD-GDPR-017/applicabilities",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"repositories": [{"slug": "app", "name": "App", "visibility": "private"}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"applicability",
"list",
"STD-GDPR-017",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["repositories"][0]["slug"], "app");
}