@@ -5,11 +5,44 @@
use serde::Deserialize;
#[derive(Args)]
#[command(after_help = REQUIREMENT_EXAMPLES)]
pub struct RequirementArgs {
#[command(subcommand)]
pub command: RequirementCommand,
}
const REQUIREMENT_EXAMPLES: &str = "\
Examples:
# Repo-scoped requirements (REQ-*)
anvil requirement create --requirement-id REQ-ACCT-001 \\
--title 'User login' --category security --repo fangorn/myapp
anvil requirement view REQ-ACCT-001 --repo fangorn/myapp
anvil requirement update REQ-ACCT-001 --status active --repo fangorn/myapp
anvil requirement delete REQ-ACCT-001 --repo fangorn/myapp
# Org-scoped standards (STD-*)
anvil requirement create --requirement-id STD-GDPR-017 \\
--title 'Right to erasure' --organization fangorn \\
--framework GDPR --citation 'Art. 17' --authority EU --mandatory
anvil requirement view STD-GDPR-017 --organization fangorn
anvil requirement view STD-GDPR-017 --organization fangorn --show-blanks
anvil requirement update STD-GDPR-017 --organization fangorn --mandatory false
anvil requirement delete STD-GDPR-017 --organization fangorn
# Listing
anvil requirement list # repo-scoped (default)
anvil requirement list --kind standard --organization fangorn # standards in an org
anvil requirement list --kind standard --organization fangorn \\
--framework GDPR --mandatory # filter standards
anvil requirement list --kind all --repo fangorn/myapp \\
--organization fangorn # both halves
ID prefixes (REQ-, STD-) are case-sensitive.
";
#[derive(Subcommand)]
pub enum RequirementCommand {
/// List requirements (repo-scoped) or standards (org-scoped) — pass --kind to switch
@@ -46,11 +79,25 @@
/// Organization slug — required for STD-* IDs
#[arg(long)]
organization: Option<String>,
/// Show fields with no value (default: skip blanks)
#[arg(long)]
show_blanks: bool,
},
/// Create a requirement (REQ-…) or standard (STD-…) — kind inferred from ID prefix
Create(Box<CreateArgs>),
/// Update an existing requirement (REQ-…) or standard (STD-…) — kind inferred from ID prefix
Update(Box<UpdateArgs>),
/// Delete (soft-delete) a requirement (REQ-…) or standard (STD-…)
Delete {
/// Requirement ID (REQ-…) or Standard ID (STD-…)
id: String,
/// Repository (org/repo) — required for REQ-* IDs
#[arg(long)]
repo: Option<String>,
/// Organization slug — required for STD-* IDs
#[arg(long)]
organization: Option<String>,
},
/// Show the traceability matrix
Matrix {
/// Repository (org/repo)
@@ -81,7 +128,36 @@
strict: bool,
}
/// Metadata flags shared by `create` and `update` for both kinds.
/// Standards-only fields are still accepted on the struct so the
/// parser is consistent — the per-command create/update routing rejects
/// standards flags on REQ-* IDs with a clear message.
#[derive(Args)]
pub struct StandardMetadataFlags {
/// Free-text source (e.g. "ADR-014", "GDPR Art. 17") — for either kind
#[arg(long)]
pub source: Option<String>,
/// Link to an ADR, RFC, or external authority page — for either kind
#[arg(long)]
pub external_url: Option<String>,
/// Standards-only: framework label (e.g. "GDPR", "WCAG", "OFAC SDN")
#[arg(long)]
pub framework: Option<String>,
/// Standards-only: citation within the framework (e.g. "Art. 17", "SC 1.4.3")
#[arg(long)]
pub citation: Option<String>,
/// Standards-only: framework version / effective revision
#[arg(long)]
pub framework_version: Option<String>,
/// Standards-only: authority (e.g. "EU", "W3C", "US Treasury")
#[arg(long)]
pub authority: Option<String>,
/// Standards-only: effective date (YYYY-MM-DD)
#[arg(long)]
pub effective_date: Option<String>,
}
#[derive(Args)]
pub struct UpdateArgs {
/// Requirement ID (REQ-…) or Standard ID (STD-…)
pub id: String,
@@ -106,28 +182,10 @@
/// Organization slug — required for STD-*
#[arg(long)]
pub organization: Option<String>,
#[command(flatten)]
pub meta: StandardMetadataFlags,
/// Standards-only: mark as mandatory (true|false). Update is tri-state —
/// omit to leave unchanged.
/// Free-text source — for either kind
#[arg(long)]
pub source: Option<String>,
/// Link to an ADR / RFC / external authority page — for either kind
#[arg(long)]
pub external_url: Option<String>,
/// Standards-only: framework label
#[arg(long)]
pub framework: Option<String>,
/// Standards-only: citation
#[arg(long)]
pub citation: Option<String>,
/// Standards-only: framework version
#[arg(long)]
pub framework_version: Option<String>,
/// Standards-only: authority
#[arg(long)]
pub authority: Option<String>,
/// Standards-only: effective date (YYYY-MM-DD)
#[arg(long)]
pub effective_date: Option<String>,
/// Standards-only: mark as mandatory
#[arg(long)]
pub mandatory: Option<bool>,
}
@@ -161,27 +219,8 @@
/// Organization slug — required for STD-*
#[arg(long)]
pub organization: Option<String>,
/// Free-text source (e.g. "ADR-014", "GDPR Art. 17") — for either kind
#[arg(long)]
pub source: Option<String>,
/// Link to an ADR, RFC, or external authority page — for either kind
#[arg(long)]
pub external_url: Option<String>,
/// Standards-only: framework label (e.g. "GDPR", "WCAG", "OFAC SDN")
#[arg(long)]
pub framework: Option<String>,
/// Standards-only: citation within the framework (e.g. "Art. 17", "SC 1.4.3")
#[arg(long)]
pub citation: Option<String>,
/// Standards-only: framework version / effective revision
#[arg(long)]
pub framework_version: Option<String>,
/// Standards-only: authority (e.g. "EU", "W3C", "US Treasury")
#[command(flatten)]
pub meta: StandardMetadataFlags,
#[arg(long)]
pub authority: Option<String>,
/// Standards-only: effective date (YYYY-MM-DD)
#[arg(long)]
pub effective_date: Option<String>,
/// Standards-only: mark as mandatory (default false)
#[arg(long)]
pub mandatory: bool,
@@ -240,7 +279,8 @@
id,
repo,
organization,
} => view(&id, repo.as_deref(), organization.as_deref()).await,
show_blanks,
} => view(&id, repo.as_deref(), organization.as_deref(), show_blanks).await,
RequirementCommand::Create(args) => {
let inputs = CreateInputs {
requirement_id: &args.requirement_id,
@@ -252,18 +292,23 @@
parent_id: args.parent_id.as_deref(),
repo: args.repo.as_deref(),
organization: args.organization.as_deref(),
source: args.source.as_deref(),
external_url: args.external_url.as_deref(),
framework: args.framework.as_deref(),
citation: args.citation.as_deref(),
framework_version: args.framework_version.as_deref(),
source: args.meta.source.as_deref(),
external_url: args.meta.external_url.as_deref(),
framework: args.meta.framework.as_deref(),
citation: args.meta.citation.as_deref(),
framework_version: args.meta.framework_version.as_deref(),
authority: args.meta.authority.as_deref(),
effective_date: args.meta.effective_date.as_deref(),
authority: args.authority.as_deref(),
effective_date: args.effective_date.as_deref(),
mandatory: args.mandatory,
};
create(inputs).await
}
RequirementCommand::Update(args) => update(*args).await,
RequirementCommand::Delete {
id,
repo,
organization,
} => delete(&id, repo.as_deref(), organization.as_deref()).await,
RequirementCommand::Matrix { repo } => matrix(repo.as_deref()).await,
RequirementCommand::Status(args) => status(args).await,
RequirementCommand::Seed { repo, clear } => seed(repo.as_deref(), clear).await,
@@ -406,13 +451,11 @@
id: &str,
repo: Option<&str>,
organization: Option<&str>,
show_blanks: bool,
) -> Result<(), Box<dyn std::error::Error>> {
match infer_kind(id) {
Kind::Standard => view_standard(id, organization).await,
Kind::Standard => view_standard(id, organization, show_blanks).await,
Kind::Requirement => view_requirement(id, repo).await,
Kind::Unknown => Err(format!(
"could not infer kind from '{id}' — IDs must start with REQ- (requirement) or STD- (standard)"
)
.into()),
Kind::Unknown => Err(unknown_prefix_error(id).into()),
}
}
@@ -456,6 +499,7 @@
async fn view_standard(
std_id: &str,
organization: Option<&str>,
show_blanks: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let org = organization.ok_or("viewing a standard requires --organization <slug>")?;
let client = Client::from_config()?;
@@ -466,25 +510,28 @@
std["requirement_id"].as_str().unwrap_or("?"),
std["title"].as_str().unwrap_or("?")
));
let detail_str = |label: &str, key: &str| {
let v = std[key].as_str().unwrap_or("");
if !v.is_empty() {
output::detail(label, v);
} else if show_blanks {
output::detail(label, "-");
}
};
output::detail("ID", std["requirement_id"].as_str().unwrap_or("-"));
output::detail("Title", std["title"].as_str().unwrap_or("-"));
output::detail(
"Description",
std["description"].as_str().unwrap_or("(none)"),
);
detail_str("Description", "description");
detail_str("Category", "category");
output::detail("Category", std["category"].as_str().unwrap_or("-"));
output::detail("Status", std["status"].as_str().unwrap_or("-"));
output::detail("Framework", std["framework"].as_str().unwrap_or("-"));
output::detail("Citation", std["citation"].as_str().unwrap_or("-"));
output::detail(
"Framework version",
std["framework_version"].as_str().unwrap_or("-"),
);
output::detail("Authority", std["authority"].as_str().unwrap_or("-"));
output::detail(
"Effective date",
std["effective_date"].as_str().unwrap_or("-"),
);
detail_str("Framework", "framework");
detail_str("Citation", "citation");
detail_str("Framework version", "framework_version");
detail_str("Authority", "authority");
detail_str("Effective date", "effective_date");
// Mandatory is a bool; always show — yes/no is informative either way.
output::detail(
"Mandatory",
if std["mandatory"].as_bool().unwrap_or(false) {
@@ -493,14 +540,74 @@
"no"
},
);
output::detail("Source", std["source"].as_str().unwrap_or("-"));
output::detail("External URL", std["external_url"].as_str().unwrap_or("-"));
detail_str("Source", "source");
detail_str("External URL", "external_url");
if let Some(id) = std["id"].as_str() {
output::detail("UUID", id);
}
Ok(())
}
async fn delete(
id: &str,
repo: Option<&str>,
organization: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
match infer_kind(id) {
Kind::Standard => delete_standard(id, repo, organization).await,
Kind::Requirement => delete_requirement(id, repo, organization).await,
Kind::Unknown => Err(unknown_prefix_error(id).into()),
}
}
async fn delete_requirement(
id: &str,
repo: Option<&str>,
organization: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
if organization.is_some() {
return Err(
"REQ-* requirements are repo-scoped — pass --repo (or use a default), not --organization"
.into(),
);
}
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
client
.delete_empty(&format!("/{org}/{name}/requirements/{id}"))
.await?;
output::success(&format!("Deleted requirement '{id}'"));
Ok(())
}
async fn delete_standard(
id: &str,
repo: Option<&str>,
organization: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
if repo.is_some() {
return Err(format!(
"standards are org-scoped — drop --repo and pass --organization <slug> instead (got --repo with '{id}')"
)
.into());
}
let org = organization.ok_or("deleting a standard requires --organization <slug>")?;
let client = Client::from_config()?;
client
.delete_empty(&format!("/{org}/standards/{id}"))
.await?;
output::success(&format!("Deleted standard '{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"
)
}
struct CreateInputs<'a> {
requirement_id: &'a str,
title: &'a str,
@@ -541,10 +648,6 @@
match infer_kind(inputs.requirement_id) {
Kind::Standard => create_standard(inputs).await,
Kind::Requirement => create_requirement(inputs).await,
Kind::Unknown => Err(format!(
"could not infer kind from '{}' — IDs must start with REQ- (requirement) or STD- (standard)",
inputs.requirement_id
)
.into()),
Kind::Unknown => Err(unknown_prefix_error(inputs.requirement_id).into()),
}
}
@@ -692,10 +795,6 @@
match infer_kind(&args.id) {
Kind::Standard => update_standard(args).await,
Kind::Requirement => update_requirement(args).await,
Kind::Unknown => Err(format!(
"could not infer kind from '{}' — IDs must start with REQ- (requirement) or STD- (standard)",
args.id
)
Kind::Unknown => Err(unknown_prefix_error(&args.id).into()),
.into()),
}
}
@@ -707,11 +806,11 @@
.into(),
);
}
if args.framework.is_some()
|| args.citation.is_some()
|| args.framework_version.is_some()
|| args.authority.is_some()
|| args.effective_date.is_some()
if args.meta.framework.is_some()
|| args.meta.citation.is_some()
|| args.meta.framework_version.is_some()
|| args.meta.authority.is_some()
|| args.meta.effective_date.is_some()
|| args.mandatory.is_some()
{
return Err(format!(
@@ -739,10 +838,10 @@
if let Some(v) = &args.status {
payload["status"] = serde_json::json!(v);
}
if let Some(v) = &args.meta.source {
if let Some(v) = &args.source {
payload["source"] = serde_json::json!(v);
}
if let Some(v) = &args.meta.external_url {
if let Some(v) = &args.external_url {
payload["external_url"] = serde_json::json!(v);
}
let resp: RequirementDetail = client
@@ -786,25 +885,25 @@
if let Some(v) = &args.status {
payload["status"] = serde_json::json!(v);
}
if let Some(v) = &args.source {
if let Some(v) = &args.meta.source {
payload["source"] = serde_json::json!(v);
}
if let Some(v) = &args.external_url {
if let Some(v) = &args.meta.external_url {
payload["external_url"] = serde_json::json!(v);
}
if let Some(v) = &args.meta.framework {
if let Some(v) = &args.framework {
payload["framework"] = serde_json::json!(v);
}
if let Some(v) = &args.citation {
if let Some(v) = &args.meta.citation {
payload["citation"] = serde_json::json!(v);
}
if let Some(v) = &args.meta.framework_version {
if let Some(v) = &args.framework_version {
payload["framework_version"] = serde_json::json!(v);
}
if let Some(v) = &args.authority {
if let Some(v) = &args.meta.authority {
payload["authority"] = serde_json::json!(v);
}
if let Some(v) = &args.effective_date {
if let Some(v) = &args.meta.effective_date {
payload["effective_date"] = serde_json::json!(v);
}
if let Some(v) = args.mandatory {
@@ -1475,6 +1574,18 @@
assert!(err.contains("STD-"), "got: {err}");
}
fn empty_meta() -> StandardMetadataFlags {
StandardMetadataFlags {
source: None,
external_url: None,
framework: None,
citation: None,
framework_version: None,
authority: None,
effective_date: None,
}
}
fn base_update(id: &str) -> UpdateArgs {
UpdateArgs {
id: id.into(),
@@ -1485,13 +1596,7 @@
status: None,
repo: None,
organization: None,
meta: empty_meta(),
source: None,
external_url: None,
framework: None,
citation: None,
framework_version: None,
authority: None,
effective_date: None,
mandatory: None,
}
}
@@ -1532,7 +1637,7 @@
#[tokio::test]
async fn update_rejects_req_with_standards_flags() {
let mut args = base_update("REQ-A-001");
args.framework = Some("GDPR".into());
args.meta.framework = Some("GDPR".into());
let err = update(args).await.unwrap_err().to_string();
assert!(err.contains("standards-only"), "got: {err}");
}