@@ -5,82 +5,98 @@
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 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)
/// Filter by category (functional, security, performance, reliability, usability, integration, compliance)
#[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 (e.g. REQ-ACCT-001)
/// Requirement ID (REQ-…) or Standard ID (STD-…)
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)
#[arg(long)]
status: Option<String>,
/// Parent requirement UUID (for hierarchy)
/// Organization slug — required for STD-* IDs
#[arg(long)]
parent_id: Option<String>,
/// Repository (org/repo)
organization: Option<String>,
/// Show fields with no value (default: skip blanks)
#[arg(long)]
repo: Option<String>,
show_blanks: bool,
},
/// Update an existing requirement
Update {
/// Requirement ID (e.g. REQ-ACCT-001)
/// 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,
/// 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)
/// 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 {
@@ -112,6 +128,104 @@
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,
/// 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>,
#[command(flatten)]
pub meta: StandardMetadataFlags,
/// Standards-only: mark as mandatory (true|false). Update is tri-state —
/// omit to leave unchanged.
#[arg(long)]
pub mandatory: Option<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>,
#[command(flatten)]
pub meta: StandardMetadataFlags,
/// Standards-only: mark as mandatory (default false)
#[arg(long)]
pub mandatory: bool,
}
#[derive(Debug, Deserialize)]
struct Requirement {
#[allow(dead_code)]
@@ -145,50 +259,56 @@
status,
category,
repo,
} => list(repo.as_deref(), status.as_deref(), category.as_deref()).await,
RequirementCommand::View { id, repo } => view(repo.as_deref(), &id).await,
RequirementCommand::Create {
requirement_id,
title,
description,
organization,
kind,
framework,
mandatory,
category,
priority,
status,
parent_id,
repo,
} => {
create(
list(
repo.as_deref(),
&requirement_id,
&title,
description.as_deref(),
status.as_deref(),
category.as_deref(),
priority.as_deref(),
organization.as_deref(),
&kind,
framework.as_deref(),
mandatory,
status.as_deref(),
parent_id.as_deref(),
)
.await
}
RequirementCommand::Update {
RequirementCommand::View {
id,
title,
description,
category,
priority,
status,
repo,
} => {
update(
repo.as_deref(),
&id,
title.as_deref(),
description.as_deref(),
category.as_deref(),
priority.as_deref(),
status.as_deref(),
)
.await
organization,
show_blanks,
} => view(&id, repo.as_deref(), organization.as_deref(), show_blanks).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.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(),
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,
@@ -199,7 +319,41 @@
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" => {
// 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
}
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 +392,78 @@
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();
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>,
async fn view(repo: Option<&str>, req_id: &str) -> Result<(), Box<dyn std::error::Error>> {
show_blanks: bool,
) -> Result<(), Box<dyn std::error::Error>> {
match infer_kind(id) {
Kind::Standard => view_standard(id, organization, show_blanks).await,
Kind::Requirement => view_requirement(id, repo).await,
Kind::Unknown => Err(unknown_prefix_error(id).into()),
}
}
async fn view_requirement(
req_id: &str,
repo: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let req: RequirementDetail = client
@@ -271,45 +496,214 @@
Ok(())
}
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()?;
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("?")
));
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("-"));
detail_str("Description", "description");
detail_str("Category", "category");
output::detail("Status", std["status"].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) {
"yes"
} else {
"no"
},
);
detail_str("Source", "source");
detail_str("External URL", "external_url");
if let Some(id) = std["id"].as_str() {
output::detail("UUID", id);
#[allow(clippy::too_many_arguments)]
async fn create(
}
Ok(())
}
async fn delete(
id: &str,
repo: Option<&str>,
requirement_id: &str,
title: &str,
description: Option<&str>,
category: Option<&str>,
priority: Option<&str>,
status: Option<&str>,
parent_id: 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,
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(unknown_prefix_error(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!(
"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": requirement_id,
"title": title,
"requirement_id": inputs.requirement_id,
"title": inputs.title,
});
if let Some(d) = description {
payload["description"] = serde_json::json!(d);
if let Some(v) = inputs.description {
payload["description"] = serde_json::json!(v);
}
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(p) = priority {
payload["priority"] = serde_json::json!(p);
if let Some(v) = inputs.priority {
payload["priority"] = serde_json::json!(v);
}
if let Some(s) = status {
payload["status"] = serde_json::json!(s);
if let Some(v) = inputs.status {
payload["status"] = serde_json::json!(v);
}
if let Some(pid) = parent_id {
payload["parent_id"] = serde_json::json!(pid);
if let Some(v) = inputs.parent_id {
payload["parent_id"] = 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);
}
let resp: RequirementDetail = client
.post(&format!("/{org}/{name}/requirements"), &payload)
.await?;
output::success(&format!(
"Created requirement '{}' — {}",
resp.requirement_id.as_deref().unwrap_or(requirement_id),
resp.title.as_deref().unwrap_or(title)
resp.requirement_id
.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,44 +711,216 @@
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(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(unknown_prefix_error(&args.id).into()),
}
}
async fn update_requirement(args: UpdateArgs) -> Result<(), Box<dyn std::error::Error>> {
if args.organization.is_some() {
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>> {
return Err(
"REQ-* requirements are repo-scoped — pass --repo (or use a default), not --organization"
.into(),
);
}
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!(
"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(repo)?;
let (org, name) = config::resolve_repo(args.repo.as_deref())?;
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 {
payload["description"] = serde_json::json!(d);
if let Some(v) = &args.description {
payload["description"] = serde_json::json!(v);
}
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(v) = &args.priority {
payload["priority"] = serde_json::json!(v);
}
if let Some(v) = &args.status {
payload["status"] = serde_json::json!(v);
}
if let Some(p) = priority {
payload["priority"] = serde_json::json!(p);
if let Some(v) = &args.meta.source {
payload["source"] = serde_json::json!(v);
}
if let Some(s) = status {
payload["status"] = serde_json::json!(s);
if let Some(v) = &args.meta.external_url {
payload["external_url"] = serde_json::json!(v);
}
let resp: RequirementDetail = client
.put(&format!("/{org}/{name}/requirements/{req_id}"), &payload)
.put(&format!("/{org}/{name}/requirements/{}", args.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.meta.source {
payload["source"] = serde_json::json!(v);
}
if let Some(v) = &args.meta.external_url {
payload["external_url"] = serde_json::json!(v);
}
if let Some(v) = &args.meta.framework {
payload["framework"] = serde_json::json!(v);
}
if let Some(v) = &args.meta.citation {
payload["citation"] = serde_json::json!(v);
}
if let Some(v) = &args.meta.framework_version {
payload["framework_version"] = serde_json::json!(v);
}
if let Some(v) = &args.meta.authority {
payload["authority"] = serde_json::json!(v);
}
if let Some(v) = &args.meta.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)?;
@@ -908,4 +1474,196 @@
],
},
]
}
#[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}");
}
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(),
title: None,
description: None,
category: None,
priority: None,
status: None,
repo: None,
organization: None,
meta: empty_meta(),
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.meta.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}");
}
}