@@ -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)
@@ -111,6 +140,15 @@
/// Matrix kind: requirement (per-repo coverage, default) | standard (org-wide)
#[arg(long, default_value = "requirement")]
kind: String,
/// Anchor the matrix on a specific run (point-in-time coverage; forces strict_latest)
#[arg(long)]
run_id: Option<String>,
/// Coverage rollup policy: best | strict_latest | any_recent
#[arg(long)]
policy: Option<String>,
/// How many recent runs to consider (1..100)
#[arg(long)]
run_window: Option<u32>,
},
/// Check requirement coverage status (exit 1 if uncovered requirements exist)
Status(StatusArgs),
@@ -119,10 +157,35 @@
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
/// Clear existing requirements before seeding
/// HARD-DELETE every requirement in the repo (including soft-deleted
/// ones) before seeding. Destructive and irreversible — prompts for
/// confirmation on a terminal; requires --yes otherwise.
#[arg(long)]
clear: bool,
/// Skip the --clear confirmation prompt (for scripts/CI)
#[arg(long)]
yes: bool,
},
/// Bulk-import requirements from a YAML/JSON file (or stdin with "-")
Import {
/// Path to the file ("-" for stdin)
file: String,
/// Input format: yaml (default) | json
#[arg(long, default_value = "yaml")]
format: String,
/// Preview the diff without writing
#[arg(long)]
dry_run: bool,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Export the repo's requirements as import-shaped YAML (round-trips with `import`)
Export {
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
}
#[derive(Args)]
@@ -367,13 +430,43 @@
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,
organization,
kind,
} => matrix(repo.as_deref(), organization.as_deref(), &kind).await,
run_id,
policy,
run_window,
} => {
matrix(
repo.as_deref(),
organization.as_deref(),
&kind,
run_id.as_deref(),
policy.as_deref(),
run_window,
)
.await
}
RequirementCommand::Status(args) => status(args).await,
RequirementCommand::Seed { repo, clear, yes } => seed(repo.as_deref(), clear, yes).await,
RequirementCommand::Import {
file,
format,
dry_run,
RequirementCommand::Seed { repo, clear } => seed(repo.as_deref(), clear).await,
repo,
} => import_requirements(repo.as_deref(), &file, &format, dry_run).await,
RequirementCommand::Export { repo } => export_requirements(repo.as_deref()).await,
}
}
@@ -429,6 +522,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 +569,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 +630,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 +675,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("?"),
@@ -641,6 +753,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(())
}
@@ -661,10 +777,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"
@@ -716,6 +908,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())
@@ -749,6 +945,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}'"
));
@@ -766,6 +974,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}'"
));
@@ -859,17 +1079,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 {
if let Some(id) = resp["id"].as_str() {
output::detail("UUID", id);
}
Ok(())
@@ -933,6 +1157,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"]
@@ -1008,13 +1236,17 @@
if let Some(v) = &args.meta.external_url {
payload["external_url"] = serde_json::json!(v);
}
let resp: RequirementDetail = client
let resp: serde_json::Value = 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.title.as_deref().unwrap_or("?")
resp["requirement_id"].as_str().unwrap_or(&args.id),
resp["title"].as_str().unwrap_or("?")
));
Ok(())
}
@@ -1077,6 +1309,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),
@@ -1089,19 +1325,38 @@
repo: Option<&str>,
organization: Option<&str>,
kind: &str,
run_id: Option<&str>,
policy: Option<&str>,
run_window: Option<u32>,
) -> Result<(), Box<dyn std::error::Error>> {
match kind {
"requirement" => matrix_requirements(repo).await,
"requirement" => matrix_requirements(repo, run_id, policy, run_window).await,
"standard" => matrix_standards(organization).await,
other => Err(format!("unknown --kind '{other}' (expected requirement | standard)").into()),
}
}
async fn matrix_requirements(repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
async fn matrix_requirements(
repo: Option<&str>,
run_id: Option<&str>,
policy: Option<&str>,
run_window: Option<u32>,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let window_str = run_window.map(|w| w.to_string());
let mut query: Vec<(&str, &str)> = Vec::new();
if let Some(r) = run_id {
query.push(("run_id", r));
}
if let Some(p) = policy {
query.push(("policy", p));
}
if let Some(w) = window_str.as_deref() {
query.push(("run_window", w));
}
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/requirements/matrix"))
.get_with_query(&format!("/{org}/{name}/requirements/matrix"), &query)
.await?;
if output::is_json() {
@@ -1238,7 +1493,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 +1512,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)),
@@ -1284,6 +1552,122 @@
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 {
// Count the whole failing set, not just uncovered: under --strict the
// gate also fails on partial/no_tests, so "N uncovered" would misreport
// (e.g. "0 uncovered" while failing on partials).
Err(format!("{} requirement(s) failing coverage", summary.failing.len()).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 +1681,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)
}
}
@@ -1373,28 +1735,22 @@
children: Vec<ReqDef>,
}
async fn seed(
async fn seed(repo: Option<&str>, clear: bool) -> Result<(), Box<dyn std::error::Error>> {
repo: Option<&str>,
clear: bool,
yes: 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 {
confirm_clear(&client, &org, &name, yes).await?;
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")
if !json {
output::warn("Clearing existing requirements (hard delete)...");
.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()
));
}
cleared = clear_requirements(&client, &org, &name).await?;
}
let domains = all_domains();
@@ -1416,7 +1772,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
}
@@ -1430,7 +1788,9 @@
let existing: RequirementDetail = client
.get(&format!("/{org}/{name}/requirements/{}", domain.id))
.await?;
output::info(&format!("Exists: {} — {}", domain.id, domain.title));
if !json {
output::info(&format!("Exists: {} — {}", domain.id, domain.title));
}
skipped += 1;
existing.id
} else {
@@ -1457,7 +1817,9 @@
match result {
Ok(_) => {
created += 1;
eprint!(".");
if !json {
eprint!(".");
}
}
Err(e) => {
let err_msg = format!("{e}");
@@ -1466,25 +1828,161 @@
|| err_msg.contains("already")
{
skipped += 1;
eprint!("s");
if !json {
eprint!("s");
}
} else {
eprintln!("\nError creating {}: {e}", child.id);
if !json {
eprintln!("\nError creating {}: {e}", child.id);
}
return Err(e.into());
}
}
}
}
eprintln!();
if !json {
eprintln!();
}
}
if json {
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: {}",
println!();
output::success(&format!(
"Done! Created: {created}, Skipped (existing): {skipped}, Total: {}",
created + skipped
));
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.
/// --clear is irreversible (rows, version history, and test links all go).
/// Show the blast radius first, then require an explicit go-ahead: a TTY
/// prompt interactively, or --yes for scripts — never silently.
async fn confirm_clear(
client: &Client,
org: &str,
name: &str,
yes: bool,
) -> Result<(), Box<dyn std::error::Error>> {
if yes {
return Ok(());
}
let count = fetch_all_requirements(client, org, name).await?.len();
if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
return Err(format!(
"--clear would HARD-DELETE all {count} requirement(s) in {org}/{name} \
(including version history and test links). Re-run with --yes to confirm."
)
.into());
}
let confirmed = dialoguer::Confirm::new()
.with_prompt(format!(
"HARD-DELETE all {count} requirement(s) in {org}/{name}? This cannot be undone"
))
.default(false)
.interact()?;
if confirmed {
Ok(())
} else {
Err("aborted — nothing was deleted".into())
}
}
/// The full slate, INCLUDING soft-deleted rows: they still occupy the unique
/// (repository_id, requirement_id) index, so a clear that skipped them would
/// leave every re-seeded create 422-ing — the exact failure --clear exists to
/// prevent.
async fn fetch_all_requirements(
client: &Client,
org: &str,
name: &str,
) -> Result<Vec<serde_json::Value>, Box<dyn std::error::Error>> {
let resp: serde_json::Value = client
.get_with_query(
&format!("/{org}/{name}/requirements"),
&[("include_deleted", "true")],
)
.await?;
Ok(resp
.get("requirements")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default())
}
async fn clear_requirements(
client: &Client,
org: &str,
name: &str,
) -> Result<usize, Box<dyn std::error::Error>> {
let mut remaining = fetch_all_requirements(client, org, name).await?;
// Children-first ordering depends on the list payload carrying parent_id.
// A server that predates that contract would make us delete parents first,
// whose hard-delete cascade-SOFT-deletes children and strands the clear
// half-done — refuse loudly instead of misbehaving destructively.
if !remaining.is_empty() && remaining.iter().all(|r| r.get("parent_id").is_none()) {
return Err(
"this server's requirements API does not expose parent_id, so --clear cannot \
order deletes safely. Upgrade the Anvil server first."
.into(),
);
}
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 {
@@ -1805,6 +2303,114 @@
]
}
async fn import_requirements(
repo: Option<&str>,
file: &str,
format: &str,
dry_run: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let text = if file == "-" {
use std::io::Read;
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
buf
} else {
std::fs::read_to_string(file)?
};
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/requirements/import"),
&serde_json::json!({"format": format, "text": text, "dry_run": dry_run}),
)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
if dry_run {
let p = &resp["preview"];
let count = |k: &str| p[k].as_array().map(|a| a.len()).unwrap_or(0);
output::header("Import preview (dry run)");
println!(
" {} create · {} update · {} unchanged · {} error(s)",
count("create"),
count("update"),
count("unchanged"),
count("errors")
);
for e in p["errors"].as_array().unwrap_or(&vec![]) {
output::warn(&format!(
"{}: {}",
e["requirement_id"].as_str().unwrap_or("?"),
e["reason"].as_str().unwrap_or("?")
));
}
} else {
let a = &resp["applied"];
output::success(&format!(
"Imported: {} created, {} updated, {} unchanged",
a["created"], a["updated"], a["unchanged"]
));
}
Ok(())
}
/// Fetch the repo's requirements and print them in the import schema, so
/// `export > f.yml` then `import f.yml` round-trips (including into another
/// repo). Parent linkage is emitted as the parent's requirement_id.
/// Descriptions are not exported — the list endpoint doesn't carry them, and
/// import is partial-update shaped so their absence never clobbers anything.
async fn export_requirements(repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/requirements")).await?;
let reqs = resp["requirements"].as_array().cloned().unwrap_or_default();
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
// Map UUID -> requirement_id so `parent` round-trips through import.
let mut id_to_req_id = std::collections::HashMap::new();
for r in &reqs {
if let (Some(uuid), Some(rid)) = (r["id"].as_str(), r["requirement_id"].as_str()) {
id_to_req_id.insert(uuid.to_string(), rid.to_string());
}
}
for r in &reqs {
println!(
"- requirement_id: {}",
yaml_str(r["requirement_id"].as_str().unwrap_or("?"))
);
if let Some(t) = r["title"].as_str() {
println!(" title: {}", yaml_str(t));
}
for key in ["category", "priority", "status"] {
if let Some(v) = r[key].as_str() {
println!(" {key}: {}", yaml_str(v));
}
}
if let Some(parent_uuid) = r["parent_id"].as_str() {
if let Some(parent_rid) = id_to_req_id.get(parent_uuid) {
println!(" parent: {}", yaml_str(parent_rid));
}
}
}
Ok(())
}
/// Quote a YAML scalar defensively (double-quoted style with escapes).
fn yaml_str(s: &str) -> String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1854,8 +2460,218 @@
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");
}
#[tokio::test]
async fn clear_requirements_refuses_servers_without_parent_id() {
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
// Old server contract: entries carry no parent_id key at all.
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"},
{"id": "C", "requirement_id": "REQ-C"}
]
})))
.mount(&server)
.await;
let client = Client::for_test(server.uri(), "tok");
let err = clear_requirements(&client, "o", "r")
.await
.expect_err("must refuse to delete without ordering info");
assert!(err.to_string().contains("parent_id"), "got: {err}");
// Nothing was deleted.
let deletes = server
.received_requests()
.await
.unwrap()
.iter()
.filter(|r| r.method.as_str() == "DELETE")
.count();
assert_eq!(deletes, 0);
}
#[tokio::test]
async fn confirm_clear_requires_yes_when_not_a_tty() {
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
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}]
})))
.mount(&server)
.await;
let client = Client::for_test(server.uri(), "tok");
// --yes short-circuits.
assert!(confirm_clear(&client, "o", "r", true).await.is_ok());
// cargo-test stdin is not a TTY → the prompt path must refuse.
let err = confirm_clear(&client, "o", "r", false)
.await
.expect_err("non-TTY without --yes must refuse");
assert!(err.to_string().contains("--yes"), "got: {err}");
}
#[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").
// The list endpoint includes parent_id per the server contract
// (fangorn/anvil #331) — without it, ordering would be undefined.
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
// logic, not network calls. They check the error MESSAGES, which
// are the contract for shell scripts that parse the CLI output.
@@ -2019,7 +2835,7 @@
#[tokio::test]
async fn matrix_standards_rejects_missing_organization() {
let err = matrix(None, None, "standard")
let err = matrix(None, None, "standard", None, None, None)
.await
.unwrap_err()
.to_string();
@@ -2028,6 +2844,9 @@
#[tokio::test]
async fn matrix_rejects_unknown_kind() {
let err = matrix(None, None, "bogus", None, None, None)
.await
let err = matrix(None, None, "bogus").await.unwrap_err().to_string();
.unwrap_err()
.to_string();
assert!(err.contains("bogus"), "got: {err}");
}