@@ -100,11 +100,17 @@
},
/// Manage which repositories a standard applies to (STD-* only)
Applicability(ApplicabilityArgs),
/// Show the traceability matrix
/// Show the traceability matrix (requirements per repo, or standards per org)
Matrix {
/// Repository (org/repo)
/// Repository (org/repo) — required for --kind requirement (default)
#[arg(long)]
repo: Option<String>,
/// Organization slug — required for --kind standard
#[arg(long)]
organization: Option<String>,
/// Matrix kind: requirement (per-repo coverage, default) | standard (org-wide)
#[arg(long, default_value = "requirement")]
kind: String,
},
/// Check requirement coverage status (exit 1 if uncovered requirements exist)
Status(StatusArgs),
@@ -352,7 +358,11 @@
organization,
} => delete(&id, repo.as_deref(), organization.as_deref()).await,
RequirementCommand::Applicability(args) => applicability(args).await,
RequirementCommand::Matrix { repo } => matrix(repo.as_deref()).await,
RequirementCommand::Matrix {
repo,
organization,
kind,
} => matrix(repo.as_deref(), organization.as_deref(), &kind).await,
RequirementCommand::Status(args) => status(args).await,
RequirementCommand::Seed { repo, clear } => seed(repo.as_deref(), clear).await,
}
@@ -1065,7 +1075,19 @@
Ok(())
}
async fn matrix(
repo: Option<&str>,
organization: Option<&str>,
kind: &str,
async fn matrix(repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>> {
match kind {
"requirement" => matrix_requirements(repo).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>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
@@ -1099,6 +1121,65 @@
Ok(())
}
async fn matrix_standards(organization: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let org = organization.ok_or("--kind standard requires --organization <slug>")?;
let client = Client::from_config()?;
let resp: serde_json::Value = client.get(&format!("/{org}/standards/matrix")).await?;
let entries = resp
.get("matrix")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
output::header(&format!("Standards Coverage Matrix ({org})"));
if entries.is_empty() {
println!("(no standards defined)");
return Ok(());
}
let mut total_cells = 0usize;
for entry in &entries {
let std = &entry["standard"];
let std_id = std["requirement_id"].as_str().unwrap_or("-");
let title = std["title"].as_str().unwrap_or("-");
let framework = std["framework"].as_str().unwrap_or("");
let mandatory = std["mandatory"].as_bool().unwrap_or(false);
let mandatory_tag = if mandatory { " [mandatory]" } else { "" };
let framework_tag = if framework.is_empty() {
String::new()
} else {
format!(" ({framework})")
};
println!("\n{std_id}{framework_tag} — {title}{mandatory_tag}");
let repos = entry["repos"].as_array().cloned().unwrap_or_default();
if repos.is_empty() {
println!(" (not opted in by any repository)");
} else {
let rows: Vec<Vec<String>> = repos
.iter()
.map(|cell| {
vec![
cell["slug"].as_str().unwrap_or("-").to_string(),
cell["status"].as_str().unwrap_or("unknown").to_string(),
]
})
.collect();
total_cells += rows.len();
output::print_table(&["REPO", "STATUS"], &rows);
}
}
println!(
"\n{} standards, {} (standard × repo) cells",
entries.len(),
total_cells
);
Ok(())
}
async fn status(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(args.repo.as_deref())?;
@@ -1830,6 +1911,21 @@
};
let err = applicability(args).await.unwrap_err().to_string();
assert!(err.contains("standards-only"), "got: {err}");
}
#[tokio::test]
async fn matrix_standards_rejects_missing_organization() {
let err = matrix(None, None, "standard")
.await
.unwrap_err()
.to_string();
assert!(err.contains("--organization"), "got: {err}");
}
#[tokio::test]
async fn matrix_rejects_unknown_kind() {
let err = matrix(None, None, "bogus").await.unwrap_err().to_string();
assert!(err.contains("bogus"), "got: {err}");
}
#[tokio::test]