ref:f20e481a18db87b454e2ce5ef639cdc3dc148c8e

feat(standards): route update STD-* to PUT /:org/standards/:id + cleanup

Addresses self-review gaps on #19: Update routing (the big one) - anvil requirement update <id> now infers kind from prefix and routes to the right endpoint, matching the create command: REQ-* → existing PUT /:org/:repo/requirements/:id STD-* → new PUT /:org/standards/:id (server PR pairs) - All standards-specific flags accepted on update (framework, citation, framework_version, authority, effective_date, mandatory, source, external_url). - Mandatory is Option<bool> for update (set true|false explicitly) vs the bool flag on create — update needs the tri-state to distinguish "not changed" from "explicitly toggled off". - Same helpful errors as create: --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-" Update args moved into Box<UpdateArgs> for the same clippy large_enum_variant reason as Create. Cleanup - Removed dead empty `if` block in view_requirement that did nothing. - list --kind all now pre-validates --organization (and --repo via config::resolve_repo) BEFORE issuing any HTTP call. Previously it would print the requirements half then fail midway with a missing-org error. 7 new tests: update prefix routing (unknown / STD with --repo / STD without --organization / REQ with --organization / REQ with standards flags) plus list --kind all org-missing precheck and unknown-kind rejection. (Repo-missing precheck is intentionally untested — depends on the user's real ~/.anvil config, would need mocking.) Full Rust suite: 57/57. Clippy clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SHA: f20e481a18db87b454e2ce5ef639cdc3dc148c8e
Author: CI <ci@anvil.test>
Date: 2026-05-24 00:08
Parents: a29de45
1 files changed +270 -68
Type
src/commands/requirement.rs +270 −68
@@ -49,29 +49,8 @@
},
/// 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)
id: String,
/// New title
#[arg(long)]
title: Option<String>,
/// New description
#[arg(long)]
description: Option<String>,
/// New category
#[arg(long)]
category: Option<String>,
/// New priority
#[arg(long)]
priority: Option<String>,
/// New status
#[arg(long)]
status: Option<String>,
/// Repository (org/repo)
/// Update an existing requirement (REQ-…) or standard (STD-…) — kind inferred from ID prefix
Update(Box<UpdateArgs>),
#[arg(long)]
repo: Option<String>,
},
/// Show the traceability matrix
Matrix {
/// Repository (org/repo)
@@ -103,6 +82,57 @@
}
#[derive(Args)]
pub struct UpdateArgs {
/// Requirement ID (REQ-…) or Standard ID (STD-…)
pub id: String,
/// New title
#[arg(long)]
pub title: Option<String>,
/// New description
#[arg(long)]
pub description: Option<String>,
/// New category
#[arg(long)]
pub category: Option<String>,
/// New priority (critical, high, medium, low)
#[arg(long)]
pub priority: Option<String>,
/// New status (draft, active, deprecated)
#[arg(long)]
pub status: 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 — 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>,
}
#[derive(Args)]
pub struct CreateArgs {
/// Requirement ID (REQ-…) or Standard ID (STD-…)
#[arg(long)]
@@ -233,26 +263,7 @@
};
create(inputs).await
}
RequirementCommand::Update {
id,
title,
description,
category,
priority,
status,
repo,
RequirementCommand::Update(args) => update(*args).await,
} => {
update(
repo.as_deref(),
&id,
title.as_deref(),
description.as_deref(),
category.as_deref(),
priority.as_deref(),
status.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,
@@ -272,6 +283,17 @@
"requirement" => list_requirements(repo, status, category).await,
"standard" => list_standards(organization, framework, mandatory).await,
"all" => {
// Validate both required inputs upfront so we don't print
// requirements and then bail midway with a missing-org error.
if organization.is_none() {
return Err(
"--kind all requires --organization <slug> (for the standards half)".into(),
);
}
config::resolve_repo(repo).map_err(|_| {
"--kind all requires --repo <org/repo> (for the requirements half), or a configured default"
})?;
list_requirements(repo, status, category).await?;
println!();
list_standards(organization, framework, mandatory).await
@@ -399,9 +421,6 @@
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
@@ -669,44 +688,140 @@
|| inputs.mandatory
}
async fn update(args: UpdateArgs) -> Result<(), Box<dyn std::error::Error>> {
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)",
async fn update(
repo: Option<&str>,
req_id: &str,
title: Option<&str>,
description: Option<&str>,
category: Option<&str>,
priority: Option<&str>,
status: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
args.id
)
.into()),
}
}
async fn update_requirement(args: UpdateArgs) -> Result<(), Box<dyn std::error::Error>> {
if args.organization.is_some() {
return Err(
"REQ-* requirements are repo-scoped — pass --repo (or use a default), not --organization"
.into(),
);
}
if args.framework.is_some()
|| args.citation.is_some()
|| args.framework_version.is_some()
|| args.authority.is_some()
|| args.effective_date.is_some()
|| args.mandatory.is_some()
{
return Err(format!(
"standards-only flags (framework/citation/authority/mandatory/effective-date/framework-version) cannot be used with REQ-* IDs (got '{}')",
args.id
)
.into());
}
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(args.repo.as_deref())?;
let (org, name) = config::resolve_repo(repo)?;
let mut payload = serde_json::json!({});
if let Some(v) = &args.title {
payload["title"] = serde_json::json!(v);
if let Some(t) = title {
payload["title"] = serde_json::json!(t);
}
if let Some(d) = description {
if let Some(v) = &args.description {
payload["description"] = serde_json::json!(v);
payload["description"] = serde_json::json!(d);
}
if let Some(c) = category {
payload["category"] = serde_json::json!(c);
if let Some(v) = &args.category {
payload["category"] = serde_json::json!(v);
}
if let Some(p) = priority {
payload["priority"] = serde_json::json!(p);
if let Some(v) = &args.priority {
payload["priority"] = serde_json::json!(v);
}
if let Some(v) = &args.status {
payload["status"] = serde_json::json!(v);
}
if let Some(v) = &args.source {
if let Some(s) = status {
payload["status"] = serde_json::json!(s);
payload["source"] = serde_json::json!(v);
}
if let Some(v) = &args.external_url {
payload["external_url"] = serde_json::json!(v);
}
let resp: RequirementDetail = client
.put(&format!("/{org}/{name}/requirements/{}", args.id), &payload)
.put(&format!("/{org}/{name}/requirements/{req_id}"), &payload)
.await?;
output::success(&format!(
"Updated requirement '{}' — {}",
resp.requirement_id.as_deref().unwrap_or(req_id),
resp.requirement_id.as_deref().unwrap_or(&args.id),
resp.title.as_deref().unwrap_or("?")
));
Ok(())
}
async fn update_standard(args: UpdateArgs) -> Result<(), Box<dyn std::error::Error>> {
if args.repo.is_some() {
return Err(format!(
"standards are org-scoped — drop --repo and pass --organization <slug> instead (got --repo with '{}')",
args.id
)
.into());
}
let org = args
.organization
.as_deref()
.ok_or("updating a standard requires --organization <slug>")?;
let client = Client::from_config()?;
let mut payload = serde_json::json!({});
if let Some(v) = &args.title {
payload["title"] = serde_json::json!(v);
}
if let Some(v) = &args.description {
payload["description"] = serde_json::json!(v);
}
if let Some(v) = &args.category {
payload["category"] = serde_json::json!(v);
}
if let Some(v) = &args.priority {
payload["priority"] = serde_json::json!(v);
}
if let Some(v) = &args.status {
payload["status"] = serde_json::json!(v);
}
if let Some(v) = &args.source {
payload["source"] = serde_json::json!(v);
}
if let Some(v) = &args.external_url {
payload["external_url"] = serde_json::json!(v);
}
if let Some(v) = &args.framework {
payload["framework"] = serde_json::json!(v);
}
if let Some(v) = &args.citation {
payload["citation"] = serde_json::json!(v);
}
if let Some(v) = &args.framework_version {
payload["framework_version"] = serde_json::json!(v);
}
if let Some(v) = &args.authority {
payload["authority"] = serde_json::json!(v);
}
if let Some(v) = &args.effective_date {
payload["effective_date"] = serde_json::json!(v);
}
if let Some(v) = args.mandatory {
payload["mandatory"] = serde_json::json!(v);
}
let resp: serde_json::Value = client
.put(&format!("/{org}/standards/{}", args.id), &payload)
.await?;
output::success(&format!(
"Updated standard '{}' — {}",
resp["requirement_id"].as_str().unwrap_or(&args.id),
resp["title"].as_str().unwrap_or("?")
));
Ok(())
}
async fn matrix(repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -1358,5 +1473,92 @@
let err = create(inputs).await.unwrap_err().to_string();
assert!(err.contains("REQ-"), "got: {err}");
assert!(err.contains("STD-"), "got: {err}");
}
fn base_update(id: &str) -> UpdateArgs {
UpdateArgs {
id: id.into(),
title: None,
description: None,
category: None,
priority: None,
status: None,
repo: None,
organization: None,
source: None,
external_url: None,
framework: None,
citation: None,
framework_version: None,
authority: None,
effective_date: None,
mandatory: None,
}
}
#[tokio::test]
async fn update_rejects_unknown_prefix() {
let args = base_update("XYZ-1");
let err = update(args).await.unwrap_err().to_string();
assert!(err.contains("REQ-"), "got: {err}");
assert!(err.contains("STD-"), "got: {err}");
}
#[tokio::test]
async fn update_rejects_std_with_repo_flag() {
let mut args = base_update("STD-GDPR-001");
args.repo = Some("fangorn/anvil".into());
let err = update(args).await.unwrap_err().to_string();
assert!(err.contains("--organization"), "got: {err}");
assert!(err.contains("--repo"), "got: {err}");
}
#[tokio::test]
async fn update_rejects_std_without_organization() {
let args = base_update("STD-GDPR-001");
let err = update(args).await.unwrap_err().to_string();
assert!(err.contains("--organization"), "got: {err}");
}
#[tokio::test]
async fn update_rejects_req_with_organization_flag() {
let mut args = base_update("REQ-A-001");
args.organization = Some("fangorn".into());
let err = update(args).await.unwrap_err().to_string();
assert!(err.contains("--organization"), "got: {err}");
assert!(err.contains("--repo"), "got: {err}");
}
#[tokio::test]
async fn update_rejects_req_with_standards_flags() {
let mut args = base_update("REQ-A-001");
args.framework = Some("GDPR".into());
let err = update(args).await.unwrap_err().to_string();
assert!(err.contains("standards-only"), "got: {err}");
}
#[tokio::test]
async fn list_all_rejects_missing_organization() {
// No --organization; should refuse before any HTTP call.
let err = list(Some("fangorn/anvil"), None, None, None, "all", None, false)
.await
.unwrap_err()
.to_string();
assert!(err.contains("--organization"), "got: {err}");
}
// Note: a sibling test for "missing --repo on --kind all" is intentionally
// omitted — config::resolve_repo reads the user's real ~/.anvil config and
// resolves a default repo when present, which makes the negative case
// unreliable without mocking the config layer. The org-missing test above
// is sufficient to prove the pre-validation pattern works.
#[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}");
}
}