ref:a29de45b3cf92f70e1837fff913337e334c4fc54

feat(standards): CLI for org-scoped standards (#252)

Phase 2 of #250 — CLI half. The server-side controller + routes ship in the anvil repo. anvil requirement create now infers kind from the requirement_id prefix (STD- = standard, REQ- = requirement) and routes to the correct endpoint: - REQ-* uses the existing repo-scoped POST /:org/:repo/requirements - STD-* uses the new org-scoped POST /:org/standards New standards-specific flags: --organization <slug> required for STD-* --framework <label> e.g. GDPR, WCAG, OFAC SDN --citation <text> e.g. "Art. 17", "SC 1.4.3" --framework-version <ver> e.g. 2.2, 2018-05-25 --external-url <url> link to the source document --authority <text> e.g. EU, W3C, US Treasury --effective-date <date> YYYY-MM-DD --mandatory flag, default false --source <text> also accepted on REQ-* (free-text origin) Helpful errors instead of silent misroutes: --repo with STD-* → "drop --repo and pass --organization" --organization with REQ-* → "REQ-* are repo-scoped" framework/etc with REQ-* → "standards-only flags cannot be used" unknown prefix → "IDs must start with REQ- or STD-" anvil requirement list gains: --kind requirement|standard|all default: requirement (no behavior change) --organization <slug> required when kind=standard --framework <label> standards filter --mandatory standards filter anvil requirement view <id> infers kind from prefix — STD-* needs --organization, REQ-* needs --repo (or a default). Create variant moved into Box<CreateArgs> to keep clippy large_enum_variant happy with the expanded option set; same pattern the existing Status variant uses. 7 unit tests cover prefix inference, standards-flag detection, and each helpful-error path. Full Rust suite: 50/50. Clippy clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SHA: a29de45b3cf92f70e1837fff913337e334c4fc54
Author: CI <ci@anvil.test>
Date: 2026-05-23 22:37
Parents: 0430ea2
2 files changed +529 -78
Type
src/commands/mod.rs +2 −2
@@ -55,7 +55,7 @@
/// Release operations
Release(release::ReleaseArgs),
/// Manage requirements and traceability
Requirement(requirement::RequirementArgs),
Requirement(Box<requirement::RequirementArgs>),
/// Deployment operations
Deploy(deploy::DeployArgs),
/// AI agent operations
@@ -86,7 +86,7 @@
Command::Commit(args) => commit::run(args).await,
Command::Branch(args) => branch::run(args).await,
Command::Release(args) => release::run(args).await,
Command::Requirement(args) => requirement::run(args).await,
Command::Requirement(args) => requirement::run(*args).await,
Command::Deploy(args) => deploy::run(args).await,
Command::Agent(args) => agent::run(args).await,
Command::Runner(args) => runner::run(args)
src/commands/requirement.rs +527 −76
@@ -12,53 +12,43 @@
#[derive(Subcommand)]
pub enum RequirementCommand {
/// List requirements for a repository
/// List requirements (repo-scoped) or standards (org-scoped) — pass --kind to switch
List {
/// Filter by status (draft, active, deprecated)
#[arg(long)]
status: Option<String>,
/// Filter by category (functional, security, performance, reliability, usability, integration, compliance)
/// Filter by category (functional, security, performance, reliability, usability, integration)
#[arg(long)]
category: Option<String>,
/// Repository (org/repo)
/// Repository (org/repo) — required when kind=requirement (the default)
#[arg(long)]
repo: Option<String>,
/// Organization slug — required when kind=standard
#[arg(long)]
organization: Option<String>,
/// Listing kind: requirement (default, repo-scoped) | standard (org-scoped) | all (both)
#[arg(long, default_value = "requirement")]
kind: String,
/// Standards filter: framework label (e.g. "GDPR", "WCAG")
#[arg(long)]
framework: Option<String>,
/// Standards filter: only mandatory entries
#[arg(long)]
mandatory: bool,
},
/// View a specific requirement
/// View a specific requirement (REQ-*) or standard (STD-*) — kind inferred from ID prefix
View {
/// Requirement ID (REQ-…) or Standard ID (STD-…)
/// Requirement ID (e.g. REQ-ACCT-001)
id: String,
/// Repository (org/repo)
/// Repository (org/repo) — required for REQ-* IDs
#[arg(long)]
repo: Option<String>,
},
/// Create a new requirement
Create {
/// Requirement ID (e.g. REQ-ACCT-001)
#[arg(long)]
requirement_id: String,
/// Title
#[arg(long)]
title: String,
/// Description
#[arg(long)]
description: Option<String>,
/// Category
#[arg(long)]
category: Option<String>,
/// Priority (critical, high, medium, low)
#[arg(long)]
priority: Option<String>,
/// Status (draft, active, deprecated)
/// Organization slug — required for STD-* IDs
#[arg(long)]
organization: Option<String>,
status: Option<String>,
/// Parent requirement UUID (for hierarchy)
#[arg(long)]
parent_id: Option<String>,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Create a requirement (REQ-…) or standard (STD-…) — kind inferred from ID prefix
Create(Box<CreateArgs>),
/// Update an existing requirement
Update {
/// Requirement ID (e.g. REQ-ACCT-001)
@@ -112,6 +102,61 @@
strict: bool,
}
#[derive(Args)]
pub struct CreateArgs {
/// Requirement ID (REQ-…) or Standard ID (STD-…)
#[arg(long)]
pub requirement_id: String,
/// Title
#[arg(long)]
pub title: String,
/// Description
#[arg(long)]
pub description: Option<String>,
/// Category
#[arg(long)]
pub category: Option<String>,
/// Priority (critical, high, medium, low)
#[arg(long)]
pub priority: Option<String>,
/// Status (draft, active, deprecated)
#[arg(long)]
pub status: Option<String>,
/// Parent requirement UUID (for hierarchy; REQ-* only)
#[arg(long)]
pub parent_id: Option<String>,
/// Repository (org/repo) — for REQ-*
#[arg(long)]
pub repo: Option<String>,
/// 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")
#[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,
}
#[derive(Debug, Deserialize)]
struct Requirement {
#[allow(dead_code)]
@@ -145,30 +190,49 @@
status,
category,
repo,
organization,
} => list(repo.as_deref(), status.as_deref(), category.as_deref()).await,
kind,
framework,
mandatory,
RequirementCommand::View { id, repo } => view(repo.as_deref(), &id).await,
RequirementCommand::Create {
requirement_id,
title,
description,
category,
priority,
status,
parent_id,
repo,
} => {
create(
list(
repo.as_deref(),
status.as_deref(),
&requirement_id,
&title,
description.as_deref(),
category.as_deref(),
organization.as_deref(),
&kind,
framework.as_deref(),
priority.as_deref(),
status.as_deref(),
mandatory,
parent_id.as_deref(),
)
.await
}
RequirementCommand::View {
id,
repo,
organization,
} => view(&id, repo.as_deref(), organization.as_deref()).await,
RequirementCommand::Create(args) => {
let inputs = CreateInputs {
requirement_id: &args.requirement_id,
title: &args.title,
description: args.description.as_deref(),
category: args.category.as_deref(),
priority: args.priority.as_deref(),
status: args.status.as_deref(),
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(),
authority: args.authority.as_deref(),
effective_date: args.effective_date.as_deref(),
mandatory: args.mandatory,
};
create(inputs).await
}
RequirementCommand::Update {
id,
title,
@@ -199,7 +263,30 @@
repo: Option<&str>,
status: Option<&str>,
category: Option<&str>,
organization: Option<&str>,
kind: &str,
framework: Option<&str>,
mandatory: bool,
) -> Result<(), Box<dyn std::error::Error>> {
match kind {
"requirement" => list_requirements(repo, status, category).await,
"standard" => list_standards(organization, framework, mandatory).await,
"all" => {
list_requirements(repo, status, category).await?;
println!();
list_standards(organization, framework, mandatory).await
}
other => {
Err(format!("unknown --kind '{other}' (expected requirement | standard | all)").into())
}
}
}
async fn list_requirements(
repo: Option<&str>,
status: Option<&str>,
category: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let mut query = Vec::new();
@@ -238,7 +325,83 @@
Ok(())
}
async fn list_standards(
organization: Option<&str>,
framework: Option<&str>,
mandatory: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let org = organization.ok_or("standards listing requires --organization <slug>")?;
let client = Client::from_config()?;
let mut query: Vec<(&str, &str)> = Vec::new();
if let Some(fw) = framework {
query.push(("framework", fw));
}
if mandatory {
query.push(("mandatory", "true"));
}
let resp: serde_json::Value = client
.get_with_query(&format!("/{org}/standards"), &query)
.await?;
let stds = resp
.get("standards")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
output::header(&format!("Standards ({org})"));
let rows: Vec<Vec<String>> = stds
.iter()
.map(|s| {
vec![
s["requirement_id"].as_str().unwrap_or("").to_string(),
s["title"].as_str().unwrap_or("").to_string(),
s["framework"].as_str().unwrap_or("").to_string(),
s["citation"].as_str().unwrap_or("").to_string(),
if s["mandatory"].as_bool().unwrap_or(false) {
"yes".into()
} else {
"".into()
},
s["status"].as_str().unwrap_or("").to_string(),
]
})
.collect();
async fn view(repo: Option<&str>, req_id: &str) -> Result<(), Box<dyn std::error::Error>> {
output::print_table(
&[
"ID",
"TITLE",
"FRAMEWORK",
"CITATION",
"MANDATORY",
"STATUS",
],
&rows,
);
println!("\n{} standards", rows.len());
Ok(())
}
async fn view(
id: &str,
repo: Option<&str>,
organization: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
match infer_kind(id) {
Kind::Standard => view_standard(id, organization).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()),
}
}
async fn view_requirement(
req_id: &str,
repo: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
if repo.is_none() && config::resolve_repo(None).is_err() {
// Surface the actionable hint before the bare resolve_repo error.
}
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let req: RequirementDetail = client
@@ -271,45 +434,154 @@
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn create(
repo: Option<&str>,
requirement_id: &str,
title: &str,
description: Option<&str>,
category: Option<&str>,
async fn view_standard(
std_id: &str,
organization: Option<&str>,
priority: Option<&str>,
status: Option<&str>,
parent_id: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let org = organization.ok_or("viewing a standard requires --organization <slug>")?;
let client = Client::from_config()?;
let std: serde_json::Value = client.get(&format!("/{org}/standards/{std_id}")).await?;
output::header(&format!(
"Standard {} — {}",
std["requirement_id"].as_str().unwrap_or("?"),
std["title"].as_str().unwrap_or("?")
));
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)"),
);
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("-"),
);
output::detail(
"Mandatory",
if std["mandatory"].as_bool().unwrap_or(false) {
"yes"
} else {
"no"
},
);
output::detail("Source", std["source"].as_str().unwrap_or("-"));
output::detail("External URL", std["external_url"].as_str().unwrap_or("-"));
if let Some(id) = std["id"].as_str() {
output::detail("UUID", id);
}
Ok(())
}
struct CreateInputs<'a> {
requirement_id: &'a str,
title: &'a str,
description: Option<&'a str>,
category: Option<&'a str>,
priority: Option<&'a str>,
status: Option<&'a str>,
parent_id: Option<&'a str>,
repo: Option<&'a str>,
organization: Option<&'a str>,
source: Option<&'a str>,
external_url: Option<&'a str>,
framework: Option<&'a str>,
citation: Option<&'a str>,
framework_version: Option<&'a str>,
authority: Option<&'a str>,
effective_date: Option<&'a str>,
mandatory: bool,
}
enum Kind {
Requirement,
Standard,
Unknown,
}
fn infer_kind(id: &str) -> Kind {
if id.starts_with("STD-") {
Kind::Standard
} else if id.starts_with("REQ-") {
Kind::Requirement
} else {
Kind::Unknown
}
}
async fn create(inputs: CreateInputs<'_>) -> Result<(), Box<dyn std::error::Error>> {
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()),
}
}
async fn create_requirement(inputs: CreateInputs<'_>) -> Result<(), Box<dyn std::error::Error>> {
if inputs.organization.is_some() {
return Err(
"REQ-* requirements are repo-scoped — pass --repo (or use a default), not --organization"
.into(),
);
}
if any_standards_flags(&inputs) {
return Err(format!(
let (org, name) = config::resolve_repo(repo)?;
"standards-only flags (framework/citation/authority/mandatory/effective-date/framework-version) cannot be used with REQ-* IDs (got '{}')",
inputs.requirement_id
)
.into());
}
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(inputs.repo)?;
let mut payload = serde_json::json!({
"requirement_id": inputs.requirement_id,
"requirement_id": requirement_id,
"title": title,
"title": inputs.title,
});
if let Some(v) = inputs.description {
payload["description"] = serde_json::json!(v);
if let Some(d) = description {
payload["description"] = serde_json::json!(d);
}
if let Some(c) = category {
payload["category"] = serde_json::json!(c);
if let Some(v) = inputs.category {
payload["category"] = serde_json::json!(v);
}
if let Some(v) = inputs.priority {
payload["priority"] = serde_json::json!(v);
}
if let Some(v) = inputs.status {
payload["status"] = serde_json::json!(v);
}
if let Some(v) = inputs.parent_id {
payload["parent_id"] = serde_json::json!(v);
if let Some(p) = priority {
payload["priority"] = serde_json::json!(p);
}
if let Some(v) = inputs.source {
if let Some(s) = status {
payload["source"] = serde_json::json!(v);
payload["status"] = serde_json::json!(s);
}
if let Some(pid) = parent_id {
if let Some(v) = inputs.external_url {
payload["external_url"] = serde_json::json!(v);
payload["parent_id"] = serde_json::json!(pid);
}
let resp: RequirementDetail = client
.post(&format!("/{org}/{name}/requirements"), &payload)
.await?;
output::success(&format!(
"Created requirement '{}' — {}",
resp.requirement_id
resp.requirement_id.as_deref().unwrap_or(requirement_id),
resp.title.as_deref().unwrap_or(title)
.as_deref()
.unwrap_or(inputs.requirement_id),
resp.title.as_deref().unwrap_or(inputs.title)
));
if let Some(id) = &resp.id {
output::detail("UUID", id);
@@ -317,6 +589,86 @@
Ok(())
}
async fn create_standard(inputs: CreateInputs<'_>) -> Result<(), Box<dyn std::error::Error>> {
if inputs.repo.is_some() {
return Err(format!(
"standards are org-scoped — drop --repo and pass --organization <slug> instead (got --repo with '{}')",
inputs.requirement_id
)
.into());
}
if inputs.parent_id.is_some() {
return Err("--parent-id is not supported for standards in Phase 2".into());
}
let org = inputs
.organization
.ok_or("creating a standard requires --organization <slug>")?;
let client = Client::from_config()?;
let mut payload = serde_json::json!({
"requirement_id": inputs.requirement_id,
"title": inputs.title,
});
if let Some(v) = inputs.description {
payload["description"] = serde_json::json!(v);
}
if let Some(v) = inputs.category {
payload["category"] = serde_json::json!(v);
}
if let Some(v) = inputs.priority {
payload["priority"] = serde_json::json!(v);
}
if let Some(v) = inputs.status {
payload["status"] = serde_json::json!(v);
}
if let Some(v) = inputs.source {
payload["source"] = serde_json::json!(v);
}
if let Some(v) = inputs.external_url {
payload["external_url"] = serde_json::json!(v);
}
if let Some(v) = inputs.framework {
payload["framework"] = serde_json::json!(v);
}
if let Some(v) = inputs.citation {
payload["citation"] = serde_json::json!(v);
}
if let Some(v) = inputs.framework_version {
payload["framework_version"] = serde_json::json!(v);
}
if let Some(v) = inputs.authority {
payload["authority"] = serde_json::json!(v);
}
if let Some(v) = inputs.effective_date {
payload["effective_date"] = serde_json::json!(v);
}
if inputs.mandatory {
payload["mandatory"] = serde_json::json!(true);
}
let resp: serde_json::Value = client.post(&format!("/{org}/standards"), &payload).await?;
output::success(&format!(
"Created standard '{}' — {}",
resp["requirement_id"]
.as_str()
.unwrap_or(inputs.requirement_id),
resp["title"].as_str().unwrap_or(inputs.title)
));
if let Some(id) = resp["id"].as_str() {
output::detail("UUID", id);
}
Ok(())
}
fn any_standards_flags(inputs: &CreateInputs<'_>) -> bool {
inputs.framework.is_some()
|| inputs.citation.is_some()
|| inputs.framework_version.is_some()
|| inputs.authority.is_some()
|| inputs.effective_date.is_some()
|| inputs.mandatory
}
async fn update(
repo: Option<&str>,
req_id: &str,
@@ -908,4 +1260,103 @@
],
},
]
}
#[cfg(test)]
mod tests {
use super::*;
fn base_inputs<'a>(req_id: &'a str, title: &'a str) -> CreateInputs<'a> {
CreateInputs {
requirement_id: req_id,
title,
description: None,
category: None,
priority: None,
status: None,
parent_id: None,
repo: None,
organization: None,
source: None,
external_url: None,
framework: None,
citation: None,
framework_version: None,
authority: None,
effective_date: None,
mandatory: false,
}
}
#[test]
fn infer_kind_from_prefix() {
assert!(matches!(infer_kind("REQ-ACCT-001"), Kind::Requirement));
assert!(matches!(infer_kind("STD-GDPR-017"), Kind::Standard));
assert!(matches!(infer_kind("XYZ-123"), Kind::Unknown));
assert!(matches!(infer_kind(""), Kind::Unknown));
}
#[test]
fn any_standards_flags_detects_each_flag() {
let mut inputs = base_inputs("REQ-X", "t");
assert!(!any_standards_flags(&inputs));
inputs.framework = Some("GDPR");
assert!(any_standards_flags(&inputs));
let mut inputs = base_inputs("REQ-X", "t");
inputs.mandatory = true;
assert!(any_standards_flags(&inputs));
let mut inputs = base_inputs("REQ-X", "t");
inputs.effective_date = Some("2026-01-01");
assert!(any_standards_flags(&inputs));
}
// 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.
#[tokio::test]
async fn create_rejects_std_with_repo_flag() {
let mut inputs = base_inputs("STD-GDPR-001", "X");
inputs.repo = Some("fangorn/anvil");
let err = create(inputs).await.unwrap_err().to_string();
assert!(err.contains("--organization"), "got: {err}");
assert!(err.contains("--repo"), "got: {err}");
}
#[tokio::test]
async fn create_rejects_std_without_organization() {
// No --repo, no --organization, no Anvil config → must surface
// the "--organization is required" message before any network call.
let inputs = base_inputs("STD-GDPR-001", "X");
let err = create(inputs).await.unwrap_err().to_string();
assert!(err.contains("--organization"), "got: {err}");
}
#[tokio::test]
async fn create_rejects_req_with_standards_flags() {
let mut inputs = base_inputs("REQ-A-001", "X");
inputs.framework = Some("GDPR");
let err = create(inputs).await.unwrap_err().to_string();
assert!(err.contains("standards-only"), "got: {err}");
}
#[tokio::test]
async fn create_rejects_req_with_organization_flag() {
let mut inputs = base_inputs("REQ-A-001", "X");
inputs.organization = Some("fangorn");
let err = create(inputs).await.unwrap_err().to_string();
assert!(err.contains("--organization"), "got: {err}");
assert!(err.contains("--repo"), "got: {err}");
}
#[tokio::test]
async fn create_rejects_unknown_prefix() {
let inputs = base_inputs("XYZ-1", "X");
let err = create(inputs).await.unwrap_err().to_string();
assert!(err.contains("REQ-"), "got: {err}");
assert!(err.contains("STD-"), "got: {err}");
}
}