ref:58c52f6e075bf3fc121001d051ef7149aff98ed1

feat(cli): complete requirement/standard machine-readable + link surface (#330)

Extends the read/status --json work into the full Workstream-B CLI surface so an agent can drive the entire requirements/standards loop structurally: - Mutation --json confirmations with a uniform `{"ok": true, <noun>: …}` envelope: create/update (echo the entity), delete/unlink (id/pair), applicability add/remove (opted_in bool), seed (created/skipped/cleared/ total). Human output unchanged. - `requirement link` / `requirement unlink` subcommands (part of #239) — POST/DELETE the existing /requirements/:id/links endpoints. The unlink test name is percent-encoded as a path segment (spaces/dots/slashes safe). - `seed --clear` now actually clears: it hard-deletes every requirement, children before parents. The stub previously warned "no delete endpoint exposed" and skipped — but DELETE /requirements/:id exists. A soft delete would leave rows occupying the unique (repository_id, requirement_id) index and every re-seed create would 422/skip, so --clear deletes hard; and hard_delete cascade-soft-deletes descendants (which the API then hides), so children must be deleted first. Shared `output::json_ok/2` helper prints the envelope. Tests: integration tests (real binary vs wiremock) for each mutation envelope + link + encoded unlink; an in-module test asserting clear_requirements issues hard DELETEs children-first. cargo fmt/clippy -D warnings clean; 138 unit + 14 integration green. Out of this PR by repo boundary (need fangorn/anvil server changes, not the CLI): #240 matrix ?run_id=, matrix policy/run_window through the API, and requirement import/export (no server endpoint yet). Part of epic fangorn/anvil#329 (Workstream B). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SHA: 58c52f6e075bf3fc121001d051ef7149aff98ed1
Author: CI <ci@anvil.test>
Date: 2026-07-03 06:16
Parents: 7566df2
5 files changed +493 -35
Type
Cargo.lock +1 −0
@@ -83,6 +83,7 @@
"glob",
"hostname",
"libc",
"percent-encoding",
"reqwest",
"serde",
"serde_json",
Cargo.toml +1 −0
@@ -22,6 +22,7 @@
chrono = { version = "0.4", features = ["serde"] }
tabled = "0.17"
url = "2"
percent-encoding = "2"
thiserror = "2"
hostname = "0.4"
libc = "0.2"
src/commands/requirement.rs +308 −35
@@ -2,6 +2,7 @@
use crate::config;
use crate::output;
use clap::{Args, Subcommand};
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use serde::Deserialize;
#[derive(Args)]
@@ -98,6 +99,34 @@
#[arg(long)]
organization: Option<String>,
},
/// Link a test to a requirement (REQ-* only)
Link {
/// Requirement ID (REQ-…)
id: String,
/// Test name/identifier to link (e.g. an ExUnit test or classname.name)
#[arg(long)]
test: String,
/// Optional source file path for the test
#[arg(long)]
test_file: Option<String>,
/// Optional line number for the test within the file
#[arg(long)]
test_line: Option<i64>,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Unlink a test from a requirement (REQ-* only)
Unlink {
/// Requirement ID (REQ-…)
id: String,
/// Test name/identifier to unlink
#[arg(long)]
test: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Manage which repositories a standard applies to (STD-* only)
Applicability(ApplicabilityArgs),
/// Show the traceability matrix (requirements per repo, or standards per org)
@@ -367,6 +396,16 @@
repo,
organization,
} => delete(&id, repo.as_deref(), organization.as_deref()).await,
RequirementCommand::Link {
id,
test,
test_file,
test_line,
repo,
} => link_test(&id, &test, test_file.as_deref(), test_line, repo.as_deref()).await,
RequirementCommand::Unlink { id, test, repo } => {
unlink_test(&id, &test, repo.as_deref()).await
}
RequirementCommand::Applicability(args) => applicability(args).await,
RequirementCommand::Matrix {
repo,
@@ -660,6 +699,10 @@
client
.delete_empty(&format!("/{org}/{name}/requirements/{id}"))
.await?;
if output::is_json() {
output::json_ok("deleted", serde_json::json!(id));
return Ok(());
}
output::success(&format!("Deleted requirement '{id}'"));
Ok(())
}
@@ -680,10 +723,86 @@
client
.delete_empty(&format!("/{org}/standards/{id}"))
.await?;
if output::is_json() {
output::json_ok("deleted", serde_json::json!(id));
return Ok(());
}
output::success(&format!("Deleted standard '{id}'"));
Ok(())
}
/// Guard: test↔requirement links are requirements-only (REQ-*); the link
/// endpoints do not exist for standards.
fn require_requirement_id(id: &str) -> Result<(), Box<dyn std::error::Error>> {
match infer_kind(id) {
Kind::Requirement => Ok(()),
Kind::Standard => {
Err(format!("test links are requirements-only — '{id}' is a STD-* standard").into())
}
Kind::Unknown => Err(unknown_prefix_error(id).into()),
}
}
async fn link_test(
req_id: &str,
test: &str,
test_file: Option<&str>,
test_line: Option<i64>,
repo: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
require_requirement_id(req_id)?;
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let mut payload = serde_json::json!({ "test_name": test });
if let Some(f) = test_file {
payload["test_file"] = serde_json::json!(f);
}
if let Some(l) = test_line {
payload["test_line"] = serde_json::json!(l);
}
let resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/requirements/{req_id}/links"),
&payload,
)
.await?;
if output::is_json() {
output::json_ok("link", resp);
return Ok(());
}
output::success(&format!("Linked test '{test}' to requirement '{req_id}'"));
Ok(())
}
async fn unlink_test(
req_id: &str,
test: &str,
repo: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
require_requirement_id(req_id)?;
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
// The test name is a path segment and may contain spaces, dots, or slashes;
// percent-encode it so the server decodes back to the stored name.
let encoded = utf8_percent_encode(test, NON_ALPHANUMERIC).to_string();
client
.delete_empty(&format!(
"/{org}/{name}/requirements/{req_id}/links/{encoded}"
))
.await?;
if output::is_json() {
output::json_ok(
"unlinked",
serde_json::json!({ "requirement": req_id, "test": test }),
);
return Ok(());
}
output::success(&format!(
"Unlinked test '{test}' from requirement '{req_id}'"
));
Ok(())
}
fn unknown_prefix_error(id: &str) -> String {
format!(
"could not infer kind from '{id}' — IDs must start with REQ- (requirement) or STD- (standard); prefixes are case-sensitive"
@@ -772,6 +891,18 @@
&serde_json::json!({}),
)
.await?;
if output::is_json() {
output::json_ok(
"applicability",
serde_json::json!({
"standard": std_id,
"organization": organization,
"repo": repo,
"opted_in": true,
}),
);
return Ok(());
}
output::success(&format!(
"Opted '{organization}/{repo}' into standard '{std_id}'"
));
@@ -789,6 +920,18 @@
"/{organization}/standards/{std_id}/applicabilities/{repo}"
))
.await?;
if output::is_json() {
output::json_ok(
"applicability",
serde_json::json!({
"standard": std_id,
"organization": organization,
"repo": repo,
"opted_in": false,
}),
);
return Ok(());
}
output::success(&format!(
"Opted '{organization}/{repo}' out of standard '{std_id}'"
));
@@ -882,17 +1025,21 @@
payload["external_url"] = serde_json::json!(v);
}
let resp: RequirementDetail = client
let resp: serde_json::Value = client
.post(&format!("/{org}/{name}/requirements"), &payload)
.await?;
if output::is_json() {
output::json_ok("requirement", resp);
return Ok(());
}
output::success(&format!(
"Created requirement '{}' — {}",
resp["requirement_id"]
.as_str()
resp.requirement_id
.as_deref()
.unwrap_or(inputs.requirement_id),
resp.title.as_deref().unwrap_or(inputs.title)
resp["title"].as_str().unwrap_or(inputs.title)
));
if let Some(id) = resp["id"].as_str() {
if let Some(id) = &resp.id {
output::detail("UUID", id);
}
Ok(())
@@ -956,6 +1103,10 @@
}
let resp: serde_json::Value = client.post(&format!("/{org}/standards"), &payload).await?;
if output::is_json() {
output::json_ok("standard", resp);
return Ok(());
}
output::success(&format!(
"Created standard '{}' — {}",
resp["requirement_id"]
@@ -1031,13 +1182,17 @@
if let Some(v) = &args.meta.external_url {
payload["external_url"] = serde_json::json!(v);
}
let resp: serde_json::Value = client
let resp: RequirementDetail = client
.put(&format!("/{org}/{name}/requirements/{}", args.id), &payload)
.await?;
if output::is_json() {
output::json_ok("requirement", resp);
return Ok(());
}
output::success(&format!(
"Updated requirement '{}' — {}",
resp.requirement_id.as_deref().unwrap_or(&args.id),
resp["requirement_id"].as_str().unwrap_or(&args.id),
resp["title"].as_str().unwrap_or("?")
resp.title.as_deref().unwrap_or("?")
));
Ok(())
}
@@ -1100,6 +1255,10 @@
let resp: serde_json::Value = client
.put(&format!("/{org}/standards/{}", args.id), &payload)
.await?;
if output::is_json() {
output::json_ok("standard", resp);
return Ok(());
}
output::success(&format!(
"Updated standard '{}' — {}",
resp["requirement_id"].as_str().unwrap_or(&args.id),
@@ -1503,25 +1662,14 @@
async fn seed(repo: Option<&str>, clear: bool) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let json = output::is_json();
let mut cleared = 0;
if clear {
output::warn("Clearing existing requirements...");
// List and delete all existing requirements (children first, then parents)
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/requirements")).await?;
let existing: Vec<serde_json::Value> = resp
.get("requirements")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
// We can't easily delete via API since there's no delete endpoint exposed.
// Instead, we'll just create and let unique constraints tell us what already exists.
if !existing.is_empty() {
output::warn(&format!(
"Found {} existing requirements. Duplicates will be skipped.",
existing.len()
if !json {
output::warn("Clearing existing requirements (hard delete)...");
));
}
cleared = clear_requirements(&client, &org, &name).await?;
}
let domains = all_domains();
@@ -1543,7 +1691,9 @@
.await;
let parent_uuid = match parent_result {
Ok(detail) => {
output::success(&format!("Created parent: {} — {}", domain.id, domain.title));
if !json {
output::success(&format!("Created parent: {} — {}", domain.id, domain.title));
}
created += 1;
detail.id
}
@@ -1557,7 +1707,9 @@
let existing: RequirementDetail = client
.get(&format!("/{org}/{name}/requirements/{}", domain.id))
.await?;
if !json {
output::info(&format!("Exists: {} — {}", domain.id, domain.title));
}
output::info(&format!("Exists: {} — {}", domain.id, domain.title));
skipped += 1;
existing.id
} else {
@@ -1584,7 +1736,9 @@
match result {
Ok(_) => {
created += 1;
if !json {
eprint!(".");
eprint!(".");
}
}
Err(e) => {
let err_msg = format!("{e}");
@@ -1593,25 +1747,95 @@
|| err_msg.contains("already")
{
skipped += 1;
eprint!("s");
if !json {
eprint!("s");
}
} else {
if !json {
eprintln!("\nError creating {}: {e}", child.id);
eprintln!("\nError creating {}: {e}", child.id);
}
return Err(e.into());
}
}
}
}
if !json {
eprintln!();
eprintln!();
}
}
if json {
println!();
output::success(&format!(
output::print_json(&serde_json::json!({
"ok": true,
"created": created,
"skipped": skipped,
"cleared": cleared,
"total": created + skipped,
}));
} else {
println!();
output::success(&format!(
"Done! Created: {created}, Skipped (existing): {skipped}, Cleared: {cleared}, Total: {}",
created + skipped
));
}
"Done! Created: {created}, Skipped (existing): {skipped}, Total: {}",
created + skipped
));
Ok(())
}
/// Hard-delete every requirement in the repo, children before parents, so a
/// subsequent seed starts from a clean slate. A soft delete would leave rows
/// occupying the unique `(repository_id, requirement_id)` index — every create
/// would then 422 and be skipped — so `--clear` deletes hard. Returns the count
/// deleted. Children must go first: `hard_delete` cascade-soft-deletes
/// descendants, which the API then hides, so a parent must never be deleted
/// before its children.
async fn clear_requirements(
client: &Client,
org: &str,
name: &str,
) -> Result<usize, Box<dyn std::error::Error>> {
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/requirements")).await?;
let mut remaining: Vec<serde_json::Value> = resp
.get("requirements")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut cleared = 0;
while !remaining.is_empty() {
// UUIDs referenced as a parent by any still-present requirement.
let parent_ids: std::collections::HashSet<String> = remaining
.iter()
.filter_map(|r| r["parent_id"].as_str().map(String::from))
.collect();
let (leaves, rest): (Vec<serde_json::Value>, Vec<serde_json::Value>) =
remaining.into_iter().partition(|r| {
r["id"]
.as_str()
.map(|id| !parent_ids.contains(id))
.unwrap_or(true)
});
// A valid parent_id tree always has a leaf; the empty case only arises
// from an unexpected cycle — delete the remainder rather than loop.
let (batch, next) = if leaves.is_empty() {
(rest, Vec::new())
} else {
(leaves, rest)
};
for r in &batch {
if let Some(rid) = r["requirement_id"].as_str() {
client
.delete_empty(&format!("/{org}/{name}/requirements/{rid}?hard=true"))
.await?;
cleared += 1;
}
}
remaining = next;
}
Ok(cleared)
}
fn all_domains() -> Vec<DomainDef> {
vec![
DomainDef {
@@ -2077,6 +2301,55 @@
assert_eq!(v["counts"]["total"], 2);
assert_eq!(v["failing"][0]["requirement_id"], "REQ-B");
assert_eq!(v["failing"][0]["coverage_status"], "uncovered");
}
#[tokio::test]
async fn clear_requirements_hard_deletes_children_before_parents() {
use serde_json::json;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
// Two-level tree: parent REQ-P (uuid "P") and child REQ-C (parent_id "P").
Mock::given(method("GET"))
.and(path("/api/v1/o/r/requirements"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"requirements": [
{"id": "P", "requirement_id": "REQ-P", "parent_id": null},
{"id": "C", "requirement_id": "REQ-C", "parent_id": "P"}
]
})))
.mount(&server)
.await;
// Both DELETEs REQUIRE ?hard=true — if the CLI omits it, no mock matches,
// the request 404s, and clear_requirements returns Err (test fails).
for rid in ["REQ-C", "REQ-P"] {
Mock::given(method("DELETE"))
.and(path(format!("/api/v1/o/r/requirements/{rid}")))
.and(query_param("hard", "true"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
}
let client = Client::for_test(server.uri(), "tok");
let cleared = clear_requirements(&client, "o", "r").await.unwrap();
assert_eq!(cleared, 2);
// The child (REQ-C) must be deleted before the parent (REQ-P).
let reqs = server.received_requests().await.unwrap();
let deleted: Vec<String> = reqs
.iter()
.map(|r| r.url.path().to_string())
.filter(|p| p.starts_with("/api/v1/o/r/requirements/REQ-"))
.collect();
assert_eq!(
deleted,
vec![
"/api/v1/o/r/requirements/REQ-C".to_string(),
"/api/v1/o/r/requirements/REQ-P".to_string(),
]
);
}
// The create() routing rules — these are unit tests on validation
src/output.rs +10 −0
@@ -27,6 +27,16 @@
}
}
/// Print a uniform `{ "ok": true, <key>: <value> }` confirmation for a
/// successful mutation in JSON mode. Callers guard with `is_json()` and return
/// afterward, so scripts get a predictable success envelope on stdout.
pub fn json_ok(key: &str, value: serde_json::Value) {
let mut map = serde_json::Map::new();
map.insert("ok".to_string(), serde_json::Value::Bool(true));
map.insert(key.to_string(), value);
print_json(&serde_json::Value::Object(map));
}
/// Print a key-value detail line.
pub fn detail(key: &str, value: &str) {
println!("{:>14} {}", key.bold(), value);
tests/json_output.rs +173 −0
@@ -353,3 +353,176 @@
assert_eq!(v["repositories"][0]["slug"], "app");
}
// ── Mutations: uniform {"ok":true, ...} confirmation envelope ────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn create_requirement_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/requirements"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"requirement_id": "REQ-NEW-001",
"title": "New requirement"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"create",
"--requirement-id",
"REQ-NEW-001",
"--title",
"New requirement",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["requirement"]["requirement_id"], "REQ-NEW-001");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn delete_requirement_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/test-org/test-repo/requirements/REQ-OLD-001"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"delete",
"REQ-OLD-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["ok"], true);
assert_eq!(v["deleted"], "REQ-OLD-001");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn applicability_add_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path(
"/api/v1/test-org/standards/STD-GDPR-017/applicabilities/app",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"applicability",
"add",
"STD-GDPR-017",
"--organization",
"test-org",
"--repo",
"app",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["applicability"]["opted_in"], true);
assert_eq!(v["applicability"]["repo"], "app");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn link_test_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(
"/api/v1/test-org/test-repo/requirements/REQ-ACCT-001/links",
))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
"test_name": "Anvil.AccountsTest.login",
"requirement_id": "REQ-ACCT-001"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"link",
"REQ-ACCT-001",
"--test",
"Anvil.AccountsTest.login",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["link"]["test_name"], "Anvil.AccountsTest.login");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unlink_test_encodes_name_and_emits_ok_envelope() {
let server = MockServer::start().await;
// A test name with a space must be percent-encoded in the path segment.
Mock::given(method("DELETE"))
.and(path(
"/api/v1/test-org/test-repo/requirements/REQ-ACCT-001/links/user%20can%20log%20in",
))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"unlink",
"REQ-ACCT-001",
"--test",
"user can log in",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"expected the encoded DELETE to match; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["unlinked"]["test"], "user can log in");
}