@@ -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