ref:914e7cf2f327df194734c21e3e41151d3045af6b

Merge pull request #33 from feat/cli-json-requirement-read-status into main

SHA: 914e7cf2f327df194734c21e3e41151d3045af6b
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-07-07 13:04
12 files changed +2449 -102
Type
Cargo.lock +1 −0
@@ -83,6 +83,7 @@
"glob",
"hostname",
"libc",
"percent-encoding",
"reqwest",
"serde",
"serde_json",
Cargo.toml +1 −0
@@ -22,6 +22,7 @@
chrono = { version = "0.4", features = ["serde"] }
tabled = "0.17"
url = "2"
percent-encoding = "2"
thiserror = "2"
hostname = "0.4"
libc = "0.2"
src/commands/agent.rs +20 −0
@@ -89,6 +89,11 @@
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/agents")).await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let agents: Vec<serde_json::Value> = if let Some(arr) = resp.get("agents") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else if let Some(arr) = resp.get("data") {
@@ -131,6 +136,11 @@
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/agents/{agent_name}"))
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let agent = resp
.get("agent")
@@ -196,6 +206,11 @@
.get(&format!("/{org}/{name}/agents/{agent_name}/sessions"))
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let sessions: Vec<serde_json::Value> = if let Some(arr) = resp.get("sessions") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else if let Some(arr) = resp.get("data") {
@@ -237,6 +252,11 @@
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/agents/sessions/{id}"))
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let session = resp
.get("session")
src/commands/board.rs +200 −0
@@ -16,14 +16,209 @@
/// Repository (org/repo)
repo: Option<String>,
},
/// Initialize the default board columns (Backlog/In Progress/Done)
Init {
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Create a board column
#[command(name = "create-column")]
CreateColumn {
/// Column name
name: String,
/// Position (0-based)
#[arg(long)]
position: Option<u32>,
/// WIP limit
#[arg(long)]
wip_limit: Option<u32>,
/// Closing issues moves them here
#[arg(long)]
closed_column: bool,
/// Column color (hex)
#[arg(long)]
color: Option<String>,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Edit a board column
#[command(name = "edit-column")]
EditColumn {
/// Column short ID or UUID
id: String,
/// New name
#[arg(long)]
name: Option<String>,
/// New position
#[arg(long)]
position: Option<u32>,
/// New WIP limit
#[arg(long)]
wip_limit: Option<u32>,
/// New color (hex)
#[arg(long)]
color: Option<String>,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Delete a board column (must be empty)
#[command(name = "delete-column")]
DeleteColumn {
/// Column short ID or UUID
id: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
}
pub async fn run(args: BoardArgs) -> Result<(), Box<dyn std::error::Error>> {
match args.command {
BoardCommand::List { repo } => list(repo.as_deref()).await,
BoardCommand::Init { repo } => init(repo.as_deref()).await,
BoardCommand::CreateColumn {
name,
position,
wip_limit,
closed_column,
color,
repo,
} => {
create_column(
repo.as_deref(),
&name,
position,
wip_limit,
closed_column,
color.as_deref(),
)
.await
}
BoardCommand::EditColumn {
id,
name,
position,
wip_limit,
color,
repo,
} => {
edit_column(
repo.as_deref(),
&id,
name.as_deref(),
position,
wip_limit,
color.as_deref(),
)
.await
}
BoardCommand::DeleteColumn { id, repo } => delete_column(repo.as_deref(), &id).await,
}
}
async fn init(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
.post(
&format!("/{org}/{name}/board/initialize"),
&serde_json::json!({}),
)
.await?;
if output::is_json() {
output::json_ok("columns", resp["columns"].clone());
return Ok(());
}
let count = resp["columns"].as_array().map(|a| a.len()).unwrap_or(0);
output::success(&format!("Initialized board with {count} columns"));
Ok(())
}
async fn create_column(
repo: Option<&str>,
col_name: &str,
position: Option<u32>,
wip_limit: Option<u32>,
closed_column: bool,
color: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let mut payload = serde_json::json!({"name": col_name});
if let Some(v) = position {
payload["position"] = serde_json::json!(v);
}
if let Some(v) = wip_limit {
payload["wip_limit"] = serde_json::json!(v);
}
if closed_column {
payload["is_closed_column"] = serde_json::json!(true);
}
if let Some(v) = color {
payload["color"] = serde_json::json!(v);
}
let resp: serde_json::Value = client
.post(&format!("/{org}/{name}/board/columns"), &payload)
.await?;
if output::is_json() {
output::json_ok("column", resp["column"].clone());
return Ok(());
}
output::success(&format!("Created column '{col_name}'"));
Ok(())
}
async fn edit_column(
repo: Option<&str>,
id: &str,
new_name: Option<&str>,
position: Option<u32>,
wip_limit: Option<u32>,
color: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let mut payload = serde_json::json!({});
if let Some(v) = new_name {
payload["name"] = serde_json::json!(v);
}
if let Some(v) = position {
payload["position"] = serde_json::json!(v);
}
if let Some(v) = wip_limit {
payload["wip_limit"] = serde_json::json!(v);
}
if let Some(v) = color {
payload["color"] = serde_json::json!(v);
}
let resp: serde_json::Value = client
.patch(&format!("/{org}/{name}/board/columns/{id}"), &payload)
.await?;
if output::is_json() {
output::json_ok("column", resp["column"].clone());
return Ok(());
}
output::success(&format!("Updated column '{id}'"));
Ok(())
}
async fn delete_column(repo: Option<&str>, id: &str) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
client
.delete_empty(&format!("/{org}/{name}/board/columns/{id}"))
.await?;
if output::is_json() {
output::json_ok("deleted", serde_json::json!(id));
return Ok(());
}
output::success(&format!("Deleted column '{id}'"));
Ok(())
}
async fn list(repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -39,6 +234,11 @@
return Err(e.into());
}
};
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
// Parse and display columns
let columns = resp
src/commands/epic.rs +63 −1
@@ -301,6 +301,31 @@
Ok(())
}
/// Build the issue-link payload for `add-child`. The child may be a bare
/// number ("5"), a same-repo ref ("#5"), or a cross-repo ref ("org/repo#5").
/// The links endpoint reads `target_number` (+ optional `target_org`/
/// `target_repo`), NOT a raw `target` string — sending `target` made the
/// server drop it and 404 the request.
fn add_child_payload(child: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
// parse_target_ref requires a '#'; a bare number is a same-repo ref.
let normalized = if child.contains('#') {
child.trim().to_string()
} else {
format!("#{}", child.trim())
};
let parsed = crate::commands::issue::parse_target_ref(&normalized)?;
let mut payload = serde_json::json!({
"kind": "parent_of",
"target_number": parsed.number,
});
if let (Some(o), Some(r)) = (parsed.org.as_ref(), parsed.repo.as_ref()) {
payload["target_org"] = serde_json::Value::String(o.clone());
payload["target_repo"] = serde_json::Value::String(r.clone());
}
Ok(payload)
}
async fn add_child(
repo: Option<&str>,
epic_number: u32,
@@ -313,6 +338,6 @@
let _resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/issues/{epic_number}/links"),
&serde_json::json!({"kind": "parent_of", "target": child}),
&add_child_payload(child)?,
)
.await?;
@@ -504,5 +529,42 @@
repository: None,
};
assert_eq!(short_ref(&t), "#7");
}
#[test]
fn add_child_payload_bare_number_is_same_repo() {
let p = add_child_payload("5").unwrap();
assert_eq!(p["kind"], "parent_of");
assert_eq!(p["target_number"], 5);
assert!(p.get("target_org").is_none());
assert!(p.get("target_repo").is_none());
}
#[test]
fn add_child_payload_hash_ref_is_same_repo() {
let p = add_child_payload("#12").unwrap();
assert_eq!(p["target_number"], 12);
assert!(p.get("target_org").is_none());
}
#[test]
fn add_child_payload_cross_repo_ref_carries_org_and_repo() {
let p = add_child_payload("fangorn/anvil#9").unwrap();
assert_eq!(p["target_number"], 9);
assert_eq!(p["target_org"], "fangorn");
assert_eq!(p["target_repo"], "anvil");
}
#[test]
fn add_child_payload_rejects_garbage() {
assert!(add_child_payload("not-a-ref").is_err());
}
#[test]
fn add_child_payload_never_sends_raw_target() {
// Regression: the old code sent {"target": child}, which the links
// controller ignores (it reads target_number) → 404.
let p = add_child_payload("5").unwrap();
assert!(p.get("target").is_none());
}
}
src/commands/issue.rs +131 −5
@@ -176,6 +176,39 @@
#[arg(long)]
repo: Option<String>,
},
/// Move an issue to a board column
Move {
/// Issue number
number: u32,
/// Target column UUID (the `id` field in `anvil board list --json`)
#[arg(long)]
column: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Manually link a requirement (REQ-*) or standard (STD-*) to an issue
#[command(name = "link-req")]
LinkReq {
/// Issue number
number: u32,
/// Requirement ID (REQ-…) or Standard ID (STD-…)
requirement_id: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Remove a requirement/standard link from an issue (any source)
#[command(name = "unlink-req")]
UnlinkReq {
/// Issue number
number: u32,
/// Requirement ID (REQ-…) or Standard ID (STD-…)
requirement_id: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
@@ -284,5 +317,20 @@
link_id,
repo,
} => remove_link(repo.as_deref(), number, &link_id).await,
IssueCommand::Move {
number,
column,
repo,
} => move_issue(repo.as_deref(), number, &column).await,
IssueCommand::LinkReq {
number,
requirement_id,
repo,
} => link_requirement(repo.as_deref(), number, &requirement_id).await,
IssueCommand::UnlinkReq {
number,
requirement_id,
repo,
} => unlink_requirement(repo.as_deref(), number, &requirement_id).await,
}
}
@@ -543,6 +591,11 @@
.get(&format!("/{org}/{name}/issues/{number}/comments"))
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let comments: Vec<serde_json::Value> = resp
.get("comments")
.and_then(|v| serde_json::from_value(v.clone()).ok())
@@ -664,13 +717,13 @@
links: LinkGroups,
}
pub(crate) struct TargetRef {
pub(crate) org: Option<String>,
pub(crate) repo: Option<String>,
struct TargetRef {
org: Option<String>,
repo: Option<String>,
pub(crate) number: u32,
number: u32,
}
fn parse_target_ref(input: &str) -> Result<TargetRef, Box<dyn std::error::Error>> {
pub(crate) fn parse_target_ref(input: &str) -> Result<TargetRef, Box<dyn std::error::Error>> {
let trimmed = input.trim();
let (prefix, num_str) = trimmed
.split_once('#')
@@ -709,6 +762,14 @@
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
if output::is_json() {
let raw: serde_json::Value = client
.get(&format!("/{org}/{name}/issues/{number}/links"))
.await?;
output::print_json(&raw);
return Ok(());
}
let resp: LinksResponse = client
.get(&format!("/{org}/{name}/issues/{number}/links"))
.await?;
@@ -835,5 +896,70 @@
output::success(&format!(
"Removed link {link_id} from {org}/{name}#{number}"
));
Ok(())
}
async fn move_issue(
repo: Option<&str>,
number: u32,
column: &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
.patch(
&format!("/{org}/{name}/issues/{number}"),
&serde_json::json!({ "column_id": column }),
)
.await?;
if output::is_json() {
output::json_ok("issue", resp);
return Ok(());
}
output::success(&format!("Moved issue #{number} to column {column}"));
Ok(())
}
async fn link_requirement(
repo: Option<&str>,
number: u32,
requirement_id: &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
.post(
&format!("/{org}/{name}/issues/{number}/requirement-links"),
&serde_json::json!({ "requirement_id": requirement_id }),
)
.await?;
if output::is_json() {
output::json_ok("linked", resp["linked"].clone());
return Ok(());
}
output::success(&format!("Linked {requirement_id} to issue #{number}"));
Ok(())
}
async fn unlink_requirement(
repo: Option<&str>,
number: u32,
requirement_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
client
.delete_empty(&format!(
"/{org}/{name}/issues/{number}/requirement-links/{requirement_id}"
))
.await?;
if output::is_json() {
output::json_ok(
"unlinked",
serde_json::json!({ "issue": number, "requirement_id": requirement_id }),
);
return Ok(());
}
output::success(&format!("Unlinked {requirement_id} from issue #{number}"));
Ok(())
}
src/commands/label.rs +115 −2
@@ -2,6 +2,7 @@
use crate::config;
use crate::output;
use clap::{Args, Subcommand};
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use serde::Deserialize;
#[derive(Args)]
@@ -32,6 +33,31 @@
#[arg(long)]
repo: Option<String>,
},
/// Edit a label definition (rename / recolor / redescribe)
Edit {
/// Current label name
name: String,
/// New name
#[arg(long)]
new_name: Option<String>,
/// New color (hex, e.g. "#00ff00")
#[arg(long)]
color: Option<String>,
/// New description
#[arg(long)]
description: Option<String>,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Delete a label definition (removes it from all issues)
Delete {
/// Label name
name: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Add a label to an issue
Add {
/// Issue number
@@ -74,6 +100,23 @@
description,
repo,
} => create(repo.as_deref(), &name, &color, description.as_deref()).await,
LabelCommand::Edit {
name,
new_name,
color,
description,
repo,
} => {
edit(
repo.as_deref(),
&name,
new_name.as_deref(),
color.as_deref(),
description.as_deref(),
)
.await
}
LabelCommand::Delete { name, repo } => delete(repo.as_deref(), &name).await,
LabelCommand::Add { issue, name, repo } => add(repo.as_deref(), issue, &name).await,
LabelCommand::Remove { issue, name, repo } => remove(repo.as_deref(), issue, &name).await,
}
@@ -83,6 +126,10 @@
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/labels")).await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let labels: Vec<Label> = resp
.get("labels")
.and_then(|v| serde_json::from_value(v.clone()).ok())
@@ -114,13 +161,64 @@
if let Some(desc) = description {
payload["description"] = serde_json::json!(desc);
}
let _resp: serde_json::Value = client
let resp: serde_json::Value = client
.post(&format!("/{org}/{name}/labels"), &payload)
.await?;
if output::is_json() {
output::json_ok("label", resp);
return Ok(());
}
output::success(&format!("Created label '{label_name}'"));
Ok(())
}
async fn edit(
repo: Option<&str>,
label_name: &str,
new_name: Option<&str>,
color: Option<&str>,
description: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let mut payload = serde_json::json!({});
if let Some(v) = new_name {
payload["new_name"] = serde_json::json!(v);
}
if let Some(v) = color {
payload["color"] = serde_json::json!(v);
}
if let Some(v) = description {
payload["description"] = serde_json::json!(v);
}
// Label names commonly contain '/' (area/ci) — encode the path segment.
let encoded = utf8_percent_encode(label_name, NON_ALPHANUMERIC).to_string();
let resp: serde_json::Value = client
.patch(&format!("/{org}/{name}/labels/{encoded}"), &payload)
.await?;
if output::is_json() {
output::json_ok("label", resp);
return Ok(());
}
output::success(&format!("Updated label '{label_name}'"));
Ok(())
}
async fn delete(repo: Option<&str>, label_name: &str) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let encoded = utf8_percent_encode(label_name, NON_ALPHANUMERIC).to_string();
client
.delete_empty(&format!("/{org}/{name}/labels/{encoded}"))
.await?;
if output::is_json() {
output::json_ok("deleted", serde_json::json!(label_name));
return Ok(());
}
output::success(&format!("Deleted label '{label_name}'"));
Ok(())
}
async fn add(
repo: Option<&str>,
issue_number: u32,
@@ -134,6 +232,13 @@
&serde_json::json!({"name": label_name}),
)
.await?;
if output::is_json() {
output::json_ok(
"label_added",
serde_json::json!({"issue": issue_number, "name": label_name}),
);
return Ok(());
}
output::success(&format!(
"Added label '{label_name}' to issue #{issue_number}"
));
@@ -147,11 +252,19 @@
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let encoded = utf8_percent_encode(label_name, NON_ALPHANUMERIC).to_string();
client
.delete_empty(&format!(
"/{org}/{name}/issues/{issue_number}/labels/{encoded}"
"/{org}/{name}/issues/{issue_number}/labels/{label_name}"
))
.await?;
if output::is_json() {
output::json_ok(
"label_removed",
serde_json::json!({"issue": issue_number, "name": label_name}),
);
return Ok(());
}
output::success(&format!(
"Removed label '{label_name}' from issue #{issue_number}"
));
src/commands/milestone.rs +138 −0
@@ -40,6 +40,47 @@
#[arg(long)]
due_date: Option<String>,
},
/// Edit a milestone's title / description / due date
Edit {
/// Milestone short ID or UUID
id: String,
/// New title
#[arg(long)]
title: Option<String>,
/// New description
#[arg(long)]
description: Option<String>,
/// New due date (YYYY-MM-DD)
#[arg(long)]
due_date: Option<String>,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Close a milestone
Close {
/// Milestone short ID or UUID
id: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Reopen a closed milestone
Reopen {
/// Milestone short ID or UUID
id: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Delete a milestone
Delete {
/// Milestone short ID or UUID
id: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
}
#[derive(Debug, Deserialize)]
@@ -69,9 +110,94 @@
description,
due_date,
} => create(repo.as_deref(), &title, &description, due_date.as_deref()).await,
MilestoneCommand::Edit {
id,
title,
description,
due_date,
repo,
} => {
edit(
repo.as_deref(),
&id,
title.as_deref(),
description.as_deref(),
due_date.as_deref(),
)
.await
}
MilestoneCommand::Close { id, repo } => set_state(repo.as_deref(), &id, "close").await,
MilestoneCommand::Reopen { id, repo } => set_state(repo.as_deref(), &id, "reopen").await,
MilestoneCommand::Delete { id, repo } => delete(repo.as_deref(), &id).await,
}
}
async fn edit(
repo: Option<&str>,
id: &str,
title: Option<&str>,
description: Option<&str>,
due_date: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let mut payload = serde_json::json!({});
if let Some(v) = title {
payload["title"] = serde_json::json!(v);
}
if let Some(v) = description {
payload["description"] = serde_json::json!(v);
}
if let Some(v) = due_date {
payload["due_date"] = serde_json::json!(v);
}
let resp: serde_json::Value = client
.patch(&format!("/{org}/{name}/milestones/{id}"), &payload)
.await?;
if output::is_json() {
output::json_ok("milestone", resp["milestone"].clone());
return Ok(());
}
output::success(&format!("Updated milestone '{id}'"));
Ok(())
}
async fn set_state(
repo: Option<&str>,
id: &str,
verb: &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
.post(
&format!("/{org}/{name}/milestones/{id}/{verb}"),
&serde_json::json!({}),
)
.await?;
if output::is_json() {
output::json_ok("milestone", resp["milestone"].clone());
return Ok(());
}
let state = resp["milestone"]["state"].as_str().unwrap_or(verb);
output::success(&format!("Milestone '{id}' is now {state}"));
Ok(())
}
async fn delete(repo: Option<&str>, id: &str) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
client
.delete_empty(&format!("/{org}/{name}/milestones/{id}"))
.await?;
if output::is_json() {
output::json_ok("deleted", serde_json::json!(id));
return Ok(());
}
output::success(&format!("Deleted milestone '{id}'"));
Ok(())
}
pub async fn create(
repo: Option<&str>,
title: &str,
@@ -105,6 +231,10 @@
.and_then(|v| v.as_str())
.unwrap_or("?");
if output::is_json() {
output::json_ok("milestone", resp["milestone"].clone());
return Ok(());
}
output::success(&format!("Created milestone {short_id}: {title}"));
Ok(())
}
@@ -114,6 +244,10 @@
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/milestones")).await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
if output::is_json() {
output::print_json(&resp);
@@ -161,6 +295,10 @@
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/milestones/{id}"))
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
if output::is_json() {
output::print_json(&resp);
src/commands/mod.rs +6 −2
@@ -26,7 +26,11 @@
version = include_str!(concat!(env!("OUT_DIR"), "/version.txt"))
)]
pub struct Cli {
/// Output JSON instead of human-readable tables/details. Applies to
/// list and view subcommands. Useful for scripting.
/// Output JSON instead of human-readable tables/details. Honored across the
/// requirement/standards commands: reads (list, view, matrix, applicability
/// list) echo the server payload, `requirement status` emits a coverage
/// summary and keeps its non-zero exit, and mutations (create/update/delete,
/// link/unlink, applicability, seed) emit an {"ok":true, …} envelope. Useful
/// for scripting and agent tooling.
#[arg(long, global = true)]
pub json: bool,
src/commands/requirement.rs +911 −92
@@ -2,6 +2,7 @@
use crate::config;
use crate::output;
use clap::{Args, Subcommand};
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use serde::Deserialize;
#[derive(Args)]
@@ -98,6 +99,34 @@
#[arg(long)]
organization: Option<String>,
},
/// Link a test to a requirement (REQ-* only)
Link {
/// Requirement ID (REQ-…)
id: String,
/// Test name/identifier to link (e.g. an ExUnit test or classname.name)
#[arg(long)]
test: String,
/// Optional source file path for the test
#[arg(long)]
test_file: Option<String>,
/// Optional line number for the test within the file
#[arg(long)]
test_line: Option<i64>,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Unlink a test from a requirement (REQ-* only)
Unlink {
/// Requirement ID (REQ-…)
id: String,
/// Test name/identifier to unlink
#[arg(long)]
test: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Manage which repositories a standard applies to (STD-* only)
Applicability(ApplicabilityArgs),
/// Show the traceability matrix (requirements per repo, or standards per org)
@@ -111,6 +140,15 @@
/// Matrix kind: requirement (per-repo coverage, default) | standard (org-wide)
#[arg(long, default_value = "requirement")]
kind: String,
/// Anchor the matrix on a specific run (point-in-time coverage; forces strict_latest)
#[arg(long)]
run_id: Option<String>,
/// Coverage rollup policy: best | strict_latest | any_recent
#[arg(long)]
policy: Option<String>,
/// How many recent runs to consider (1..100)
#[arg(long)]
run_window: Option<u32>,
},
/// Check requirement coverage status (exit 1 if uncovered requirements exist)
Status(StatusArgs),
@@ -119,10 +157,35 @@
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
/// Clear existing requirements before seeding
/// HARD-DELETE every requirement in the repo (including soft-deleted
/// ones) before seeding. Destructive and irreversible — prompts for
/// confirmation on a terminal; requires --yes otherwise.
#[arg(long)]
clear: bool,
/// Skip the --clear confirmation prompt (for scripts/CI)
#[arg(long)]
yes: bool,
},
/// Bulk-import requirements from a YAML/JSON file (or stdin with "-")
Import {
/// Path to the file ("-" for stdin)
file: String,
/// Input format: yaml (default) | json
#[arg(long, default_value = "yaml")]
format: String,
/// Preview the diff without writing
#[arg(long)]
dry_run: bool,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Export the repo's requirements as import-shaped YAML (round-trips with `import`)
Export {
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
}
#[derive(Args)]
@@ -367,13 +430,43 @@
repo,
organization,
} => delete(&id, repo.as_deref(), organization.as_deref()).await,
RequirementCommand::Link {
id,
test,
test_file,
test_line,
repo,
} => link_test(&id, &test, test_file.as_deref(), test_line, repo.as_deref()).await,
RequirementCommand::Unlink { id, test, repo } => {
unlink_test(&id, &test, repo.as_deref()).await
}
RequirementCommand::Applicability(args) => applicability(args).await,
RequirementCommand::Matrix {
repo,
organization,
kind,
} => matrix(repo.as_deref(), organization.as_deref(), &kind).await,
run_id,
policy,
run_window,
} => {
matrix(
repo.as_deref(),
organization.as_deref(),
&kind,
run_id.as_deref(),
policy.as_deref(),
run_window,
)
.await
}
RequirementCommand::Status(args) => status(args).await,
RequirementCommand::Seed { repo, clear, yes } => seed(repo.as_deref(), clear, yes).await,
RequirementCommand::Import {
file,
format,
dry_run,
RequirementCommand::Seed { repo, clear } => seed(repo.as_deref(), clear).await,
repo,
} => import_requirements(repo.as_deref(), &file, &format, dry_run).await,
RequirementCommand::Export { repo } => export_requirements(repo.as_deref()).await,
}
}
@@ -429,6 +522,10 @@
let resp: serde_json::Value = client
.get_with_query(&format!("/{org}/{name}/requirements"), &query)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let reqs: Vec<Requirement> = resp
.get("requirements")
.and_then(|v| serde_json::from_value(v.clone()).ok())
@@ -472,6 +569,10 @@
let resp: serde_json::Value = client
.get_with_query(&format!("/{org}/standards"), &query)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let stds = resp
.get("standards")
.and_then(|v| v.as_array())
@@ -529,9 +630,15 @@
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let req: RequirementDetail = client
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/requirements/{req_id}"))
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let req: RequirementDetail = serde_json::from_value(resp)
.map_err(|e| format!("unexpected requirement response shape: {e}"))?;
output::header(&format!(
"Requirement {} — {}",
req.requirement_id.as_deref().unwrap_or("?"),
@@ -568,6 +675,11 @@
let client = Client::from_config()?;
let std: serde_json::Value = client.get(&format!("/{org}/standards/{std_id}")).await?;
if output::is_json() {
output::print_json(&std);
return Ok(());
}
output::header(&format!(
"Standard {} — {}",
std["requirement_id"].as_str().unwrap_or("?"),
@@ -641,6 +753,10 @@
client
.delete_empty(&format!("/{org}/{name}/requirements/{id}"))
.await?;
if output::is_json() {
output::json_ok("deleted", serde_json::json!(id));
return Ok(());
}
output::success(&format!("Deleted requirement '{id}'"));
Ok(())
}
@@ -661,10 +777,86 @@
client
.delete_empty(&format!("/{org}/standards/{id}"))
.await?;
if output::is_json() {
output::json_ok("deleted", serde_json::json!(id));
return Ok(());
}
output::success(&format!("Deleted standard '{id}'"));
Ok(())
}
/// Guard: test↔requirement links are requirements-only (REQ-*); the link
/// endpoints do not exist for standards.
fn require_requirement_id(id: &str) -> Result<(), Box<dyn std::error::Error>> {
match infer_kind(id) {
Kind::Requirement => Ok(()),
Kind::Standard => {
Err(format!("test links are requirements-only — '{id}' is a STD-* standard").into())
}
Kind::Unknown => Err(unknown_prefix_error(id).into()),
}
}
async fn link_test(
req_id: &str,
test: &str,
test_file: Option<&str>,
test_line: Option<i64>,
repo: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
require_requirement_id(req_id)?;
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let mut payload = serde_json::json!({ "test_name": test });
if let Some(f) = test_file {
payload["test_file"] = serde_json::json!(f);
}
if let Some(l) = test_line {
payload["test_line"] = serde_json::json!(l);
}
let resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/requirements/{req_id}/links"),
&payload,
)
.await?;
if output::is_json() {
output::json_ok("link", resp);
return Ok(());
}
output::success(&format!("Linked test '{test}' to requirement '{req_id}'"));
Ok(())
}
async fn unlink_test(
req_id: &str,
test: &str,
repo: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
require_requirement_id(req_id)?;
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
// The test name is a path segment and may contain spaces, dots, or slashes;
// percent-encode it so the server decodes back to the stored name.
let encoded = utf8_percent_encode(test, NON_ALPHANUMERIC).to_string();
client
.delete_empty(&format!(
"/{org}/{name}/requirements/{req_id}/links/{encoded}"
))
.await?;
if output::is_json() {
output::json_ok(
"unlinked",
serde_json::json!({ "requirement": req_id, "test": test }),
);
return Ok(());
}
output::success(&format!(
"Unlinked test '{test}' from requirement '{req_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"
@@ -716,6 +908,10 @@
"/{organization}/standards/{std_id}/applicabilities"
))
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let repos = resp
.get("repositories")
.and_then(|v| v.as_array())
@@ -749,6 +945,18 @@
&serde_json::json!({}),
)
.await?;
if output::is_json() {
output::json_ok(
"applicability",
serde_json::json!({
"standard": std_id,
"organization": organization,
"repo": repo,
"opted_in": true,
}),
);
return Ok(());
}
output::success(&format!(
"Opted '{organization}/{repo}' into standard '{std_id}'"
));
@@ -766,6 +974,18 @@
"/{organization}/standards/{std_id}/applicabilities/{repo}"
))
.await?;
if output::is_json() {
output::json_ok(
"applicability",
serde_json::json!({
"standard": std_id,
"organization": organization,
"repo": repo,
"opted_in": false,
}),
);
return Ok(());
}
output::success(&format!(
"Opted '{organization}/{repo}' out of standard '{std_id}'"
));
@@ -859,17 +1079,21 @@
payload["external_url"] = serde_json::json!(v);
}
let resp: RequirementDetail = client
let resp: serde_json::Value = client
.post(&format!("/{org}/{name}/requirements"), &payload)
.await?;
if output::is_json() {
output::json_ok("requirement", resp);
return Ok(());
}
output::success(&format!(
"Created requirement '{}' — {}",
resp["requirement_id"]
.as_str()
resp.requirement_id
.as_deref()
.unwrap_or(inputs.requirement_id),
resp.title.as_deref().unwrap_or(inputs.title)
resp["title"].as_str().unwrap_or(inputs.title)
));
if let Some(id) = &resp.id {
if let Some(id) = resp["id"].as_str() {
output::detail("UUID", id);
}
Ok(())
@@ -933,6 +1157,10 @@
}
let resp: serde_json::Value = client.post(&format!("/{org}/standards"), &payload).await?;
if output::is_json() {
output::json_ok("standard", resp);
return Ok(());
}
output::success(&format!(
"Created standard '{}' — {}",
resp["requirement_id"]
@@ -1008,13 +1236,17 @@
if let Some(v) = &args.meta.external_url {
payload["external_url"] = serde_json::json!(v);
}
let resp: RequirementDetail = client
let resp: serde_json::Value = client
.put(&format!("/{org}/{name}/requirements/{}", args.id), &payload)
.await?;
if output::is_json() {
output::json_ok("requirement", resp);
return Ok(());
}
output::success(&format!(
"Updated requirement '{}' — {}",
resp.requirement_id.as_deref().unwrap_or(&args.id),
resp.title.as_deref().unwrap_or("?")
resp["requirement_id"].as_str().unwrap_or(&args.id),
resp["title"].as_str().unwrap_or("?")
));
Ok(())
}
@@ -1077,6 +1309,10 @@
let resp: serde_json::Value = client
.put(&format!("/{org}/standards/{}", args.id), &payload)
.await?;
if output::is_json() {
output::json_ok("standard", resp);
return Ok(());
}
output::success(&format!(
"Updated standard '{}' — {}",
resp["requirement_id"].as_str().unwrap_or(&args.id),
@@ -1089,19 +1325,38 @@
repo: Option<&str>,
organization: Option<&str>,
kind: &str,
run_id: Option<&str>,
policy: Option<&str>,
run_window: Option<u32>,
) -> Result<(), Box<dyn std::error::Error>> {
match kind {
"requirement" => matrix_requirements(repo).await,
"requirement" => matrix_requirements(repo, run_id, policy, run_window).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_requirements(
repo: Option<&str>,
run_id: Option<&str>,
policy: Option<&str>,
run_window: Option<u32>,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let window_str = run_window.map(|w| w.to_string());
let mut query: Vec<(&str, &str)> = Vec::new();
if let Some(r) = run_id {
query.push(("run_id", r));
}
if let Some(p) = policy {
query.push(("policy", p));
}
if let Some(w) = window_str.as_deref() {
query.push(("run_window", w));
}
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/requirements/matrix"))
.get_with_query(&format!("/{org}/{name}/requirements/matrix"), &query)
.await?;
if output::is_json() {
@@ -1238,7 +1493,11 @@
Ok(body) => {
// 2xx with status=ok — coverage gate passed.
if body["status"] == "ok" {
output::success("Standards strict coverage gate passed");
if output::is_json() {
output::print_json(&serde_json::json!({"passed": true, "uncovered": []}));
} else {
output::success("Standards strict coverage gate passed");
}
Ok(())
} else {
// Unexpected envelope.
@@ -1253,8 +1512,17 @@
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);
if output::is_json() {
// Machine payload on stdout, then preserve the non-zero exit.
let uncovered = body
.get("uncovered")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
output::print_json(&serde_json::json!({"passed": false, "uncovered": uncovered}));
} else {
print_uncovered(&body);
}
Err(format!("{count} uncovered mandatory standard(s)").into())
}
Err(other) => Err(Box::new(other)),
@@ -1284,6 +1552,122 @@
println!();
}
/// Coverage tallies derived from the traceability matrix.
#[derive(Debug, Default, PartialEq)]
struct StatusCounts {
covered: usize,
partial: usize,
uncovered: usize,
no_tests: usize,
total: usize,
}
/// One requirement that trips the coverage gate.
#[derive(Debug, PartialEq)]
struct FailingReq {
requirement_id: String,
title: String,
coverage_status: String,
}
/// Machine-readable rollup of requirement coverage. Drives both the `--json`
/// payload and the pass/fail gate, so the two can never disagree.
struct StatusSummary {
counts: StatusCounts,
/// Requirements that trip the gate: uncovered always, plus partial/no_tests
/// under `--strict`.
failing: Vec<FailingReq>,
strict: bool,
}
impl StatusSummary {
/// The gate passes exactly when nothing is failing — this is the process
/// exit contract, identical in human and JSON modes.
fn passed(&self) -> bool {
self.failing.is_empty()
}
fn to_json(&self, org: &str, name: &str) -> serde_json::Value {
serde_json::json!({
"repo": format!("{org}/{name}"),
"strict": self.strict,
"passed": self.passed(),
"counts": {
"covered": self.counts.covered,
"partial": self.counts.partial,
"uncovered": self.counts.uncovered,
"no_tests": self.counts.no_tests,
"total": self.counts.total,
},
"failing": self
.failing
.iter()
.map(|f| {
serde_json::json!({
"requirement_id": f.requirement_id,
"title": f.title,
"coverage_status": f.coverage_status,
})
})
.collect::<Vec<_>>(),
})
}
}
/// Roll up matrix entries into counts + the failing list. Pure (no I/O) so the
/// gate logic is unit-testable without a server.
fn summarize_status(entries: &[serde_json::Value], strict: bool) -> StatusSummary {
let mut counts = StatusCounts {
total: entries.len(),
..Default::default()
};
let mut failing = Vec::new();
for entry in entries {
let status = entry["coverage_status"].as_str().unwrap_or("unknown");
match status {
"covered" => counts.covered += 1,
"partial" => counts.partial += 1,
"uncovered" => counts.uncovered += 1,
"no_tests" => counts.no_tests += 1,
_ => {}
}
let is_failing =
status == "uncovered" || (strict && (status == "partial" || status == "no_tests"));
if is_failing {
failing.push(FailingReq {
requirement_id: entry["requirement"]["requirement_id"]
.as_str()
.unwrap_or("?")
.to_string(),
title: entry["requirement"]["title"]
.as_str()
.unwrap_or("?")
.to_string(),
coverage_status: status.to_string(),
});
}
}
StatusSummary {
counts,
failing,
strict,
}
}
/// The gate's process-exit contract: `Ok` when passing, otherwise an `Err`
/// whose message names the uncovered count (matching the historical wording).
/// Shared by the human and JSON paths so both exit identically.
fn gate_result(summary: &StatusSummary) -> Result<(), Box<dyn std::error::Error>> {
if summary.passed() {
Ok(())
} else {
// Count the whole failing set, not just uncovered: under --strict the
// gate also fails on partial/no_tests, so "N uncovered" would misreport
// (e.g. "0 uncovered" while failing on partials).
Err(format!("{} requirement(s) failing coverage", summary.failing.len()).into())
}
}
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())?;
@@ -1297,62 +1681,40 @@
.cloned()
.unwrap_or_default();
let summary = summarize_status(&entries, args.strict);
if output::is_json() {
// Machine payload on stdout, then preserve the gate's exit contract so
// `set -e` scripts still fail on uncovered coverage.
output::print_json(&summary.to_json(&org, &name));
return gate_result(&summary);
}
if entries.is_empty() {
output::warn("No requirements found. Create requirements first.");
return Ok(());
}
let mut covered = 0usize;
let mut partial = 0usize;
let mut uncovered = 0usize;
let mut no_tests = 0usize;
for entry in &entries {
match entry["coverage_status"].as_str().unwrap_or("unknown") {
"covered" => covered += 1,
"partial" => partial += 1,
"uncovered" => uncovered += 1,
"no_tests" => no_tests += 1,
_ => {}
}
}
let total = entries.len();
output::header("Requirement Coverage Status");
println!(" Covered: {} ✓", covered);
println!(" Partial: {} ◐", partial);
println!(" Uncovered: {} ✗", uncovered);
println!(" No tests: {} ○", no_tests);
println!(" Total: {}", total);
println!(" Covered: {} ✓", summary.counts.covered);
println!(" Partial: {} ◐", summary.counts.partial);
println!(" Uncovered: {} ✗", summary.counts.uncovered);
println!(" No tests: {} ○", summary.counts.no_tests);
println!(" Total: {}", summary.counts.total);
println!();
if summary.passed() {
output::success("Requirement coverage check passed");
let fail = if args.strict {
uncovered > 0 || partial > 0 || no_tests > 0
Ok(())
} else {
uncovered > 0
};
if fail {
output::error("Requirement coverage check FAILED");
println!();
for entry in &entries {
let status = entry["coverage_status"].as_str().unwrap_or("");
let req_id = entry["requirement"]["requirement_id"]
.as_str()
.unwrap_or("?");
let title = entry["requirement"]["title"].as_str().unwrap_or("?");
let should_list = status == "uncovered"
|| (args.strict && (status == "partial" || status == "no_tests"));
if should_list {
output::detail(req_id, &format!("{} — {}", title, status));
}
for f in &summary.failing {
output::detail(
&f.requirement_id,
&format!("{} — {}", f.title, f.coverage_status),
);
}
Err(format!("{} uncovered requirement(s)", uncovered).into())
} else {
output::success("Requirement coverage check passed");
Ok(())
gate_result(&summary)
}
}
@@ -1373,28 +1735,22 @@
children: Vec<ReqDef>,
}
async fn seed(
async fn seed(repo: Option<&str>, clear: bool) -> Result<(), Box<dyn std::error::Error>> {
repo: Option<&str>,
clear: bool,
yes: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let json = output::is_json();
let mut cleared = 0;
if clear {
confirm_clear(&client, &org, &name, yes).await?;
output::warn("Clearing existing requirements...");
// List and delete all existing requirements (children first, then parents)
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/requirements")).await?;
let existing: Vec<serde_json::Value> = resp
.get("requirements")
if !json {
output::warn("Clearing existing requirements (hard delete)...");
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
// We can't easily delete via API since there's no delete endpoint exposed.
// Instead, we'll just create and let unique constraints tell us what already exists.
if !existing.is_empty() {
output::warn(&format!(
"Found {} existing requirements. Duplicates will be skipped.",
existing.len()
));
}
cleared = clear_requirements(&client, &org, &name).await?;
}
let domains = all_domains();
@@ -1416,7 +1772,9 @@
.await;
let parent_uuid = match parent_result {
Ok(detail) => {
output::success(&format!("Created parent: {} — {}", domain.id, domain.title));
if !json {
output::success(&format!("Created parent: {} — {}", domain.id, domain.title));
}
created += 1;
detail.id
}
@@ -1430,7 +1788,9 @@
let existing: RequirementDetail = client
.get(&format!("/{org}/{name}/requirements/{}", domain.id))
.await?;
output::info(&format!("Exists: {} — {}", domain.id, domain.title));
if !json {
output::info(&format!("Exists: {} — {}", domain.id, domain.title));
}
skipped += 1;
existing.id
} else {
@@ -1457,7 +1817,9 @@
match result {
Ok(_) => {
created += 1;
eprint!(".");
if !json {
eprint!(".");
}
}
Err(e) => {
let err_msg = format!("{e}");
@@ -1466,25 +1828,161 @@
|| err_msg.contains("already")
{
skipped += 1;
eprint!("s");
if !json {
eprint!("s");
}
} else {
eprintln!("\nError creating {}: {e}", child.id);
if !json {
eprintln!("\nError creating {}: {e}", child.id);
}
return Err(e.into());
}
}
}
}
eprintln!();
if !json {
eprintln!();
}
}
if json {
output::print_json(&serde_json::json!({
"ok": true,
"created": created,
"skipped": skipped,
"cleared": cleared,
"total": created + skipped,
}));
} else {
println!();
output::success(&format!(
"Done! Created: {created}, Skipped (existing): {skipped}, Cleared: {cleared}, Total: {}",
println!();
output::success(&format!(
"Done! Created: {created}, Skipped (existing): {skipped}, Total: {}",
created + skipped
));
created + skipped
));
}
Ok(())
}
/// Hard-delete every requirement in the repo, children before parents, so a
/// subsequent seed starts from a clean slate. A soft delete would leave rows
/// occupying the unique `(repository_id, requirement_id)` index — every create
/// would then 422 and be skipped — so `--clear` deletes hard. Returns the count
/// deleted. Children must go first: `hard_delete` cascade-soft-deletes
/// descendants, which the API then hides, so a parent must never be deleted
/// before its children.
/// --clear is irreversible (rows, version history, and test links all go).
/// Show the blast radius first, then require an explicit go-ahead: a TTY
/// prompt interactively, or --yes for scripts — never silently.
async fn confirm_clear(
client: &Client,
org: &str,
name: &str,
yes: bool,
) -> Result<(), Box<dyn std::error::Error>> {
if yes {
return Ok(());
}
let count = fetch_all_requirements(client, org, name).await?.len();
if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
return Err(format!(
"--clear would HARD-DELETE all {count} requirement(s) in {org}/{name} \
(including version history and test links). Re-run with --yes to confirm."
)
.into());
}
let confirmed = dialoguer::Confirm::new()
.with_prompt(format!(
"HARD-DELETE all {count} requirement(s) in {org}/{name}? This cannot be undone"
))
.default(false)
.interact()?;
if confirmed {
Ok(())
} else {
Err("aborted — nothing was deleted".into())
}
}
/// The full slate, INCLUDING soft-deleted rows: they still occupy the unique
/// (repository_id, requirement_id) index, so a clear that skipped them would
/// leave every re-seeded create 422-ing — the exact failure --clear exists to
/// prevent.
async fn fetch_all_requirements(
client: &Client,
org: &str,
name: &str,
) -> Result<Vec<serde_json::Value>, Box<dyn std::error::Error>> {
let resp: serde_json::Value = client
.get_with_query(
&format!("/{org}/{name}/requirements"),
&[("include_deleted", "true")],
)
.await?;
Ok(resp
.get("requirements")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default())
}
async fn clear_requirements(
client: &Client,
org: &str,
name: &str,
) -> Result<usize, Box<dyn std::error::Error>> {
let mut remaining = fetch_all_requirements(client, org, name).await?;
// Children-first ordering depends on the list payload carrying parent_id.
// A server that predates that contract would make us delete parents first,
// whose hard-delete cascade-SOFT-deletes children and strands the clear
// half-done — refuse loudly instead of misbehaving destructively.
if !remaining.is_empty() && remaining.iter().all(|r| r.get("parent_id").is_none()) {
return Err(
"this server's requirements API does not expose parent_id, so --clear cannot \
order deletes safely. Upgrade the Anvil server first."
.into(),
);
}
let mut cleared = 0;
while !remaining.is_empty() {
// UUIDs referenced as a parent by any still-present requirement.
let parent_ids: std::collections::HashSet<String> = remaining
.iter()
.filter_map(|r| r["parent_id"].as_str().map(String::from))
.collect();
let (leaves, rest): (Vec<serde_json::Value>, Vec<serde_json::Value>) =
remaining.into_iter().partition(|r| {
r["id"]
.as_str()
.map(|id| !parent_ids.contains(id))
.unwrap_or(true)
});
// A valid parent_id tree always has a leaf; the empty case only arises
// from an unexpected cycle — delete the remainder rather than loop.
let (batch, next) = if leaves.is_empty() {
(rest, Vec::new())
} else {
(leaves, rest)
};
for r in &batch {
if let Some(rid) = r["requirement_id"].as_str() {
client
.delete_empty(&format!("/{org}/{name}/requirements/{rid}?hard=true"))
.await?;
cleared += 1;
}
}
remaining = next;
}
Ok(cleared)
}
fn all_domains() -> Vec<DomainDef> {
vec![
DomainDef {
@@ -1805,6 +2303,114 @@
]
}
async fn import_requirements(
repo: Option<&str>,
file: &str,
format: &str,
dry_run: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let text = if file == "-" {
use std::io::Read;
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
buf
} else {
std::fs::read_to_string(file)?
};
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/requirements/import"),
&serde_json::json!({"format": format, "text": text, "dry_run": dry_run}),
)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
if dry_run {
let p = &resp["preview"];
let count = |k: &str| p[k].as_array().map(|a| a.len()).unwrap_or(0);
output::header("Import preview (dry run)");
println!(
" {} create · {} update · {} unchanged · {} error(s)",
count("create"),
count("update"),
count("unchanged"),
count("errors")
);
for e in p["errors"].as_array().unwrap_or(&vec![]) {
output::warn(&format!(
"{}: {}",
e["requirement_id"].as_str().unwrap_or("?"),
e["reason"].as_str().unwrap_or("?")
));
}
} else {
let a = &resp["applied"];
output::success(&format!(
"Imported: {} created, {} updated, {} unchanged",
a["created"], a["updated"], a["unchanged"]
));
}
Ok(())
}
/// Fetch the repo's requirements and print them in the import schema, so
/// `export > f.yml` then `import f.yml` round-trips (including into another
/// repo). Parent linkage is emitted as the parent's requirement_id.
/// Descriptions are not exported — the list endpoint doesn't carry them, and
/// import is partial-update shaped so their absence never clobbers anything.
async fn export_requirements(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")).await?;
let reqs = resp["requirements"].as_array().cloned().unwrap_or_default();
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
// Map UUID -> requirement_id so `parent` round-trips through import.
let mut id_to_req_id = std::collections::HashMap::new();
for r in &reqs {
if let (Some(uuid), Some(rid)) = (r["id"].as_str(), r["requirement_id"].as_str()) {
id_to_req_id.insert(uuid.to_string(), rid.to_string());
}
}
for r in &reqs {
println!(
"- requirement_id: {}",
yaml_str(r["requirement_id"].as_str().unwrap_or("?"))
);
if let Some(t) = r["title"].as_str() {
println!(" title: {}", yaml_str(t));
}
for key in ["category", "priority", "status"] {
if let Some(v) = r[key].as_str() {
println!(" {key}: {}", yaml_str(v));
}
}
if let Some(parent_uuid) = r["parent_id"].as_str() {
if let Some(parent_rid) = id_to_req_id.get(parent_uuid) {
println!(" parent: {}", yaml_str(parent_rid));
}
}
}
Ok(())
}
/// Quote a YAML scalar defensively (double-quoted style with escapes).
fn yaml_str(s: &str) -> String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1854,8 +2460,218 @@
let mut inputs = base_inputs("REQ-X", "t");
inputs.effective_date = Some("2026-01-01");
assert!(any_standards_flags(&inputs));
}
fn matrix_entry(id: &str, status: &str) -> serde_json::Value {
serde_json::json!({
"coverage_status": status,
"requirement": {"requirement_id": id, "title": format!("title {id}")}
})
}
#[test]
fn summarize_status_counts_all_categories() {
let entries = vec![
matrix_entry("REQ-A", "covered"),
matrix_entry("REQ-B", "covered"),
matrix_entry("REQ-C", "partial"),
matrix_entry("REQ-D", "uncovered"),
matrix_entry("REQ-E", "no_tests"),
];
let s = summarize_status(&entries, false);
assert_eq!(
s.counts,
StatusCounts {
covered: 2,
partial: 1,
uncovered: 1,
no_tests: 1,
total: 5,
}
);
}
#[test]
fn summarize_status_non_strict_fails_only_on_uncovered() {
let entries = vec![
matrix_entry("REQ-A", "partial"),
matrix_entry("REQ-B", "no_tests"),
matrix_entry("REQ-C", "uncovered"),
];
let s = summarize_status(&entries, false);
assert!(!s.passed());
assert_eq!(s.failing.len(), 1);
assert_eq!(s.failing[0].requirement_id, "REQ-C");
assert_eq!(s.failing[0].coverage_status, "uncovered");
}
#[test]
fn summarize_status_non_strict_passes_with_partials() {
let entries = vec![
matrix_entry("REQ-A", "covered"),
matrix_entry("REQ-B", "partial"),
];
let s = summarize_status(&entries, false);
assert!(s.passed());
assert!(s.failing.is_empty());
}
#[test]
fn summarize_status_strict_fails_on_partial_and_no_tests() {
let entries = vec![
matrix_entry("REQ-A", "covered"),
matrix_entry("REQ-B", "partial"),
matrix_entry("REQ-C", "no_tests"),
];
let s = summarize_status(&entries, true);
assert!(!s.passed());
let ids: Vec<&str> = s
.failing
.iter()
.map(|f| f.requirement_id.as_str())
.collect();
assert_eq!(ids, vec!["REQ-B", "REQ-C"]);
}
#[test]
fn summarize_status_empty_passes_in_both_modes() {
assert!(summarize_status(&[], false).passed());
let s = summarize_status(&[], true);
assert!(s.passed());
assert_eq!(s.counts.total, 0);
}
#[test]
fn status_summary_to_json_shape() {
let entries = vec![
matrix_entry("REQ-A", "covered"),
matrix_entry("REQ-B", "uncovered"),
];
let v = summarize_status(&entries, false).to_json("test-org", "test-repo");
assert_eq!(v["repo"], "test-org/test-repo");
assert_eq!(v["strict"], false);
assert_eq!(v["passed"], false);
assert_eq!(v["counts"]["covered"], 1);
assert_eq!(v["counts"]["uncovered"], 1);
assert_eq!(v["counts"]["total"], 2);
assert_eq!(v["failing"][0]["requirement_id"], "REQ-B");
assert_eq!(v["failing"][0]["coverage_status"], "uncovered");
}
#[tokio::test]
async fn clear_requirements_refuses_servers_without_parent_id() {
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
// Old server contract: entries carry no parent_id key at all.
Mock::given(method("GET"))
.and(path("/api/v1/o/r/requirements"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"requirements": [
{"id": "P", "requirement_id": "REQ-P"},
{"id": "C", "requirement_id": "REQ-C"}
]
})))
.mount(&server)
.await;
let client = Client::for_test(server.uri(), "tok");
let err = clear_requirements(&client, "o", "r")
.await
.expect_err("must refuse to delete without ordering info");
assert!(err.to_string().contains("parent_id"), "got: {err}");
// Nothing was deleted.
let deletes = server
.received_requests()
.await
.unwrap()
.iter()
.filter(|r| r.method.as_str() == "DELETE")
.count();
assert_eq!(deletes, 0);
}
#[tokio::test]
async fn confirm_clear_requires_yes_when_not_a_tty() {
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/o/r/requirements"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"requirements": [{"id": "P", "requirement_id": "REQ-P", "parent_id": null}]
})))
.mount(&server)
.await;
let client = Client::for_test(server.uri(), "tok");
// --yes short-circuits.
assert!(confirm_clear(&client, "o", "r", true).await.is_ok());
// cargo-test stdin is not a TTY → the prompt path must refuse.
let err = confirm_clear(&client, "o", "r", false)
.await
.expect_err("non-TTY without --yes must refuse");
assert!(err.to_string().contains("--yes"), "got: {err}");
}
#[tokio::test]
async fn clear_requirements_hard_deletes_children_before_parents() {
use serde_json::json;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
// Two-level tree: parent REQ-P (uuid "P") and child REQ-C (parent_id "P").
// The list endpoint includes parent_id per the server contract
// (fangorn/anvil #331) — without it, ordering would be undefined.
Mock::given(method("GET"))
.and(path("/api/v1/o/r/requirements"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"requirements": [
{"id": "P", "requirement_id": "REQ-P", "parent_id": null},
{"id": "C", "requirement_id": "REQ-C", "parent_id": "P"}
]
})))
.mount(&server)
.await;
// Both DELETEs REQUIRE ?hard=true — if the CLI omits it, no mock matches,
// the request 404s, and clear_requirements returns Err (test fails).
for rid in ["REQ-C", "REQ-P"] {
Mock::given(method("DELETE"))
.and(path(format!("/api/v1/o/r/requirements/{rid}")))
.and(query_param("hard", "true"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
}
let client = Client::for_test(server.uri(), "tok");
let cleared = clear_requirements(&client, "o", "r").await.unwrap();
assert_eq!(cleared, 2);
// The child (REQ-C) must be deleted before the parent (REQ-P).
let reqs = server.received_requests().await.unwrap();
let deleted: Vec<String> = reqs
.iter()
.map(|r| r.url.path().to_string())
.filter(|p| p.starts_with("/api/v1/o/r/requirements/REQ-"))
.collect();
assert_eq!(
deleted,
vec![
"/api/v1/o/r/requirements/REQ-C".to_string(),
"/api/v1/o/r/requirements/REQ-P".to_string(),
]
);
}
// 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.
@@ -2019,7 +2835,7 @@
#[tokio::test]
async fn matrix_standards_rejects_missing_organization() {
let err = matrix(None, None, "standard")
let err = matrix(None, None, "standard", None, None, None)
.await
.unwrap_err()
.to_string();
@@ -2028,6 +2844,9 @@
#[tokio::test]
async fn matrix_rejects_unknown_kind() {
let err = matrix(None, None, "bogus", None, None, None)
.await
let err = matrix(None, None, "bogus").await.unwrap_err().to_string();
.unwrap_err()
.to_string();
assert!(err.contains("bogus"), "got: {err}");
}
src/output.rs +10 −0
@@ -27,6 +27,16 @@
}
}
/// Print a uniform `{ "ok": true, <key>: <value> }` confirmation for a
/// successful mutation in JSON mode. Callers guard with `is_json()` and return
/// afterward, so scripts get a predictable success envelope on stdout.
pub fn json_ok(key: &str, value: serde_json::Value) {
let mut map = serde_json::Map::new();
map.insert("ok".to_string(), serde_json::Value::Bool(true));
map.insert(key.to_string(), value);
print_json(&serde_json::Value::Object(map));
}
/// Print a key-value detail line.
pub fn detail(key: &str, value: &str) {
println!("{:>14} {}", key.bold(), value);
tests/json_output.rs +853 −0
@@ -1,0 +1,853 @@
//! End-to-end `--json` tests for the requirement/standard read + status surface.
//!
//! Each test boots a wiremock server, points the real `anvil` binary at it via
//! `ANVIL_SERVER_URL`/`ANVIL_TOKEN`, runs a command with `--json`, and asserts
//! that **stdout parses as JSON** with the expected shape — i.e. that an agent
//! scripting against the CLI gets structured output it can consume, and that the
//! status gate still exits non-zero while emitting a clean machine payload.
//! (fangorn/anvil#330, epic #329 Workstream B.)
use serde_json::{json, Value};
use std::process::Output;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// Run the built `anvil` binary with `--json` against `server_uri`, capturing
/// its output. Callers pass `--repo`/`--organization` explicitly so repo
/// resolution never falls through to the developer's real config or git remote.
fn run_json(server_uri: &str, args: &[&str]) -> Output {
std::process::Command::new(env!("CARGO_BIN_EXE_anvil"))
.arg("--json")
.args(args)
.env("ANVIL_SERVER_URL", server_uri)
.env("ANVIL_TOKEN", "test-token")
.output()
.expect("failed to run anvil binary")
}
/// Parse stdout as JSON, surfacing stdout+stderr on failure so a regression
/// (e.g. a stray human-readable line leaking onto stdout) is easy to diagnose.
fn stdout_json(out: &Output) -> Value {
serde_json::from_slice(&out.stdout).unwrap_or_else(|e| {
panic!(
"stdout was not valid JSON: {e}\n--- stdout ---\n{}\n--- stderr ---\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
)
})
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn requirement_list_emits_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"requirements": [{
"requirement_id": "REQ-ACCT-001",
"title": "Users can register",
"category": "functional",
"priority": "high",
"status": "active",
"test_count": 2
}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["requirement", "list", "--repo", "test-org/test-repo"],
);
assert!(
out.status.success(),
"expected success; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert!(v["requirements"].is_array(), "got: {v}");
assert_eq!(v["requirements"][0]["requirement_id"], "REQ-ACCT-001");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn standard_list_emits_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/standards"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"standards": [{
"requirement_id": "STD-GDPR-017",
"title": "Right to erasure",
"framework": "GDPR",
"citation": "Art. 17",
"mandatory": true,
"status": "active"
}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"list",
"--kind",
"standard",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["standards"][0]["requirement_id"], "STD-GDPR-017");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn requirement_view_emits_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements/REQ-ACCT-001"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"requirement_id": "REQ-ACCT-001",
"title": "Users can register",
"category": "functional",
"priority": "high",
"status": "active"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"view",
"REQ-ACCT-001",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["requirement_id"], "REQ-ACCT-001");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn standard_view_emits_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/standards/STD-GDPR-017"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"requirement_id": "STD-GDPR-017",
"title": "Right to erasure",
"framework": "GDPR",
"mandatory": true,
"status": "active"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"view",
"STD-GDPR-017",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["requirement_id"], "STD-GDPR-017");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn status_emits_json_and_passes_when_covered() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements/matrix"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"matrix": [
{"coverage_status": "covered",
"requirement": {"requirement_id": "REQ-A", "title": "A"}}
]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["requirement", "status", "--repo", "test-org/test-repo"],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["passed"], true);
assert_eq!(v["strict"], false);
assert_eq!(v["counts"]["covered"], 1);
assert_eq!(v["counts"]["total"], 1);
assert!(v["failing"].as_array().unwrap().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn status_strict_emits_json_and_exits_nonzero_when_failing() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements/matrix"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"matrix": [
{"coverage_status": "covered",
"requirement": {"requirement_id": "REQ-A", "title": "A"}},
{"coverage_status": "partial",
"requirement": {"requirement_id": "REQ-B", "title": "B"}},
{"coverage_status": "uncovered",
"requirement": {"requirement_id": "REQ-C", "title": "C"}}
]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"status",
"--strict",
"--repo",
"test-org/test-repo",
],
);
// Gate must still fail the process even though we emitted JSON.
assert!(
!out.status.success(),
"strict status with uncovered/partial must exit non-zero"
);
// ...and stdout must still be clean, parseable JSON for the agent.
let v = stdout_json(&out);
assert_eq!(v["passed"], false);
assert_eq!(v["strict"], true);
assert_eq!(v["counts"]["covered"], 1);
assert_eq!(v["counts"]["partial"], 1);
assert_eq!(v["counts"]["uncovered"], 1);
assert_eq!(v["counts"]["total"], 3);
// Strict mode reports both the uncovered and the partial as failing.
let failing = v["failing"].as_array().unwrap();
assert_eq!(failing.len(), 2, "got: {v}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn strict_standards_emits_json_and_passes() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/standards/strict"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"status": "ok"})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"status",
"--strict-standards",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["passed"], true);
assert!(v["uncovered"].as_array().unwrap().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn strict_standards_emits_json_and_exits_nonzero_when_uncovered() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/standards/strict"))
.respond_with(ResponseTemplate::new(422).set_body_json(json!({
"status": "uncovered",
"uncovered": [{
"standard": {"requirement_id": "STD-GDPR-017", "title": "Right to erasure"},
"repo": {"slug": "customer-app"},
"status": "uncovered"
}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"status",
"--strict-standards",
"--organization",
"test-org",
],
);
assert!(
!out.status.success(),
"uncovered mandatory standards must exit non-zero"
);
let v = stdout_json(&out);
assert_eq!(v["passed"], false);
let uncovered = v["uncovered"].as_array().unwrap();
assert_eq!(uncovered.len(), 1, "got: {v}");
assert_eq!(uncovered[0]["standard"]["requirement_id"], "STD-GDPR-017");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn applicability_list_emits_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(
"/api/v1/test-org/standards/STD-GDPR-017/applicabilities",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"repositories": [{"slug": "app", "name": "App", "visibility": "private"}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"applicability",
"list",
"STD-GDPR-017",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["repositories"][0]["slug"], "app");
}
// ── Mutations: uniform {"ok":true, ...} confirmation envelope ────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn create_requirement_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/requirements"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"requirement_id": "REQ-NEW-001",
"title": "New requirement"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"create",
"--requirement-id",
"REQ-NEW-001",
"--title",
"New requirement",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["requirement"]["requirement_id"], "REQ-NEW-001");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn delete_requirement_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/test-org/test-repo/requirements/REQ-OLD-001"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"delete",
"REQ-OLD-001",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["deleted"], "REQ-OLD-001");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn applicability_add_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path(
"/api/v1/test-org/standards/STD-GDPR-017/applicabilities/app",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"applicability",
"add",
"STD-GDPR-017",
"--organization",
"test-org",
"--repo",
"app",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["applicability"]["opted_in"], true);
assert_eq!(v["applicability"]["repo"], "app");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn link_test_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(
"/api/v1/test-org/test-repo/requirements/REQ-ACCT-001/links",
))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
"test_name": "Anvil.AccountsTest.login",
"requirement_id": "REQ-ACCT-001"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"link",
"REQ-ACCT-001",
"--test",
"Anvil.AccountsTest.login",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["link"]["test_name"], "Anvil.AccountsTest.login");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unlink_test_encodes_name_and_emits_ok_envelope() {
let server = MockServer::start().await;
// A test name with a space must be percent-encoded in the path segment.
Mock::given(method("DELETE"))
.and(path(
"/api/v1/test-org/test-repo/requirements/REQ-ACCT-001/links/user%20can%20log%20in",
))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"unlink",
"REQ-ACCT-001",
"--test",
"user can log in",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"expected the encoded DELETE to match; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["unlinked"]["test"], "user can log in");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn issue_link_req_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(
"/api/v1/test-org/test-repo/issues/7/requirement-links",
))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"linked": {"requirement_id": "REQ-A-001", "title": "Auth", "kind": "requirement"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"issue",
"link-req",
"7",
"REQ-A-001",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["linked"]["requirement_id"], "REQ-A-001");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn issue_unlink_req_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path(
"/api/v1/test-org/test-repo/issues/7/requirement-links/STD-B-002",
))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"issue",
"unlink-req",
"7",
"STD-B-002",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["unlinked"]["requirement_id"], "STD-B-002");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_edit_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/api/v1/test-org/test-repo/labels/bug"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"name": "defect", "color": "#00ff00", "description": null
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"label",
"edit",
"bug",
"--new-name",
"defect",
"--color",
"#00ff00",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["label"]["name"], "defect");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_delete_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/test-org/test-repo/labels/bug"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["label", "delete", "bug", "--repo", "test-org/test-repo"],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["deleted"], "bug");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_list_emits_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/labels"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"labels": [{"name": "bug", "color": "#ff0000", "description": null}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["label", "list", "test-org/test-repo"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["labels"][0]["name"], "bug");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn milestone_edit_close_delete_emit_ok_envelopes() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/api/v1/test-org/test-repo/milestones/abc123"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"milestone": {"short_id": "abc123", "title": "v1.1", "state": "open"}
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/milestones/abc123/close"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"milestone": {"short_id": "abc123", "title": "v1.1", "state": "closed"}
})))
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/api/v1/test-org/test-repo/milestones/abc123"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let edit = run_json(
&server.uri(),
&[
"milestone",
"edit",
"abc123",
"--title",
"v1.1",
"--repo",
"test-org/test-repo",
],
);
assert!(edit.status.success());
let v = stdout_json(&edit);
assert_eq!(v["ok"], true);
assert_eq!(v["milestone"]["title"], "v1.1");
let close = run_json(
&server.uri(),
&[
"milestone",
"close",
"abc123",
"--repo",
"test-org/test-repo",
],
);
assert!(close.status.success());
assert_eq!(stdout_json(&close)["milestone"]["state"], "closed");
let del = run_json(
&server.uri(),
&[
"milestone",
"delete",
"abc123",
"--repo",
"test-org/test-repo",
],
);
assert!(del.status.success());
assert_eq!(stdout_json(&del)["deleted"], "abc123");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn board_init_and_issue_move_emit_ok_envelopes() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/board/initialize"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"columns": [{"id": "c1", "short_id": "aaa", "name": "Backlog", "position": 0}]
})))
.mount(&server)
.await;
Mock::given(method("PATCH"))
.and(path("/api/v1/test-org/test-repo/issues/7"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"number": 7, "column_id": "c1"
})))
.mount(&server)
.await;
let init = run_json(
&server.uri(),
&["board", "init", "--repo", "test-org/test-repo"],
);
assert!(
init.status.success(),
"stderr: {}",
String::from_utf8_lossy(&init.stderr)
);
let v = stdout_json(&init);
assert_eq!(v["ok"], true);
assert_eq!(v["columns"][0]["name"], "Backlog");
let mv = run_json(
&server.uri(),
&[
"issue",
"move",
"7",
"--column",
"c1",
"--repo",
"test-org/test-repo",
],
);
assert!(
mv.status.success(),
"stderr: {}",
String::from_utf8_lossy(&mv.stderr)
);
assert_eq!(stdout_json(&mv)["ok"], true);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn requirement_import_dry_run_round_trips() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/requirements/import"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"dry_run": true,
"preview": {
"create": [{"requirement_id": "REQ-A-001", "title": "A"}],
"update": [], "unchanged": [], "errors": []
}
})))
.mount(&server)
.await;
let dir = std::env::temp_dir().join(format!("anvil-import-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("reqs.yml");
std::fs::write(&file, "- requirement_id: REQ-A-001\n title: A\n").unwrap();
let out = run_json(
&server.uri(),
&[
"requirement",
"import",
file.to_str().unwrap(),
"--dry-run",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["dry_run"], true);
assert_eq!(v["preview"]["create"][0]["requirement_id"], "REQ-A-001");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_names_with_slashes_are_path_encoded() {
let server = MockServer::start().await;
// "area/ci" must arrive as ONE encoded segment, not two path segments.
Mock::given(method("DELETE"))
.and(path("/api/v1/test-org/test-repo/labels/area%2Fci"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["label", "delete", "area/ci", "--repo", "test-org/test-repo"],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(stdout_json(&out)["deleted"], "area/ci");
}