ref:27f01c261ee2e50b17e67728bfc084bcc03ad560

Merge pull request #20 from phase-3-standards-applicability-cli into main

SHA: 27f01c261ee2e50b17e67728bfc084bcc03ad560
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-05-25 20:43
1 files changed +481 -4
Type
src/commands/requirement.rs +481 −4
@@ -98,11 +98,19 @@
#[arg(long)]
organization: Option<String>,
},
/// Show the traceability matrix
/// Manage which repositories a standard applies to (STD-* only)
Applicability(ApplicabilityArgs),
/// 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),
@@ -118,6 +126,46 @@
}
#[derive(Args)]
pub struct ApplicabilityArgs {
#[command(subcommand)]
pub command: ApplicabilityCommand,
}
#[derive(Subcommand)]
pub enum ApplicabilityCommand {
/// List repositories opted into a standard
List {
/// Standard ID (STD-…)
id: String,
/// Organization slug
#[arg(long)]
organization: String,
},
/// Opt a repository into a standard (idempotent)
Add {
/// Standard ID (STD-…)
id: String,
/// Organization slug
#[arg(long)]
organization: String,
/// Repository slug within the organization
#[arg(long)]
repo: String,
},
/// Opt a repository out of a standard (idempotent)
Remove {
/// Standard ID (STD-…)
id: String,
/// Organization slug
#[arg(long)]
organization: String,
/// Repository slug within the organization
#[arg(long)]
repo: String,
},
}
#[derive(Args)]
pub struct StatusArgs {
/// Repository (org/repo)
#[arg(long)]
@@ -126,6 +174,16 @@
/// Fail on partial coverage too (default: only fail on uncovered)
#[arg(long)]
strict: bool,
/// Check the org-wide standards coverage gate (#257) instead of per-repo requirements.
/// Hits GET /api/v1/<org>/standards/strict and exits 1 if any mandatory standard
/// is uncovered for any applicable repo. Requires --organization.
#[arg(long)]
pub strict_standards: bool,
/// Organization slug — required when --strict-standards is set.
#[arg(long)]
pub organization: Option<String>,
}
/// Metadata flags shared by `create` and `update` for both kinds.
@@ -309,7 +367,12 @@
repo,
organization,
} => delete(&id, repo.as_deref(), organization.as_deref()).await,
RequirementCommand::Matrix { repo } => matrix(repo.as_deref()).await,
RequirementCommand::Applicability(args) => applicability(args).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,
}
@@ -608,6 +671,107 @@
)
}
async fn applicability(args: ApplicabilityArgs) -> Result<(), Box<dyn std::error::Error>> {
match args.command {
ApplicabilityCommand::List { id, organization } => {
require_standard_id(&id)?;
applicability_list(&id, &organization).await
}
ApplicabilityCommand::Add {
id,
organization,
repo,
} => {
require_standard_id(&id)?;
applicability_add(&id, &organization, &repo).await
}
ApplicabilityCommand::Remove {
id,
organization,
repo,
} => {
require_standard_id(&id)?;
applicability_remove(&id, &organization, &repo).await
}
}
}
fn require_standard_id(id: &str) -> Result<(), Box<dyn std::error::Error>> {
match infer_kind(id) {
Kind::Standard => Ok(()),
Kind::Requirement => {
Err(format!("applicability is standards-only — '{id}' is a REQ-* requirement").into())
}
Kind::Unknown => Err(unknown_prefix_error(id).into()),
}
}
async fn applicability_list(
std_id: &str,
organization: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let resp: serde_json::Value = client
.get(&format!(
"/{organization}/standards/{std_id}/applicabilities"
))
.await?;
let repos = resp
.get("repositories")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
output::header(&format!("Repos opted into '{std_id}' ({organization})"));
let rows: Vec<Vec<String>> = repos
.iter()
.map(|r| {
vec![
r["slug"].as_str().unwrap_or("").to_string(),
r["name"].as_str().unwrap_or("").to_string(),
r["visibility"].as_str().unwrap_or("").to_string(),
]
})
.collect();
output::print_table(&["SLUG", "NAME", "VISIBILITY"], &rows);
println!("\n{} repositories", rows.len());
Ok(())
}
async fn applicability_add(
std_id: &str,
organization: &str,
repo: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let _: serde_json::Value = client
.put(
&format!("/{organization}/standards/{std_id}/applicabilities/{repo}"),
&serde_json::json!({}),
)
.await?;
output::success(&format!(
"Opted '{organization}/{repo}' into standard '{std_id}'"
));
Ok(())
}
async fn applicability_remove(
std_id: &str,
organization: &str,
repo: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
client
.delete_empty(&format!(
"/{organization}/standards/{std_id}/applicabilities/{repo}"
))
.await?;
output::success(&format!(
"Opted '{organization}/{repo}' out of standard '{std_id}'"
));
Ok(())
}
struct CreateInputs<'a> {
requirement_id: &'a str,
title: &'a str,
@@ -921,12 +1085,30 @@
Ok(())
}
async fn matrix(
repo: Option<&str>,
organization: Option<&str>,
kind: &str,
) -> 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>> {
async fn matrix(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/matrix"))
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let entries = resp
.get("matrix")
.and_then(|v| v.as_array())
@@ -955,8 +1137,155 @@
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?;
// Pipeable JSON output when invoked with the global --json flag.
// Re-emits the server's response verbatim — the structure is already
// stable per REQ-STD-025 and downstream tools (compliance dashboards,
// SOC2 audit exports) can consume it directly.
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
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>> {
if args.strict_standards {
return strict_standards_status(args.organization.as_deref()).await;
}
status_requirements(args).await
}
async fn strict_standards_status(
organization: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let org = organization.ok_or(
"--strict-standards requires --organization <slug> (the org whose mandatory standards to gate on)",
)?;
let client = Client::from_config()?;
strict_standards_with_client(&client, org).await
}
async fn strict_standards_with_client(
client: &Client,
org: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// The endpoint returns 422 on uncovered, so we go through the raw
// request path rather than `client.get` which would treat that as an
// error. We let `client.get` give us a generic Value and inspect the
// body for status === "ok" vs "uncovered".
match client
.get::<serde_json::Value>(&format!("/{org}/standards/strict"))
.await
{
Ok(body) => {
// 2xx with status=ok — coverage gate passed.
if body["status"] == "ok" {
output::success("Standards strict coverage gate passed");
Ok(())
} else {
// Unexpected envelope.
Err(format!("unexpected response body from /standards/strict: {body}").into())
}
}
Err(crate::client::ApiError::Api {
status: 422,
message,
}) => {
// Parse the uncovered list and print a per-(standard, repo) failure.
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);
Err(format!("{count} uncovered mandatory standard(s)").into())
}
Err(other) => Err(Box::new(other)),
}
}
fn print_uncovered(body: &serde_json::Value) {
output::header("Standards Strict Coverage — FAILED");
let entries = body["uncovered"].as_array().cloned().unwrap_or_default();
println!(" {} uncovered mandatory pair(s):", entries.len());
println!();
let rows: Vec<Vec<String>> = entries
.iter()
.map(|e| {
vec![
e["standard"]["requirement_id"]
.as_str()
.unwrap_or("?")
.to_string(),
e["standard"]["title"].as_str().unwrap_or("?").to_string(),
e["repo"]["slug"].as_str().unwrap_or("?").to_string(),
e["status"].as_str().unwrap_or("?").to_string(),
]
})
.collect();
output::print_table(&["STANDARD", "TITLE", "REPO", "STATUS"], &rows);
println!();
}
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())?;
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/requirements/matrix"))
@@ -1658,12 +1987,160 @@
// unreliable without mocking the config layer. The org-missing test above
// is sufficient to prove the pre-validation pattern works.
#[test]
fn require_standard_id_rejects_req() {
let err = require_standard_id("REQ-A-1").unwrap_err().to_string();
assert!(err.contains("standards-only"), "got: {err}");
}
#[test]
fn require_standard_id_rejects_unknown() {
let err = require_standard_id("XYZ-1").unwrap_err().to_string();
assert!(err.contains("REQ-"), "got: {err}");
assert!(err.contains("STD-"), "got: {err}");
}
#[test]
fn require_standard_id_accepts_std() {
assert!(require_standard_id("STD-A-1").is_ok());
}
#[tokio::test]
async fn applicability_list_rejects_req_id() {
let args = ApplicabilityArgs {
command: ApplicabilityCommand::List {
id: "REQ-A-1".into(),
organization: "fangorn".into(),
},
};
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]
async fn applicability_add_rejects_unknown_prefix() {
let args = ApplicabilityArgs {
command: ApplicabilityCommand::Add {
id: "XYZ-1".into(),
organization: "fangorn".into(),
repo: "myapp".into(),
},
};
let err = applicability(args).await.unwrap_err().to_string();
assert!(err.contains("REQ-"), "got: {err}");
assert!(err.contains("STD-"), "got: {err}");
}
#[tokio::test]
async fn list_rejects_unknown_kind() {
let err = list(None, None, None, None, "bogus", None, false)
.await
.unwrap_err()
.to_string();
assert!(err.contains("bogus"), "got: {err}");
}
// ── Phase 7 (#257) — --strict-standards ──
#[tokio::test]
async fn strict_standards_rejects_missing_organization() {
let err = strict_standards_status(None).await.unwrap_err().to_string();
assert!(err.contains("--organization"), "got: {err}");
assert!(err.contains("--strict-standards"), "got: {err}");
}
#[tokio::test]
async fn strict_standards_returns_ok_on_2xx_status_ok() {
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/fangorn/standards/strict"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"status": "ok"})))
.mount(&server)
.await;
let client = Client::for_test(server.uri(), "tok");
let result = strict_standards_with_client(&client, "fangorn").await;
assert!(result.is_ok(), "expected ok, got {result:?}");
}
#[tokio::test]
async fn strict_standards_errors_on_422_uncovered() {
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/fangorn/standards/strict"))
.respond_with(ResponseTemplate::new(422).set_body_json(json!({
"status": "uncovered",
"uncovered": [
{
"standard": {
"id": "11111111-1111-1111-1111-111111111111",
"requirement_id": "STD-GDPR-017",
"title": "Right to erasure",
"framework": "GDPR",
"mandatory": true
},
"repo": {
"id": "22222222-2222-2222-2222-222222222222",
"slug": "customer-app",
"name": "Customer App"
},
"status": "uncovered"
}
]
})))
.mount(&server)
.await;
let client = Client::for_test(server.uri(), "tok");
let err = strict_standards_with_client(&client, "fangorn")
.await
.expect_err("expected uncovered → Err");
let msg = err.to_string();
assert!(msg.contains("1 uncovered"), "got: {msg}");
assert!(msg.contains("mandatory standard"), "got: {msg}");
}
#[tokio::test]
async fn strict_standards_propagates_non_422_errors() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/no-such/standards/strict"))
.respond_with(ResponseTemplate::new(404).set_body_string(r#"{"error":"not_found"}"#))
.mount(&server)
.await;
let client = Client::for_test(server.uri(), "tok");
let err = strict_standards_with_client(&client, "no-such")
.await
.expect_err("expected 404 → Err");
// The CLI's underlying ApiError stringifies as "API error (404): ..."
let msg = err.to_string();
assert!(msg.contains("404"), "got: {msg}");
}
}