▸
.anvil.yml
+10
−1
@@ -11,5 +11,10 @@
run: cargo check --all-targets 2>&1
- name: test
# Generous budget: the suite is ~400 tests, many of which spawn the built
# binary against a wiremock server (the --json contract + adversarial
# suites), which is slow on a loaded runner. Compiling 10 integration-test
# binaries adds to it. Was tipping past the default timeout under load.
timeout_seconds: 1200
run: cargo test 2>&1
depends_on: [check]
@@ -19,7 +24,11 @@
run: |
rustup component add llvm-tools-preview
cargo install cargo-tarpaulin --version 0.32.7 2>&1
# Skip the JSON contract fuzzer here: it spawns ~120 instrumented
cargo tarpaulin --engine llvm --out lcov --output-dir coverage/ 2>&1
# subprocesses (slow enough under tarpaulin to trip its own per-leaf
# deadline) and, being a subprocess test, contributes no line coverage.
# It runs for real in the `test` job.
cargo tarpaulin --engine llvm --out lcov --output-dir coverage/ -- --skip every_leaf_emits_valid_json_under_json 2>&1
depends_on: [check]
artifacts:
- name: lcov.info
▸
build.rs
+3
−0
@@ -1,3 +1,6 @@
// `println!("cargo:…")` is the build-script directive channel, not stdout output.
#![allow(clippy::disallowed_macros)]
use std::process::Command;
fn main() {
▸
Cargo.toml
+4
−0
@@ -5,6 +5,10 @@
description = "CLI and CI runner for Anvil — a self-hosted code forge"
license = "MIT"
[lib]
name = "anvil"
path = "src/lib.rs"
[[bin]]
name = "anvil"
path = "src/main.rs"
▸
clippy.toml
+10
−0
@@ -1,0 +1,10 @@
# Raw stdout writes bypass the JSON contract: under `--json`, stdout must hold
# exactly one JSON value (see src/output.rs). A `println!`/`print!` in a command
# leaks human text into that stream. Route human output through `output::line`
# (and friends, which no-op under --json) and machine output through a returned
# `output::Response`. The renderer in `output.rs` — the one place that actually
# writes stdout — allows these explicitly with `#[allow(clippy::disallowed_macros)]`.
disallowed-macros = [
{ path = "std::println", reason = "use output::line / return an output::Response so --json stays clean" },
{ path = "std::print", reason = "use output::line / return an output::Response so --json stays clean" },
]
▸
src/commands/agent.rs
+45
−41
@@ -69,7 +69,7 @@
},
}
pub async fn run(args: AgentArgs) -> Result<(), Box<dyn std::error::Error>> {
pub async fn run(args: AgentArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
match args.command {
AgentCommand::List { repo } => list(repo.as_deref()).await,
AgentCommand::View { name, repo } => view(repo.as_deref(), &name).await,
@@ -83,23 +83,18 @@
}
}
async fn list(repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
async fn list(repo: Option<&str>) -> Result<output::Response, 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}/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") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else {
serde_json::from_value(resp).unwrap_or_default()
serde_json::from_value(resp.clone()).unwrap_or_default()
};
output::header(&format!("Agents ({org}/{name})"));
@@ -126,9 +121,12 @@
output::print_table(&["NAME", "DESCRIPTION", "TRIGGER"], &rows);
Ok(())
Ok(output::Response::read(resp))
}
async fn view(
repo: Option<&str>,
agent_name: &str,
) -> Result<output::Response, Box<dyn std::error::Error>> {
async fn view(repo: Option<&str>, agent_name: &str) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -137,11 +135,6 @@
.get(&format!("/{org}/{name}/agents/{agent_name}"))
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let agent = resp
.get("agent")
.or_else(|| resp.get("data"))
@@ -165,13 +158,13 @@
output::detail("Model", model);
}
Ok(output::Response::read(resp))
Ok(())
}
async fn trigger(
repo: Option<&str>,
agent_name: &str,
prompt: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -195,9 +188,12 @@
output::success(&format!("Triggered agent {agent_name}"));
output::detail("Session", session_id);
Ok(output::Response::ok("agent", resp))
Ok(())
}
async fn sessions(
repo: Option<&str>,
agent_name: &str,
) -> Result<output::Response, Box<dyn std::error::Error>> {
async fn sessions(repo: Option<&str>, agent_name: &str) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -206,17 +202,12 @@
.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") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else {
serde_json::from_value(resp.clone()).unwrap_or_default()
serde_json::from_value(resp).unwrap_or_default()
};
output::header(&format!("Sessions for agent {agent_name}"));
@@ -242,9 +233,12 @@
output::print_table(&["ID", "STATUS", "STARTED"], &rows);
Ok(())
Ok(output::Response::read(resp))
}
async fn session(repo: Option<&str>, id: &str) -> Result<(), Box<dyn std::error::Error>> {
async fn session(
repo: Option<&str>,
id: &str,
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -253,17 +247,15 @@
.get(&format!("/{org}/{name}/agents/sessions/{id}"))
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let session = resp
.get("session")
.or_else(|| resp.get("data"))
.unwrap_or(&resp);
output::header(&format!("Session {}", &id[..8.min(id.len())]));
output::header(&format!(
"Session {}",
id.chars().take(8).collect::<String>()
));
if let Some(status) = session.get("status").and_then(|v| v.as_str()) {
output::detail("Status", &output::colorize_status(status));
@@ -278,13 +270,16 @@
for msg in messages {
let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("?");
let content = msg.get("content").and_then(|v| v.as_str()).unwrap_or("");
output::line(&format!(" [{role}] {content}"));
println!(" [{role}] {content}");
}
}
Ok(())
Ok(output::Response::read(resp))
}
async fn approve(
repo: Option<&str>,
id: &str,
async fn approve(repo: Option<&str>, id: &str) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -295,12 +290,18 @@
output::success(&format!(
"Approved action for session {}",
&id[..8.min(id.len())]
id.chars().take(8).collect::<String>()
));
Ok(output::Response::ok(
"approved",
Ok(())
serde_json::json!({ "session": id }),
))
}
async fn reject(
repo: Option<&str>,
async fn reject(repo: Option<&str>, id: &str) -> Result<(), Box<dyn std::error::Error>> {
id: &str,
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -311,8 +312,11 @@
output::success(&format!(
"Rejected action for session {}",
&id[..8.min(id.len())]
id.chars().take(8).collect::<String>()
));
Ok(())
Ok(output::Response::ok(
"rejected",
serde_json::json!({ "session": id }),
))
}
▸
src/commands/auth.rs
+42
−25
@@ -59,7 +59,7 @@
Logout,
}
pub async fn run(args: AuthArgs) -> Result<(), Box<dyn std::error::Error>> {
pub async fn run(args: AuthArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
match args.command {
AuthCommand::Login {
url,
@@ -106,7 +106,7 @@
url: Option<String>,
token: Option<String>,
no_browser: bool,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>> {
let url = match url {
Some(u) => u,
None => dialoguer::Input::<String>::new()
@@ -132,12 +132,18 @@
config.save()?;
match username {
Some(ref u) => output::success(&format!("Logged in to {base} as {u}")),
Some(u) => output::success(&format!("Logged in to {base} as {u}")),
None => output::success(&format!("Logged in to {base}")),
}
output::info(&format!("Config saved to {}", Config::path().display()));
Ok(output::Response::ok(
"login",
serde_json::json!({
Ok(())
"server": base,
"username": username,
}),
))
}
#[derive(Debug, Deserialize)]
@@ -172,10 +178,12 @@
.clone()
.unwrap_or_else(|| dc.verification_uri.clone());
output::info(&format!("Open this URL to authorize: {target}"));
output::info(&format!("If prompted, confirm the code: {}", dc.user_code));
// These go to stderr (warn) so a --json login never blocks/leaks on the
// authorization prompt — the JSON stdout stays a clean data channel.
output::warn(&format!("Open this URL to authorize: {target}"));
output::warn(&format!("If prompted, confirm the code: {}", dc.user_code));
if !no_browser && try_open_browser(&target) {
output::info("Opened the authorization page in your browser.");
output::warn("Opened the authorization page in your browser.");
}
poll_for_token(&client, base, &dc.device_code, dc.interval, dc.expires_in).await
@@ -303,23 +311,29 @@
}
}
async fn status() -> Result<(), Box<dyn std::error::Error>> {
async fn status() -> Result<output::Response, Box<dyn std::error::Error>> {
let config = Config::load()?;
match (&config.server_url, &config.token) {
let logged_in = match (&config.server_url, &config.token) {
(Some(url), Some(token)) => {
output::detail("Server", url);
output::detail("Token", &format!("{}…", truncate(token)));
if let Some(ref repo) = config.default_repo {
output::detail("Default repo", repo);
}
true
}
_ => {
println!("Not logged in. Run `anvil auth login` to authenticate.");
output::line("Not logged in. Run `anvil auth login` to authenticate.");
false
}
}
};
Ok(output::Response::read(serde_json::json!({
"logged_in": logged_in,
"server": config.server_url,
"default_repo": config.default_repo,
})))
Ok(())
}
/// First 8 characters of a token, for display. Deliberately char-based: `&s[..8]`
@@ -338,7 +352,7 @@
/// Rotate the stored credential: the server mints a replacement with the same
/// name, scopes and expiry, and destroys the presenting token in the same
/// transaction. There is no undo, so we confirm first unless `--yes`.
async fn rotate(yes: bool) -> Result<output::Response, Box<dyn std::error::Error>> {
async fn rotate(yes: bool) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load()?;
// Fail before touching the server if there's nothing to rotate.
let server = config.server_url()?.to_string();
@@ -367,6 +381,9 @@
.interact()?
{
output::info("Rotation cancelled — nothing changed.");
return Ok(output::Response::ok(
"rotated",
serde_json::json!({"cancelled": true}),
return Ok(());
));
}
}
@@ -410,11 +427,6 @@
eprintln!("\nUpdate it to the new value:\n\n export ANVIL_TOKEN={new_token}\n");
}
if output::is_json() {
output::json_ok("rotated", resp);
return Ok(());
}
output::success(&format!("Rotated the token for {server}"));
let revoked = resp
.get("revoked")
@@ -426,18 +438,23 @@
output::detail("New token", &format!("{}…", truncate(new_token)));
output::detail("Config", &Config::path().display().to_string());
Ok(output::Response::ok("rotated", resp))
Ok(())
}
async fn logout() -> Result<(), Box<dyn std::error::Error>> {
async fn logout() -> Result<output::Response, Box<dyn std::error::Error>> {
let path = Config::path();
if path.exists() {
let removed = if path.exists() {
std::fs::remove_file(&path)?;
output::success("Logged out — credentials removed");
true
} else {
println!("Not logged in.");
}
Ok(())
output::line("Not logged in.");
false
};
Ok(output::Response::ok(
"logout",
serde_json::json!({"removed": removed}),
))
}
#[cfg(test)]
▸
src/commands/board.rs
+25
−35
@@ -75,7 +75,7 @@
},
}
pub async fn run(args: BoardArgs) -> Result<(), Box<dyn std::error::Error>> {
pub async fn run(args: BoardArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
match args.command {
BoardCommand::List { repo } => list(repo.as_deref()).await,
BoardCommand::Init { repo } => init(repo.as_deref()).await,
@@ -119,7 +119,7 @@
}
}
async fn init(repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
async fn init(repo: Option<&str>) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
@@ -128,13 +128,12 @@
&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(output::Response::ok(
"columns",
resp.get("columns").cloned().unwrap_or(resp),
))
Ok(())
}
async fn create_column(
@@ -144,7 +143,7 @@
wip_limit: Option<u32>,
closed_column: bool,
color: Option<&str>,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> 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});
@@ -163,12 +162,11 @@
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(output::Response::ok(
"column",
resp.get("column").cloned().unwrap_or(resp),
))
Ok(())
}
async fn edit_column(
@@ -178,7 +176,7 @@
position: Option<u32>,
wip_limit: Option<u32>,
color: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let mut payload = serde_json::json!({});
@@ -197,28 +195,26 @@
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(())
Ok(output::Response::ok(
"column",
resp.get("column").cloned().unwrap_or(resp),
))
}
async fn delete_column(
repo: Option<&str>,
id: &str,
async fn delete_column(repo: Option<&str>, id: &str) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, 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(())
Ok(output::Response::deleted(id))
}
async fn list(repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
async fn list(repo: Option<&str>) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -228,18 +224,12 @@
Err(e) => {
let err_str = format!("{}", e);
if err_str.contains("404") || err_str.contains("Feature") {
output::error("Board feature is not enabled for this repository.");
return Ok(());
return Err("Board feature is not enabled for this repository.".into());
}
return Err(e.into());
}
};
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
// Parse and display columns
let columns = resp
.get("columns")
@@ -249,7 +239,7 @@
if columns.is_empty() {
output::info("No board columns found.");
return Ok(output::Response::read(resp));
return Ok(());
}
for col in &columns {
@@ -275,5 +265,5 @@
}
}
Ok(output::Response::read(resp))
Ok(())
}
▸
src/commands/branch.rs
+4
−9
@@ -26,29 +26,24 @@
target: Option<String>,
}
pub async fn run(args: BranchArgs) -> Result<(), Box<dyn std::error::Error>> {
pub async fn run(args: BranchArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
match args.command {
BranchCommand::List { repo } => list(repo.as_deref()).await,
}
}
async fn list(repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
async fn list(repo: Option<&str>) -> Result<output::Response, 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}/branches")).await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let branches: Vec<Branch> = if let Some(arr) = resp.get("branches") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else if let Some(arr) = resp.get("data") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else {
serde_json::from_value(resp).unwrap_or_default()
serde_json::from_value(resp.clone()).unwrap_or_default()
};
output::header(&format!("Branches ({org}/{name})"));
@@ -69,5 +64,5 @@
output::print_table(&["NAME", "SHA"], &rows);
Ok(output::Response::read(resp))
Ok(())
}
▸
src/commands/ci.rs
+83
−48
@@ -125,7 +125,7 @@
exit_code: Option<i32>,
}
pub async fn run(args: CiArgs) -> Result<(), Box<dyn std::error::Error>> {
pub async fn run(args: CiArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
match args.command {
CiCommand::List {
repo,
@@ -157,6 +157,6 @@
repo: Option<&str>,
status: Option<&str>,
limit: u32,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -171,18 +171,13 @@
let resp: serde_json::Value = client
.get_with_query(&format!("/{org}/{name}/ci/runs"), &query_refs)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let runs: Vec<CiRun> = if let Some(arr) = resp.get("ci_runs") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else if let Some(arr) = resp.get("data") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else {
serde_json::from_value(resp).unwrap_or_default()
serde_json::from_value(resp.clone()).unwrap_or_default()
};
output::header(&format!("CI runs ({org}/{name})"));
@@ -224,26 +219,24 @@
&rows,
);
Ok(())
Ok(output::Response::read(resp))
}
async fn view(
id: &str,
repo: Option<&str>,
) -> Result<output::Response, Box<dyn std::error::Error>> {
async fn view(id: &str, 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}/ci/runs/{id}")).await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let run: CiRun = if let Some(obj) = resp.get("ci_run") {
serde_json::from_value(obj.clone())?
} else if let Some(obj) = resp.get("data") {
serde_json::from_value(obj.clone())?
} else {
serde_json::from_value(resp)?
serde_json::from_value(resp.clone())?
};
let display_id = run
@@ -304,13 +297,13 @@
}
}
Ok(())
Ok(output::Response::read(resp))
}
async fn job_view(
id: &str,
no_follow: bool,
repo: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -323,7 +316,7 @@
} else if let Some(obj) = resp.get("data") {
serde_json::from_value(obj.clone())?
} else {
serde_json::from_value(resp)?
serde_json::from_value(resp.clone())?
};
let display_id = job
@@ -369,20 +362,36 @@
let full_path = format!("{log_path}{follow_query}");
eprintln!(); // blank line before logs
if let Err(e) = stream_job_logs(&client, &full_path).await {
// Metadata above was already printed successfully — distinguish so
let logs = match stream_job_logs(&client, &full_path).await {
Ok(logs) => logs,
Err(e) => {
// Metadata above was already printed successfully — surface the
// failure to `main` (which renders the {"ok":false,…} envelope
// under --json), while noting the metadata was retrieved.
return Err(format!(
"Could not fetch logs: {e} (job metadata was retrieved successfully)"
)
.into());
}
};
let job_obj = resp
.get("job")
.or_else(|| resp.get("data"))
.cloned()
// the user doesn't think the whole command failed. Print directly
// and exit so the top-level handler doesn't add a redundant
// "Error: ..." line that contradicts the metadata we already showed.
eprintln!("Could not fetch logs: {e}");
eprintln!("(Job metadata above was retrieved successfully.)");
std::process::exit(1);
}
.unwrap_or(resp);
Ok(())
Ok(output::Response::read(
serde_json::json!({ "job": job_obj, "logs": logs }),
))
}
/// Stream a job's SSE build logs. Returns the captured log lines (for the JSON
/// envelope); in human mode it also prints them live as they arrive.
async fn stream_job_logs(
client: &Client,
async fn stream_job_logs(client: &Client, path: &str) -> Result<(), Box<dyn std::error::Error>> {
path: &str,
) -> Result<Vec<serde_json::Value>, Box<dyn std::error::Error>> {
let resp = client.get_sse_stream(path).await?;
let stream = resp
.bytes_stream()
@@ -392,6 +401,7 @@
let mut line = String::new();
let mut event_type = String::new();
let mut logs: Vec<serde_json::Value> = Vec::new();
loop {
line.clear();
@@ -410,18 +420,21 @@
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
let content = parsed["content"].as_str().unwrap_or("");
let stream_type = parsed["stream"].as_str().unwrap_or("stdout");
// Human mode prints logs live; `output::line` self-
// suppresses under --json (where the captured `logs`
// are returned in the Response instead).
output::line(content);
logs.push(serde_json::json!({
if stream_type == "stderr" {
// dim style for stderr
eprintln!("\x1b[2m{}\x1b[0m", content);
} else {
println!("{}", content);
"content": content,
"stream": stream_type,
}));
}
}
}
"done" => {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
let status = parsed["status"].as_str().unwrap_or("?");
let exit_code = parsed["exit_code"].as_i64().unwrap_or(-1);
// Summary on stderr (safe under --json).
eprintln!("\n--- Job {} (exit code: {}) ---", status, exit_code);
}
break;
@@ -433,9 +446,12 @@
}
}
Ok(())
Ok(logs)
}
async fn cancel(id: &str, repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
async fn cancel(
id: &str,
repo: Option<&str>,
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -454,13 +470,20 @@
let short_id = resp.get("short_id").and_then(|v| v.as_str()).unwrap_or(id);
output::success(&format!("Cancelled CI run {short_id} (status: {status})"));
// Unwrap the server's envelope (ci_run / data) to echo the run object,
// mirroring `trigger` — otherwise a wrapped body double-nests under "run".
let run_obj = resp
.get("ci_run")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or(resp);
Ok(())
Ok(output::Response::ok("run", run_obj))
}
async fn trigger(
repo: Option<&str>,
sha: Option<&str>,
branch: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -516,10 +539,16 @@
output::detail("Branch", &branch_name);
output::detail("Commit", &display_sha[..8.min(display_sha.len())]);
Ok(())
// Unwrap the server's envelope (ci_run / data) to echo the run object.
let run_obj = resp
.get("ci_run")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or(resp);
Ok(output::Response::ok("run", run_obj))
}
async fn list_secrets(repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
async fn list_secrets(repo: Option<&str>) -> Result<output::Response, 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}/ci/secrets")).await?;
@@ -548,7 +577,7 @@
})
.collect();
output::print_table(&["NAME", "ENVIRONMENT", "UPDATED"], &rows);
Ok(())
Ok(output::Response::items(secrets))
}
async fn set_secret(
@@ -556,29 +585,35 @@
secret_name: &str,
value: &str,
env: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let mut payload = serde_json::json!({"name": secret_name, "value": value});
if let Some(e) = env {
payload["environment"] = serde_json::json!(e);
}
let _resp: serde_json::Value = client
let resp: serde_json::Value = client
.post(&format!("/{org}/{name}/ci/secrets"), &payload)
.await?;
output::success(&format!("Set secret '{secret_name}'"));
Ok(())
// Unwrap the server's envelope if present; echo the secret object.
let secret_obj = resp.get("secret").cloned().unwrap_or(resp);
Ok(output::Response::ok("secret", secret_obj))
}
async fn delete_secret(
repo: Option<&str>,
secret_name: &str,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
client
.delete_empty(&format!("/{org}/{name}/ci/secrets/{secret_name}"))
.await?;
output::success(&format!("Deleted secret '{secret_name}'"));
Ok(())
// No response body from delete_empty; synthesize a confirmation.
Ok(output::Response::ok(
"secret",
serde_json::json!({ "name": secret_name, "deleted": true }),
))
}
▸
src/commands/commit.rs
+26
−11
@@ -46,7 +46,7 @@
name: Option<String>,
}
pub async fn run(args: CommitArgs) -> Result<(), Box<dyn std::error::Error>> {
pub async fn run(args: CommitArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
match args.command {
CommitCommand::List {
repo,
@@ -57,7 +57,7 @@
}
}
async fn view(sha: &str, diff: bool) -> Result<output::Response, Box<dyn std::error::Error>> {
async fn view(sha: &str, diff: bool) -> Result<(), Box<dyn std::error::Error>> {
// v1: just shell out to `git show` — works in any cloned repo. The
// server endpoint for commit metadata exists but no diff endpoint;
// local `git show` covers both metadata and patch in one shot.
@@ -68,17 +68,37 @@
}
args.push(sha);
let format = if diff { "diff" } else { "log" };
// Under --json we cannot inherit stdout (it is the JSON data channel), so
// capture the child output and fold it into the Response. In human mode we
// keep inheriting so the terminal gets git's own coloring/paging.
if output::is_json() {
let out = std::process::Command::new("git").args(&args).output()?;
if !out.status.success() {
return Err(format!("git show {sha} failed").into());
}
let content = String::from_utf8_lossy(&out.stdout).into_owned();
return Ok(output::Response::read(serde_json::json!({
"content": content,
"format": format,
})));
}
let status = std::process::Command::new("git").args(&args).status()?;
if !status.success() {
return Err(format!("git show {sha} failed").into());
}
Ok(output::Response::read(serde_json::json!({
"content": serde_json::Value::Null,
"format": format,
Ok(())
})))
}
async fn list(
repo: Option<&str>,
ref_name: &str,
limit: u32,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -90,15 +110,10 @@
)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let commits: Vec<Commit> = if let Some(arr) = resp.get("commits") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else {
serde_json::from_value(resp).unwrap_or_default()
serde_json::from_value(resp.clone()).unwrap_or_default()
};
output::header(&format!("Commits ({org}/{name}) — {ref_name}"));
@@ -137,7 +152,7 @@
output::print_table(&["SHA", "MESSAGE", "AUTHOR", "DATE"], &rows);
Ok(())
Ok(output::Response::read(resp))
}
#[cfg(test)]
▸
src/commands/deploy.rs
+42
−16
@@ -67,7 +67,7 @@
},
}
pub async fn run(args: DeployArgs) -> Result<(), Box<dyn std::error::Error>> {
pub async fn run(args: DeployArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
match args.command {
DeployCommand::List { repo, env } => list(repo.as_deref(), &env).await,
DeployCommand::Create {
@@ -84,6 +84,9 @@
}
}
async fn list(repo: Option<&str>, env: &str) -> Result<(), Box<dyn std::error::Error>> {
async fn list(
repo: Option<&str>,
env: &str,
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -97,7 +100,7 @@
} else if let Some(arr) = resp.get("data") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else {
serde_json::from_value(resp.clone()).unwrap_or_default()
serde_json::from_value(resp).unwrap_or_default()
};
output::header(&format!("Deployments ({org}/{name})"));
@@ -125,7 +128,7 @@
output::print_table(&["ENV", "STATUS", "REF", "CREATED"], &rows);
Ok(())
Ok(output::Response::read(resp))
}
async fn create(
@@ -133,11 +136,11 @@
env: &str,
deploy_ref: &str,
description: &str,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
let _resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/environments/{env}/deployments"),
&serde_json::json!({
@@ -150,9 +153,15 @@
output::success(&format!("Created deployment to {env}"));
output::detail("Ref", deploy_ref);
Ok(())
Ok(output::Response::ok(
"deployment",
resp.get("deployment")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or(resp),
))
}
async fn status(repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
async fn status(repo: Option<&str>) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -162,11 +171,19 @@
.await?;
output::header(&format!("Deployment status ({org}/{name})"));
if let Some(obj) = resp.as_object() {
println!("{}", serde_json::to_string_pretty(&resp)?);
for (key, value) in obj {
let rendered = match value {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
output::detail(key, &rendered);
}
}
Ok(())
Ok(output::Response::read(resp))
}
async fn env_list(repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
async fn env_list(repo: Option<&str>) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -178,7 +195,7 @@
} else if let Some(arr) = resp.get("data") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else {
serde_json::from_value(resp).unwrap_or_default()
serde_json::from_value(resp.clone()).unwrap_or_default()
};
output::header(&format!("Environments ({org}/{name})"));
@@ -196,14 +213,17 @@
output::print_table(&["NAME"], &rows);
Ok(())
Ok(output::Response::read(resp))
}
async fn env_create(
async fn env_create(repo: Option<&str>, name: &str) -> Result<(), Box<dyn std::error::Error>> {
repo: Option<&str>,
name: &str,
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, repo_name) = config::resolve_repo(repo)?;
let _resp: serde_json::Value = client
let resp: serde_json::Value = client
.post(
&format!("/{org}/{repo_name}/environments"),
&serde_json::json!({ "name": name }),
@@ -212,5 +232,11 @@
output::success(&format!("Created environment {name}"));
Ok(())
Ok(output::Response::ok(
"environment",
resp.get("environment")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or(resp),
))
}
▸
src/commands/epic.rs
+111
−79
@@ -107,11 +107,15 @@
kind: Option<String>,
}
// Shapes mirror the server's `GET /issues/:n/links` grouped payload:
// `{"links": {"children": [{"link_id", "kind", "issue": {org, repo, number,
// title, state}}, ...], ...}}` (AnvilWeb.Api.V1.IssueLinkController).
#[derive(Debug, Deserialize)]
struct LinkEntry {
id: Option<String>,
link_id: Option<String>,
#[allow(dead_code)]
kind: Option<String>,
target_issue: Option<TargetIssue>,
issue: Option<TargetIssue>,
}
#[derive(Debug, Deserialize)]
@@ -119,16 +123,11 @@
number: Option<u32>,
title: Option<String>,
state: Option<String>,
repository: Option<RepoRef>,
}
#[derive(Debug, Deserialize)]
struct RepoRef {
org_slug: Option<String>,
slug: Option<String>,
org: Option<String>,
repo: Option<String>,
}
pub async fn run(args: EpicArgs) -> Result<(), Box<dyn std::error::Error>> {
pub async fn run(args: EpicArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
match args.command {
EpicCommand::List { repo, state, limit } => list(repo.as_deref(), &state, limit).await,
EpicCommand::View { number, repo } => view(repo.as_deref(), number).await,
@@ -157,6 +156,6 @@
repo: Option<&str>,
state: &str,
limit: u32,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -172,24 +171,21 @@
)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let issues: Vec<EpicSummary> = if let Some(arr) = resp.get("issues") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else if let Some(arr) = resp.get("data") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else {
serde_json::from_value(resp).unwrap_or_default()
serde_json::from_value(resp.clone()).unwrap_or_default()
};
output::header(&format!("Epics ({org}/{name}) — {state}"));
if issues.is_empty() {
println!("\n No epics yet. Create one with: anvil issue create --epic --title \"...\"");
return Ok(());
output::line(
"\n No epics yet. Create one with: anvil issue create --epic --title \"...\"",
);
return Ok(output::Response::read(resp));
}
let rows: Vec<Vec<String>> = issues
@@ -206,9 +202,22 @@
output::print_table(&["#", "TITLE", "STATE"], &rows);
Ok(output::Response::read(resp))
Ok(())
}
/// Build a JSON view of a child link's target issue for the `--json` payload.
fn child_json(t: &TargetIssue) -> serde_json::Value {
serde_json::json!({
"ref": short_ref(t),
"number": t.number,
"title": t.title,
"state": t.state,
})
}
async fn view(
repo: Option<&str>,
async fn view(repo: Option<&str>, number: u32) -> Result<(), Box<dyn std::error::Error>> {
number: u32,
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -217,11 +226,6 @@
.get(&format!("/{org}/{name}/issues/{number}"))
.await?;
if output::is_json() {
output::print_json(&issue);
return Ok(());
}
let title = issue
.get("title")
.and_then(|v| v.as_str())
@@ -236,27 +240,37 @@
output::detail("State", &output::colorize_status(state));
if kind != "epic" {
println!(
output::line(&format!(
"\n Note: this issue's kind is \"{kind}\", not \"epic\". \
Run `anvil epic mark {number}` to promote it."
));
);
}
// Fetch children/progress unconditionally — the JSON payload must include
// them too, not just the bare issue object.
let children_links = fetch_children(&client, &org, &name, number).await?;
let total = children_links.len();
let closed = children_links
.iter()
.filter(|l| l.target_issue.as_ref().and_then(|i| i.state.as_deref()) == Some("closed"))
.filter(|l| l.issue.as_ref().and_then(|i| i.state.as_deref()) == Some("closed"))
.count();
let pct = (100 * closed).checked_div(total).unwrap_or(0);
output::line(&format!(
"\n Progress: {closed} of {total} closed ({pct}%)"
));
let children_json: Vec<serde_json::Value> = children_links
.iter()
.filter_map(|l| l.issue.as_ref())
.map(child_json)
println!("\n Progress: {closed} of {total} closed ({pct}%)");
.collect();
if !children_links.is_empty() {
let rows: Vec<Vec<String>> = children_links
.iter()
.filter_map(|l| l.target_issue.as_ref())
.filter_map(|l| l.issue.as_ref())
.map(|t| {
vec![
short_ref(t),
@@ -266,27 +280,47 @@
})
.collect();
output::line("");
println!();
output::print_table(&["REF", "TITLE", "STATE"], &rows);
}
// Echo the server issue augmented with the client-computed progress and
// children so `--json` consumers get the full epic view in one document.
let mut payload = issue;
if let Some(obj) = payload.as_object_mut() {
obj.insert(
"progress".to_string(),
serde_json::json!({ "closed": closed, "total": total, "percent": pct }),
);
obj.insert("children".to_string(), serde_json::json!(children_json));
}
Ok(output::Response::read(payload))
Ok(())
}
async fn children(
repo: Option<&str>,
async fn children(repo: Option<&str>, number: u32) -> Result<(), Box<dyn std::error::Error>> {
number: u32,
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let links = fetch_children(&client, &org, &name, number).await?;
let items: Vec<serde_json::Value> = links
.iter()
.filter_map(|l| l.issue.as_ref())
.map(child_json)
.collect();
if links.is_empty() {
println!("Epic #{number} has no children.");
return Ok(());
output::line(&format!("Epic #{number} has no children."));
return Ok(output::Response::items(items));
}
let rows: Vec<Vec<String>> = links
.iter()
.filter_map(|l| l.target_issue.as_ref())
.filter_map(|l| l.issue.as_ref())
.map(|t| {
vec![
short_ref(t),
@@ -298,7 +332,7 @@
output::print_table(&["REF", "TITLE", "STATE"], &rows);
Ok(output::Response::items(items))
Ok(())
}
/// Build the issue-link payload for `add-child`. The child may be a bare
@@ -330,12 +364,12 @@
repo: Option<&str>,
epic_number: u32,
child: &str,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
// Reuse the existing issue link endpoint with kind=parent_of.
let _resp: serde_json::Value = client
let resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/issues/{epic_number}/links"),
&add_child_payload(child)?,
@@ -343,13 +377,13 @@
.await?;
output::success(&format!("Added child {child} to epic #{epic_number}"));
Ok(output::Response::ok("child", resp))
Ok(())
}
async fn remove_child(
repo: Option<&str>,
epic_number: u32,
child: &str,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -358,13 +392,13 @@
let links = fetch_children(&client, &org, &name, epic_number).await?;
let target = links.iter().find(|l| {
l.target_issue
l.issue
.as_ref()
.map(|t| matches_child_ref(t, child))
.unwrap_or(false)
});
let link_id = match target.and_then(|l| l.link_id.as_deref()) {
let link_id = match target.and_then(|l| l.id.as_deref()) {
Some(id) => id,
None => {
return Err(format!("No parent_of link from epic #{epic_number} to {child}").into());
@@ -378,7 +412,10 @@
.await?;
output::success(&format!("Removed child {child} from epic #{epic_number}"));
Ok(())
Ok(output::Response::ok(
"child",
serde_json::json!({ "epic": epic_number, "child": child, "link_id": link_id }),
))
}
async fn set_auto_close(
@@ -386,7 +423,7 @@
number: u32,
on: bool,
off: bool,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
if !on && !off {
return Err("Pass --on or --off".into());
}
@@ -395,7 +432,7 @@
let (org, name) = config::resolve_repo(repo)?;
let value = on && !off;
let resp: serde_json::Value = client
let _resp: serde_json::Value = client
.patch(
&format!("/{org}/{name}/issues/{number}"),
&serde_json::json!({"auto_close_on_complete": value}),
@@ -404,20 +441,20 @@
let label = if value { "on" } else { "off" };
output::success(&format!("Set auto-close {label} for epic #{number}"));
Ok(())
Ok(output::Response::ok("epic", resp))
}
async fn mark(
repo: Option<&str>,
number: u32,
unmark: bool,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let new_kind = if unmark { "standard" } else { "epic" };
let resp: serde_json::Value = client
let _resp: serde_json::Value = client
.patch(
&format!("/{org}/{name}/issues/{number}"),
&serde_json::json!({"kind": new_kind}),
@@ -426,7 +463,13 @@
let verb = if unmark { "Demoted" } else { "Marked" };
output::success(&format!("{verb} issue #{number} as {new_kind}"));
Ok(output::Response::ok(
"issue",
resp.get("issue")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or(resp),
Ok(())
))
}
async fn fetch_children(
@@ -439,27 +482,22 @@
.get(&format!("/{org}/{name}/issues/{number}/links"))
.await?;
let raw = resp
.get("links")
.or_else(|| resp.get("data"))
// The server groups links by relationship; the children of this epic live
// under `links.children` already — no client-side kind filter needed.
let children = resp
.pointer("/links/children")
.or_else(|| resp.pointer("/data/children"))
.cloned()
.unwrap_or(resp);
let entries: Vec<LinkEntry> = serde_json::from_value(raw).unwrap_or_default();
.unwrap_or_else(|| serde_json::Value::Array(vec![]));
Ok(entries
.into_iter()
.filter(|l| l.kind.as_deref() == Some("parent_of"))
.collect())
let entries: Vec<LinkEntry> = serde_json::from_value(children).unwrap_or_default();
Ok(entries)
}
fn short_ref(t: &TargetIssue) -> String {
let n = t.number.unwrap_or(0);
match t.repository.as_ref() {
Some(RepoRef {
match (t.org.as_deref(), t.repo.as_deref()) {
(Some(o), Some(r)) => format!("{o}/{r}#{n}"),
org_slug: Some(o),
slug: Some(r),
}) => format!("{o}/{r}#{n}"),
_ => format!("#{n}"),
}
}
@@ -472,13 +510,8 @@
return true;
}
if let Some(RepoRef {
org_slug: Some(o),
slug: Some(r),
}) = t.repository.as_ref()
{
format!("{o}/{r}#{n}") == child_ref
match (t.org.as_deref(), t.repo.as_deref()) {
(Some(o), Some(r)) => format!("{o}/{r}#{n}") == child_ref,
_ => false,
} else {
false
}
}
@@ -492,9 +525,7 @@
number: Some(number),
title: None,
state: None,
repository: Some(RepoRef {
org_slug: org.map(String::from),
org: org.map(String::from),
repo: repo.map(String::from),
slug: repo.map(String::from),
}),
}
}
@@ -526,7 +557,8 @@
number: Some(7),
title: None,
state: None,
org: None,
repository: None,
repo: None,
};
assert_eq!(short_ref(&t), "#7");
}
▸
src/commands/issue.rs
+117
−91
@@ -257,7 +257,7 @@
color: Option<String>,
}
pub async fn run(args: IssueArgs) -> Result<(), Box<dyn std::error::Error>> {
pub async fn run(args: IssueArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
match args.command {
IssueCommand::List { repo, state, limit } => list(repo.as_deref(), &state, limit).await,
IssueCommand::View { number, repo } => view(repo.as_deref(), number).await,
@@ -339,6 +339,6 @@
repo: Option<&str>,
state: &str,
limit: u32,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -350,17 +350,12 @@
)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let issues: Vec<Issue> = if let Some(arr) = resp.get("issues") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else if let Some(arr) = resp.get("data") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else {
serde_json::from_value(resp).unwrap_or_default()
serde_json::from_value(resp.clone()).unwrap_or_default()
};
output::header(&format!("Issues ({org}/{name}) — {state}"));
@@ -394,9 +389,12 @@
output::print_table(&["#", "TITLE", "STATE", "AUTHOR", "LABELS"], &rows);
Ok(output::Response::read(resp))
Ok(())
}
async fn view(
repo: Option<&str>,
number: u32,
) -> Result<output::Response, Box<dyn std::error::Error>> {
async fn view(repo: Option<&str>, number: u32) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -405,17 +403,12 @@
.get(&format!("/{org}/{name}/issues/{number}"))
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let issue: Issue = if let Some(obj) = resp.get("issue") {
serde_json::from_value(obj.clone())?
} else if let Some(obj) = resp.get("data") {
serde_json::from_value(obj.clone())?
} else {
serde_json::from_value(resp)?
serde_json::from_value(resp.clone())?
};
output::header(&format!(
@@ -449,11 +442,11 @@
}
if let Some(ref body) = issue.body {
if !body.is_empty() {
println!("\n{body}");
output::line(&format!("\n{body}"));
}
}
Ok(())
Ok(output::Response::read(resp))
}
async fn create(
@@ -462,6 +455,6 @@
body: &str,
epic: bool,
parent: Option<&str>,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -499,39 +492,63 @@
let config = crate::config::Config::load()?;
if let Some(ref url) = config.server_url {
output::line(&format!(
println!(
"\n {}",
format!("{url}/{org}/{name}/issues/{number}").as_str()
);
));
}
Ok(())
Ok(output::Response::ok(
"issue",
resp.get("issue")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or(resp),
))
}
async fn close_issue(
repo: Option<&str>,
number: u32,
async fn close_issue(repo: Option<&str>, number: u32) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let _resp: serde_json::Value = client
let resp: serde_json::Value = client
.patch(
&format!("/{org}/{name}/issues/{number}"),
&serde_json::json!({"state": "closed"}),
)
.await?;
output::success(&format!("Closed issue #{number}"));
Ok(output::Response::ok(
Ok(())
"issue",
resp.get("issue")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or(resp),
))
}
async fn reopen_issue(
async fn reopen_issue(repo: Option<&str>, number: u32) -> Result<(), Box<dyn std::error::Error>> {
repo: Option<&str>,
number: u32,
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
let _resp: serde_json::Value = client
.patch(
&format!("/{org}/{name}/issues/{number}"),
&serde_json::json!({"state": "open"}),
)
.await?;
output::success(&format!("Reopened issue #{number}"));
Ok(output::Response::ok(
"issue",
resp.get("issue")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or(resp),
))
Ok(())
}
async fn edit_issue(
@@ -539,6 +556,6 @@
number: u32,
title: Option<&str>,
body: Option<&str>,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -553,10 +570,14 @@
if payload.is_empty() {
output::warn("Nothing to update — specify --title or --body");
return Ok(());
// Never zero output: emit a well-formed mutation envelope for JSON.
return Ok(output::Response::ok(
"issue",
serde_json::json!({ "number": number, "updated": false }),
));
}
let resp: serde_json::Value = client
let _resp: serde_json::Value = client
.patch(
&format!("/{org}/{name}/issues/{number}"),
&serde_json::Value::Object(payload),
@@ -564,38 +585,42 @@
.await?;
output::success(&format!("Updated issue #{number}"));
Ok(output::Response::ok(
"issue",
Ok(())
resp.get("issue")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or(resp),
))
}
async fn add_comment(
repo: Option<&str>,
number: u32,
body: &str,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let _resp: serde_json::Value = client
let resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/issues/{number}/comments"),
&serde_json::json!({"body": body}),
)
.await?;
output::success(&format!("Added comment to issue #{number}"));
Ok(())
Ok(output::Response::ok("comment", resp))
}
async fn list_comments(
async fn list_comments(repo: Option<&str>, number: u32) -> Result<(), Box<dyn std::error::Error>> {
repo: Option<&str>,
number: u32,
) -> Result<output::Response, 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}/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())
@@ -604,8 +629,8 @@
output::header(&format!("Comments on issue #{number}"));
if comments.is_empty() {
output::line(" (no comments)");
println!(" (no comments)");
return Ok(());
return Ok(output::Response::read(resp));
}
for comment in &comments {
@@ -619,13 +644,15 @@
.map(output::format_time)
.unwrap_or_default();
let body = comment.get("body").and_then(|v| v.as_str()).unwrap_or("");
println!("\n {} — {}", author, time);
println!(" {body}");
output::line(&format!("\n {} — {}", author, time));
output::line(&format!(" {body}"));
}
Ok(output::Response::read(resp))
Ok(())
}
async fn list_milestones(repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
async fn list_milestones(
repo: Option<&str>,
) -> Result<output::Response, Box<dyn std::error::Error>> {
output::warn("`anvil issue milestones` is deprecated; use `anvil milestone list` instead.");
super::milestone::run(super::milestone::MilestoneArgs {
command: super::milestone::MilestoneCommand::List {
@@ -640,7 +667,7 @@
title: &str,
description: Option<&str>,
due_date: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
output::warn(
"`anvil issue create-milestone` is deprecated; use `anvil milestone create` instead.",
);
@@ -651,24 +678,24 @@
repo: Option<&str>,
number: u32,
user_id: &str,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
let _resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/issues/{number}/assignees"),
&serde_json::json!({"user_id": user_id}),
)
.await?;
output::success(&format!("Assigned user to issue #{number}"));
Ok(())
Ok(output::Response::ok("assignee", resp))
}
async fn unassign(
repo: Option<&str>,
number: u32,
user_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
client
@@ -677,7 +704,10 @@
))
.await?;
output::success(&format!("Unassigned user from issue #{number}"));
Ok(output::Response::ok(
"unassigned",
serde_json::json!({ "issue": number, "user_id": user_id }),
Ok(())
))
}
// ── Links ────────────────────────────────────────────────────────────────
@@ -758,20 +788,18 @@
})
}
async fn list_links(
async fn list_links(repo: Option<&str>, number: u32) -> Result<(), Box<dyn std::error::Error>> {
repo: Option<&str>,
number: u32,
) -> Result<output::Response, Box<dyn std::error::Error>> {
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(());
// Fetch the raw envelope once: it's echoed verbatim under --json, and also
// deserialized into the typed groups for the human table.
let raw: serde_json::Value = client
}
let resp: LinksResponse = client
.get(&format!("/{org}/{name}/issues/{number}/links"))
.await?;
let resp: LinksResponse = serde_json::from_value(raw.clone())?;
output::header(&format!("Links on {org}/{name}#{number}"));
@@ -795,16 +823,16 @@
continue;
}
any_printed = true;
output::line(&format!("\n {label}"));
println!("\n {label}");
let rows: Vec<Vec<String>> = entries.iter().map(|e| render_link_row(e)).collect();
output::print_table(&["LINK ID", "TARGET", "STATE", "TITLE"], &rows);
}
if !any_printed {
println!("\n (no links)");
output::line("\n (no links)");
}
Ok(())
Ok(output::Response::read(raw))
}
fn render_link_row(entry: &LinkEntry) -> Vec<String> {
@@ -841,7 +869,7 @@
number: u32,
kind: LinkKind,
target: &str,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let parsed = parse_target_ref(target)?;
@@ -878,13 +906,13 @@
kind.api_value()
));
output::detail("Link ID", link_id);
Ok(output::Response::ok("link", resp))
Ok(())
}
async fn remove_link(
repo: Option<&str>,
number: u32,
link_id: &str,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -896,14 +924,14 @@
output::success(&format!(
"Removed link {link_id} from {org}/{name}#{number}"
));
Ok(())
Ok(output::Response::deleted(link_id))
}
async fn move_issue(
repo: Option<&str>,
number: u32,
column: &str,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
@@ -912,19 +940,21 @@
&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(output::Response::ok(
"issue",
Ok(())
resp.get("issue")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or(resp),
))
}
async fn link_requirement(
repo: Option<&str>,
number: u32,
requirement_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
@@ -933,19 +963,19 @@
&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}"));
// The server may omit the `linked` key — fall back to echoing the whole body.
Ok(())
Ok(output::Response::ok(
"linked",
resp.get("linked").cloned().unwrap_or(resp),
))
}
async fn unlink_requirement(
repo: Option<&str>,
number: u32,
requirement_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
client
@@ -953,15 +983,11 @@
"/{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(output::Response::ok(
Ok(())
"unlinked",
serde_json::json!({ "issue": number, "requirement_id": requirement_id }),
))
}
#[cfg(test)]
▸
src/commands/label.rs
+29
−44
@@ -91,7 +91,7 @@
description: Option<String>,
}
pub async fn run(args: LabelArgs) -> Result<(), Box<dyn std::error::Error>> {
pub async fn run(args: LabelArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
match args.command {
LabelCommand::List { repo } => list(repo.as_deref()).await,
LabelCommand::Create {
@@ -122,14 +122,10 @@
}
}
async fn list(repo: Option<&str>) -> Result<output::Response, Box<dyn std::error::Error>> {
async fn list(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}/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())
@@ -146,7 +142,7 @@
})
.collect();
output::print_table(&["NAME", "COLOR", "DESCRIPTION"], &rows);
Ok(())
Ok(output::Response::read(resp))
}
async fn create(
@@ -154,7 +150,7 @@
label_name: &str,
color: &str,
description: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let mut payload = serde_json::json!({"name": label_name, "color": color});
@@ -164,12 +160,11 @@
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(output::Response::ok(
"label",
resp.get("label").cloned().unwrap_or(resp),
Ok(())
))
}
async fn edit(
@@ -178,7 +173,7 @@
new_name: Option<&str>,
color: Option<&str>,
description: Option<&str>,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let mut payload = serde_json::json!({});
@@ -196,60 +191,54 @@
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(())
Ok(output::Response::ok(
"label",
resp.get("label").cloned().unwrap_or(resp),
))
}
async fn delete(
async fn delete(repo: Option<&str>, label_name: &str) -> Result<(), Box<dyn std::error::Error>> {
repo: Option<&str>,
label_name: &str,
) -> Result<output::Response, 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(output::Response::deleted(label_name))
Ok(())
}
async fn add(
repo: Option<&str>,
issue_number: u32,
label_name: &str,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let _resp: serde_json::Value = client
let resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/issues/{issue_number}/labels"),
&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}"
));
Ok(())
Ok(output::Response::ok(
"label",
resp.get("label").cloned().unwrap_or(resp),
))
}
async fn remove(
repo: Option<&str>,
issue_number: u32,
label_name: &str,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> 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();
@@ -258,15 +247,11 @@
"/{org}/{name}/issues/{issue_number}/labels/{encoded}"
))
.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}"
));
Ok(output::Response::ok(
Ok(())
"label_removed",
serde_json::json!({"issue": issue_number, "name": label_name}),
))
}
▸
src/commands/milestone.rs
+31
−55
@@ -100,7 +100,7 @@
percent: Option<f64>,
}
pub async fn run(args: MilestoneArgs) -> Result<(), Box<dyn std::error::Error>> {
pub async fn run(args: MilestoneArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
match args.command {
MilestoneCommand::List { repo } => list(repo.as_deref()).await,
MilestoneCommand::View { id, repo } => view(&id, repo.as_deref()).await,
@@ -138,7 +138,7 @@
title: Option<&str>,
description: Option<&str>,
due_date: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let mut payload = serde_json::json!({});
@@ -154,19 +154,18 @@
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(output::Response::ok(
Ok(())
"milestone",
resp.get("milestone").cloned().unwrap_or(resp),
))
}
async fn set_state(
repo: Option<&str>,
id: &str,
verb: &str,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
@@ -175,27 +174,25 @@
&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(output::Response::ok(
"milestone",
resp.get("milestone").cloned().unwrap_or(resp),
Ok(())
))
}
async fn delete(
repo: Option<&str>,
id: &str,
) -> Result<output::Response, Box<dyn std::error::Error>> {
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(())
Ok(output::Response::deleted(id))
}
pub async fn create(
@@ -203,6 +200,6 @@
title: &str,
description: &str,
due_date: Option<&str>,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -219,11 +216,6 @@
.post(&format!("/{org}/{name}/milestones"), &payload)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let short_id = resp
.pointer("/milestone/short_id")
.or_else(|| resp.pointer("/data/short_id"))
@@ -231,29 +223,19 @@
.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(output::Response::ok(
"milestone",
resp.get("milestone").cloned().unwrap_or(resp),
Ok(())
))
}
async fn list(repo: Option<&str>) -> Result<output::Response, Box<dyn std::error::Error>> {
async fn list(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}/milestones")).await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let milestones: Vec<Milestone> = resp
.get("milestones")
.and_then(|v| serde_json::from_value(v.clone()).ok())
@@ -263,7 +245,7 @@
if milestones.is_empty() {
output::info("No milestones found.");
return Ok(output::Response::read(resp));
return Ok(());
}
let rows: Vec<Vec<String>> = milestones
@@ -285,31 +267,25 @@
.collect();
output::print_table(&["ID", "TITLE", "STATE", "PROGRESS", "DUE"], &rows);
Ok(output::Response::read(resp))
Ok(())
}
async fn view(
async fn view(id: &str, repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
id: &str,
repo: Option<&str>,
) -> Result<output::Response, 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}/milestones/{id}"))
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let m: Milestone = resp
.get("milestone")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_else(|| {
serde_json::from_value(resp).unwrap_or(Milestone {
serde_json::from_value(resp.clone()).unwrap_or(Milestone {
short_id: None,
title: None,
description: None,
@@ -345,11 +321,11 @@
if let Some(ref desc) = m.description {
if !desc.is_empty() {
output::header("Description");
output::line(desc);
println!("{}", desc);
}
}
Ok(())
Ok(output::Response::read(resp))
}
#[cfg(test)]
▸
src/commands/mod.rs
+38
−26
@@ -27,11 +27,9 @@
version = include_str!(concat!(env!("OUT_DIR"), "/version.txt"))
)]
pub struct Cli {
/// 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.
/// Emit machine-readable JSON on stdout instead of human tables. Reads print
/// the payload; mutations print {"ok":true, …}; errors print
/// {"ok":false,"error": …}. Diagnostics and progress go to stderr. For
/// scripting and agent tooling.
#[arg(long, global = true)]
pub json: bool,
@@ -86,27 +84,41 @@
pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
crate::output::set_json_mode(cli.json);
match cli.command {
Command::Auth(args) => auth::run(args).await,
Command::Repo(args) => repo::run(args).await,
Command::Pr(args) => pr::run(args).await,
Command::Issue(args) => issue::run(args).await,
Command::Epic(args) => epic::run(args).await,
Command::Ci(args) => ci::run(args).await,
Command::Commit(args) => commit::run(args).await,
Command::Branch(args) => branch::run(args).await,
Command::Release(args) => release::run(args).await,
Command::Registry(args) => registry::run(args).await,
Command::Requirement(args) => requirement::run(*args).await,
Command::Deploy(args) => deploy::run(args).await,
Command::Agent(args) => agent::run(args).await,
// Every leaf returns a Response; this is the single point that writes it to
// stdout under --json. Human output already happened in the leaf via the
// self-suppressing output:: helpers.
let resp = match cli.command {
Command::Auth(args) => auth::run(args).await?,
Command::Repo(args) => repo::run(args).await?,
Command::Pr(args) => pr::run(args).await?,
Command::Issue(args) => issue::run(args).await?,
Command::Epic(args) => epic::run(args).await?,
Command::Ci(args) => ci::run(args).await?,
Command::Commit(args) => commit::run(args).await?,
Command::Branch(args) => branch::run(args).await?,
Command::Release(args) => release::run(args).await?,
Command::Registry(args) => registry::run(args).await?,
Command::Requirement(args) => requirement::run(*args).await?,
Command::Deploy(args) => deploy::run(args).await?,
Command::Agent(args) => agent::run(args).await?,
Command::Runner(args) => runner::run(args)
.await
.map_err(|e| -> Box<dyn std::error::Error> { e }),
Command::Label(args) => label::run(args).await,
Command::SshKey(args) => ssh_key::run(args).await,
Command::Board(args) => board::run(args).await,
Command::Milestone(args) => milestone::run(args).await,
Command::Update(args) => update::run(args).await,
.map_err(|e| -> Box<dyn std::error::Error> { e })?,
Command::Label(args) => label::run(args).await?,
Command::SshKey(args) => ssh_key::run(args).await?,
Command::Board(args) => board::run(args).await?,
Command::Milestone(args) => milestone::run(args).await?,
Command::Update(args) => update::run(args).await?,
};
crate::output::emit(&resp);
// A gate (e.g. `requirement status --strict`) emits its JSON, then exits
// nonzero with a diagnostic on stderr.
if let Some((code, msg)) = resp.exit {
crate::output::error(&msg);
std::process::exit(code);
}
Ok(())
}
▸
src/commands/pr.rs
+113
−54
@@ -192,7 +192,7 @@
data: Option<Vec<PullRequest>>,
}
pub async fn run(args: PrArgs) -> Result<(), Box<dyn std::error::Error>> {
pub async fn run(args: PrArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
match args.command {
PrCommand::List { repo, state, limit } => list(repo.as_deref(), &state, limit).await,
PrCommand::View { number, repo } => view(repo.as_deref(), number).await,
@@ -284,7 +284,7 @@
number: u32,
name_only: bool,
remote: &str,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let (base, head) = resolve_pr_branches(repo, number).await?;
// Fetch both branches so the diff is against the server's current view.
@@ -301,19 +301,38 @@
}
// Three-dot syntax: diff from the merge-base of remote/base..remote/head.
args.push(format!("{remote}/{base}...{remote}/{head}"));
let fmt = if name_only { "name-only" } else { "diff" };
// Under --json we cannot inherit stdout — capture the patch and echo it as
// a JSON payload. In human mode, stream it straight through as before.
if output::is_json() {
let out = std::process::Command::new("git").args(&args).output()?;
if !out.status.success() {
return Err("git diff failed".into());
}
let content = String::from_utf8_lossy(&out.stdout).to_string();
return Ok(output::Response::read(serde_json::json!({
"content": content,
"format": fmt,
})));
}
let status = std::process::Command::new("git").args(&args).status()?;
if !status.success() {
return Err("git diff failed".into());
}
Ok(())
Ok(output::Response::read(serde_json::json!({
"content": serde_json::Value::Null,
"format": fmt,
})))
}
async fn checkout(
repo: Option<&str>,
number: u32,
remote: &str,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>> {
let (_base, head) = resolve_pr_branches(repo, number).await?;
// Refuse to clobber uncommitted changes — match `gh pr checkout` behavior.
@@ -332,21 +351,35 @@
}
// Create or update the local branch tracking remote/head, then check it out.
// Under --json we cannot inherit stdout — capture the checkout output so
// nothing leaks into the JSON document; in human mode inherit as before.
let checkout_args = ["checkout", "-B", &head, &format!("{remote}/{head}")];
let checkout_ok = if output::is_json() {
let out = std::process::Command::new("git")
let checkout = std::process::Command::new("git")
.args(["checkout", "-B", &head, &format!("{remote}/{head}")])
.status()?;
if !checkout.success() {
.args(checkout_args)
.output()?;
out.status.success()
} else {
std::process::Command::new("git")
.args(checkout_args)
.status()?
.success()
};
if !checkout_ok {
return Err(format!("git checkout {head} failed").into());
}
output::success(&format!("Checked out PR #{number} ({head})"));
Ok(output::Response::ok(
"checkout",
Ok(())
serde_json::json!({ "number": number, "branch": head }),
))
}
async fn list(
repo: Option<&str>,
state: &str,
limit: u32,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -358,17 +391,12 @@
)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let prs: Vec<PullRequest> = if let Some(arr) = resp.get("pull_requests") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else if let Some(arr) = resp.get("data") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else {
serde_json::from_value(resp).unwrap_or_default()
serde_json::from_value(resp.clone()).unwrap_or_default()
};
output::header(&format!("Pull requests ({org}/{name}) — {state}"));
@@ -401,20 +429,18 @@
output::print_table(&["#", "TITLE", "STATE", "AUTHOR", "BRANCHES"], &rows);
Ok(output::Response::read(resp))
Ok(())
}
async fn view(repo: Option<&str>, number: u32) -> Result<(), Box<dyn std::error::Error>> {
async fn view(
repo: Option<&str>,
number: u32,
) -> Result<output::Response, 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}/pulls/{number}")).await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let pr: PullRequest = if let Some(obj) = resp.get("pull_request") {
serde_json::from_value(obj.clone())?
} else if let Some(obj) = resp.get("data") {
@@ -462,11 +488,11 @@
}
if let Some(ref body) = pr.body {
if !body.is_empty() {
println!("\n{body}");
output::line(&format!("\n{body}"));
}
}
Ok(())
Ok(output::Response::read(resp))
}
/// Arguments for [`edit`]. Bundled into a struct so the function takes one
@@ -491,7 +517,7 @@
serde_json::from_value(obj.clone()).ok()
}
async fn edit(args: EditArgs<'_>) -> Result<output::Response, Box<dyn std::error::Error>> {
async fn edit(args: EditArgs<'_>) -> Result<(), Box<dyn std::error::Error>> {
let EditArgs {
repo,
number,
@@ -525,7 +551,12 @@
if payload.is_empty() {
output::warn("Nothing to update — specify --title, --body, --base, --draft, or --ready");
// Never zero output: still emit a well-formed mutation envelope so JSON
// consumers get `{"ok":true,"pull_request":{…}}` even on a no-op edit.
return Ok(output::Response::ok(
"pull_request",
serde_json::json!({ "number": number, "updated": false }),
));
return Ok(());
}
let resp: serde_json::Value = client
@@ -534,10 +565,5 @@
&serde_json::Value::Object(payload),
)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
output::success(&format!("Updated PR #{number}"));
@@ -566,39 +592,63 @@
output::detail("Draft", "no — marked ready for review");
}
Ok(output::Response::ok(
"pull_request",
resp.get("pull_request")
.or_else(|| resp.get("data"))
.cloned()
Ok(())
.unwrap_or(resp),
))
}
async fn close(repo: Option<&str>, number: u32) -> Result<(), Box<dyn std::error::Error>> {
async fn close(
repo: Option<&str>,
number: u32,
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let _resp: serde_json::Value = client
let resp: serde_json::Value = client
.patch(
&format!("/{org}/{name}/pulls/{number}"),
&serde_json::json!({"state": "closed"}),
)
.await?;
output::success(&format!("Closed PR #{number}"));
Ok(output::Response::ok(
"pull_request",
resp.get("pull_request")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or(resp),
Ok(())
))
}
async fn reopen(
repo: Option<&str>,
number: u32,
async fn reopen(repo: Option<&str>, number: u32) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
let _resp: serde_json::Value = client
.patch(
&format!("/{org}/{name}/pulls/{number}"),
&serde_json::json!({"state": "open"}),
)
.await?;
output::success(&format!("Reopened PR #{number}"));
Ok(output::Response::ok(
"pull_request",
resp.get("pull_request")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or(resp),
Ok(())
))
}
async fn merge(
repo: Option<&str>,
number: u32,
strategy: &str,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -619,7 +669,7 @@
output::success(&format!("Merged PR #{number} via {strategy}"));
output::detail("Merge SHA", merge_sha);
Ok(output::Response::ok("merge", resp))
Ok(())
}
async fn create(
@@ -628,6 +678,6 @@
body: &str,
base: &str,
head: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -665,16 +715,22 @@
output::detail("Base", base);
output::detail("Head", &head_branch);
// Print web URL (human only)
// Print web URL
let config = crate::config::Config::load()?;
if let Some(ref url) = config.server_url {
output::line(&format!(
println!(
"\n {}",
format!("{url}/{org}/{name}/pull/{number}").as_str()
));
);
}
Ok(output::Response::ok(
"pull_request",
resp.get("pull_request")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or(resp),
))
Ok(())
}
async fn submit_review(
@@ -682,10 +738,10 @@
number: u32,
action: &str,
body: &str,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let _resp: serde_json::Value = client
let resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/pulls/{number}/reviews"),
&serde_json::json!({"state": action, "body": body}),
@@ -697,10 +753,13 @@
_ => "Commented on",
};
output::success(&format!("{action_display} PR #{number}"));
Ok(())
Ok(output::Response::ok("review", resp))
}
async fn list_reviews(
async fn list_reviews(repo: Option<&str>, number: u32) -> Result<(), Box<dyn std::error::Error>> {
repo: Option<&str>,
number: u32,
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
@@ -738,7 +797,7 @@
})
.collect();
output::print_table(&["STATE", "AUTHOR", "BODY", "DATE"], &rows);
Ok(output::Response::items(reviews))
Ok(())
}
async fn add_pr_comment(
@@ -747,17 +806,17 @@
file: &str,
line: u32,
body: &str,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
let _resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/pulls/{number}/comments"),
&serde_json::json!({"file_path": file, "line_number": line, "body": body}),
)
.await?;
output::success(&format!("Added comment on {file}:{line} in PR #{number}"));
Ok(())
Ok(output::Response::ok("comment", resp))
}
#[cfg(test)]
▸
src/commands/registry.rs
+10
−10
@@ -64,7 +64,7 @@
id: String,
}
pub async fn run(args: RegistryArgs) -> Result<(), Box<dyn std::error::Error>> {
pub async fn run(args: RegistryArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
match args.command {
RegistryCommand::Token(t) => match t.command {
TokenCommand::Create(a) => create(a).await,
@@ -104,6 +104,6 @@
Ok(actions.iter().map(|a| format!("{a}:{target}")).collect())
}
async fn create(args: CreateArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
async fn create(args: CreateArgs) -> Result<(), Box<dyn std::error::Error>> {
let scopes = build_scopes(&args)?;
let client = Client::from_config()?;
@@ -119,10 +119,10 @@
output::success(&format!("Created registry token '{}'", args.name));
output::detail("Scopes", &scopes.join(", "));
output::detail("Token", token);
println!("\nThis is the only time the token is shown — store it now.");
Ok(())
output::line("\nThis is the only time the token is shown — store it now.");
Ok(output::Response::ok("token", resp))
}
async fn list() -> Result<(), Box<dyn std::error::Error>> {
async fn list() -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let resp: serde_json::Value = client.get("/registry/tokens").await?;
@@ -135,7 +135,7 @@
if tokens.is_empty() {
output::info("No registry tokens.");
return Ok(());
return Ok(output::Response::items(tokens));
}
for t in &tokens {
@@ -151,18 +151,18 @@
.join(", ")
})
.unwrap_or_default();
output::line(&format!("{id} {name} [{scopes}]"));
println!("{id} {name} [{scopes}]");
}
Ok(())
Ok(output::Response::items(tokens))
}
async fn delete(args: DeleteArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
async fn delete(args: DeleteArgs) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
client
.delete_empty(&format!("/registry/tokens/{}", args.id))
.await?;
output::success(&format!("Revoked registry token {}", args.id));
Ok(())
Ok(output::Response::deleted(&args.id))
}
#[cfg(test)]
▸
src/commands/release.rs
+115
−83
@@ -1,16 +1,10 @@
use crate::client::Client;
use crate::config;
use crate::output;
use clap::{Args, Subcommand, ValueEnum};
use clap::{Args, Subcommand};
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum OutputFormat {
Table,
Json,
}
#[derive(Args)]
pub struct ReleaseArgs {
#[command(subcommand)]
@@ -23,9 +17,6 @@
List {
/// Repository (org/repo)
repo: Option<String>,
/// Output format
#[arg(long, value_enum, default_value_t = OutputFormat::Table)]
format: OutputFormat,
},
/// View a release
View {
@@ -149,7 +140,7 @@
},
}
#[derive(Debug, Deserialize, Default)]
#[derive(Debug, Deserialize)]
struct Release {
tag_name: Option<String>,
title: Option<String>,
@@ -188,9 +179,9 @@
inserted_at: Option<String>,
}
pub async fn run(args: ReleaseArgs) -> Result<(), Box<dyn std::error::Error>> {
pub async fn run(args: ReleaseArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
match args.command {
ReleaseCommand::List { repo, format } => list(repo.as_deref(), format).await,
ReleaseCommand::List { repo } => list(repo.as_deref()).await,
ReleaseCommand::View { tag, repo } => view(repo.as_deref(), &tag).await,
ReleaseCommand::Create {
repo,
@@ -254,6 +245,6 @@
}
}
async fn list(repo: Option<&str>) -> Result<output::Response, Box<dyn std::error::Error>> {
async fn list(repo: Option<&str>, format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -266,11 +257,6 @@
.cloned()
.unwrap_or(resp);
if matches!(format, OutputFormat::Json) {
println!("{}", serde_json::to_string(&releases_val)?);
return Ok(());
}
let releases: Vec<Release> = serde_json::from_value(releases_val).unwrap_or_default();
let releases: Vec<Release> = serde_json::from_value(releases_val.clone()).unwrap_or_default();
output::header(&format!("Releases ({org}/{name})"));
@@ -301,22 +287,40 @@
output::print_table(&["TAG", "TITLE", "FLAGS", "CREATED"], &rows);
// A list must read as a JSON array; a present-but-null server key would
// otherwise echo a bare `null` and break `jq '.[]'`.
Ok(output::Response::read(array_or_empty(releases_val)))
}
/// Normalize a list payload to a JSON array: a null (e.g. `{"releases":null}`)
/// or otherwise-absent collection becomes `[]` rather than a bare `null`.
fn array_or_empty(v: serde_json::Value) -> serde_json::Value {
if v.is_array() {
v
} else {
serde_json::Value::Array(vec![])
Ok(())
}
}
async fn view(repo: Option<&str>, tag: &str) -> Result<(), Box<dyn std::error::Error>> {
async fn view(
repo: Option<&str>,
tag: &str,
) -> Result<output::Response, 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}/releases/{tag}")).await?;
let release_val = resp
.get("release")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or_else(|| resp.clone());
let rel: Release = if let Some(obj) = resp.get("release") {
serde_json::from_value(obj.clone())?
// Echo the raw server payload verbatim (below); the strict struct is only
// for the human table, so a 2xx body whose field types surprise us must not
// turn a successful read into an error. Degrade the human view, never the JSON.
let rel: Release = serde_json::from_value(release_val.clone()).unwrap_or_default();
} else if let Some(obj) = resp.get("data") {
serde_json::from_value(obj.clone())?
} else {
serde_json::from_value(resp)?
};
output::header(&format!(
"Release {}",
@@ -345,14 +349,14 @@
}
if let Some(ref body) = rel.body {
if !body.is_empty() {
output::line(&format!("\n{body}"));
println!("\n{body}");
}
}
// Show assets if present
if let Some(ref asset_list) = rel.assets {
if !asset_list.is_empty() {
output::line("");
println!();
output::header("Assets");
let rows: Vec<Vec<String>> = asset_list
.iter()
@@ -369,7 +373,7 @@
}
}
Ok(())
Ok(output::Response::read(release_val))
}
async fn create(
@@ -379,11 +383,11 @@
body: &str,
prerelease: bool,
draft: bool,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
let _resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/releases"),
&serde_json::json!({
@@ -400,12 +404,21 @@
let config = crate::config::Config::load()?;
if let Some(ref url) = config.server_url {
println!("\n {url}/{org}/{name}/releases/{tag}");
output::line(&format!("\n {url}/{org}/{name}/releases/{tag}"));
}
let release_val = resp
Ok(())
.get("release")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or(resp);
Ok(output::Response::ok("release", release_val))
}
async fn delete(
async fn delete(repo: Option<&str>, tag: &str) -> Result<(), Box<dyn std::error::Error>> {
repo: Option<&str>,
tag: &str,
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -416,7 +429,7 @@
output::success(&format!("Deleted release {tag}"));
Ok(())
Ok(output::Response::deleted(tag))
}
async fn update(
@@ -426,6 +439,6 @@
body: Option<&str>,
draft: Option<bool>,
prerelease: Option<bool>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -446,10 +459,10 @@
if payload.is_empty() {
output::warn("No fields to update. Use --title, --body, --draft, or --prerelease.");
return Ok(output::Response::done());
return Ok(());
}
let _resp: serde_json::Value = client
let resp: serde_json::Value = client
.put(
&format!("/{org}/{name}/releases/{tag}"),
&serde_json::Value::Object(payload),
@@ -458,9 +471,18 @@
output::success(&format!("Updated release {tag}"));
Ok(())
let release_val = resp
.get("release")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or(resp);
Ok(output::Response::ok("release", release_val))
}
async fn publish(
repo: Option<&str>,
tag: &str,
async fn publish(repo: Option<&str>, tag: &str) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -471,7 +493,10 @@
output::success(&format!("Published release {tag}"));
Ok(output::Response::ok(
"release",
serde_json::json!({ "tag_name": tag, "draft": false }),
))
Ok(())
}
async fn upload(
@@ -479,6 +504,6 @@
tag: &str,
file: &str,
name_override: Option<&str>,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -514,16 +539,18 @@
let _ = tokio::fs::remove_file(&upload_path).await;
}
let asset_name = resp
let asset_val = resp
.get("asset")
.or_else(|| resp.get("data"))
.and_then(|a| a.get("filename"))
.cloned()
.unwrap_or(resp);
let asset_name = asset_val
.get("filename")
.and_then(|f| f.as_str())
.unwrap_or("(unknown)");
let asset_size = resp
.get("asset")
.or_else(|| resp.get("data"))
let asset_size = asset_val
.get("size_bytes")
.and_then(|a| a.get("size_bytes"))
.and_then(|s| s.as_u64())
.unwrap_or(0);
@@ -534,7 +561,7 @@
format_size(asset_size)
));
Ok(output::Response::ok("asset", asset_val))
Ok(())
}
async fn download(
@@ -542,6 +569,6 @@
tag: &str,
filename: &str,
output_path: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -585,9 +612,15 @@
output::success(&format!("Downloaded {} to {}", filename, dest.display()));
Ok(output::Response::ok(
"downloaded",
serde_json::json!({ "tag": tag, "file": dest.display().to_string() }),
))
Ok(())
}
async fn assets(
async fn assets(repo: Option<&str>, tag: &str) -> Result<(), Box<dyn std::error::Error>> {
repo: Option<&str>,
tag: &str,
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -602,6 +635,6 @@
.cloned()
.unwrap_or(serde_json::Value::Array(vec![]));
let asset_list: Vec<Asset> = serde_json::from_value(assets_val).unwrap_or_default();
let asset_list: Vec<Asset> = serde_json::from_value(assets_val.clone()).unwrap_or_default();
output::header(&format!("Assets for {tag} ({org}/{name})"));
@@ -620,13 +653,13 @@
output::print_table(&["FILENAME", "SIZE", "DOWNLOADS", "ID"], &rows);
Ok(())
Ok(output::Response::read(array_or_empty(assets_val)))
}
async fn delete_asset(
repo: Option<&str>,
tag: &str,
asset_id: &str,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -637,13 +670,13 @@
output::success(&format!("Deleted asset {asset_id} from release {tag}"));
Ok(())
Ok(output::Response::deleted(asset_id))
}
async fn changelog(
repo: Option<&str>,
base: &str,
head: &str,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -664,33 +697,32 @@
if let Some(text) = changelog_text {
output::header(&format!("Changelog: {base}..{head}"));
println!("\n{text}");
output::line(&format!("\n{text}"));
} else if let Some(commits) = resp.get("commits").and_then(|c| c.as_array()) {
} else {
// Maybe the whole response is the changelog, or has commits
output::header(&format!(
"Changelog: {base}..{head} ({} commits)",
commits.len()
));
for commit in commits {
if let Some(commits) = resp.get("commits").and_then(|c| c.as_array()) {
output::header(&format!(
let sha = commit
.get("sha")
.or_else(|| commit.get("id"))
.and_then(|s| s.as_str())
.unwrap_or("???????");
let short_sha = if sha.len() > 7 { &sha[..7] } else { sha };
let message = commit.get("message").and_then(|m| m.as_str()).unwrap_or("");
// Just show first line of the message
let first_line = message.lines().next().unwrap_or("");
output::line(&format!(" {short_sha} {first_line}"));
"Changelog: {base}..{head} ({} commits)",
commits.len()
));
for commit in commits {
let sha = commit
.get("sha")
.or_else(|| commit.get("id"))
.and_then(|s| s.as_str())
.unwrap_or("???????");
let short_sha = if sha.len() > 7 { &sha[..7] } else { sha };
let message = commit.get("message").and_then(|m| m.as_str()).unwrap_or("");
// Just show first line of the message
let first_line = message.lines().next().unwrap_or("");
println!(" {short_sha} {first_line}");
}
} else {
// Fall back to printing the whole response
println!("{}", serde_json::to_string_pretty(&resp)?);
}
} else {
// Fall back to showing the whole response
output::header(&format!("Changelog: {base}..{head}"));
output::line(&serde_json::to_string_pretty(&resp)?);
}
Ok(output::Response::read(resp))
Ok(())
}
/// Format a byte size into a human-readable string.
▸
src/commands/repo.rs
+62
−46
@@ -55,7 +55,7 @@
},
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Default, Deserialize)]
#[allow(dead_code)]
struct Repo {
name: Option<String>,
@@ -75,7 +75,7 @@
name: Option<String>,
}
pub async fn run(args: RepoArgs) -> Result<(), Box<dyn std::error::Error>> {
pub async fn run(args: RepoArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
match args.command {
RepoCommand::List { org, limit } => list(org.as_deref(), limit).await,
RepoCommand::View { repo } => view(repo.as_deref()).await,
@@ -90,45 +90,51 @@
}
}
async fn list(org_filter: Option<&str>, limit: u32) -> Result<(), Box<dyn std::error::Error>> {
async fn list(
org_filter: Option<&str>,
limit: u32,
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let resp: serde_json::Value = client
.get_with_query("/repos", &[("per_page", &limit.to_string())])
.await?;
// Pull the repo array out regardless of the server's envelope key.
let raw: Vec<serde_json::Value> =
if output::is_json() {
if let Some(arr) = resp.get("repositories").and_then(|v| v.as_array()) {
arr.clone()
output::print_json(&resp);
return Ok(());
}
let repos: Vec<Repo> = if let Some(arr) = resp.get("repositories") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else if let Some(arr) = resp.get("repos") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else if let Some(arr) = resp.get("data") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else {
serde_json::from_value(resp).unwrap_or_default()
};
} else if let Some(arr) = resp.get("repos").and_then(|v| v.as_array()) {
arr.clone()
} else if let Some(arr) = resp.get("data").and_then(|v| v.as_array()) {
arr.clone()
} else if let Some(arr) = resp.as_array() {
arr.clone()
} else {
Vec::new()
};
// Apply the --org filter in BOTH modes (client-side substring match on org
// slug) so the JSON payload and the human table agree.
let filtered: Vec<serde_json::Value> = match org_filter {
Some(f) => raw
.into_iter()
let filtered: Vec<&Repo> = match org_filter {
Some(f) => repos
.iter()
.filter(|r| {
r.pointer("/org/slug")
r.org
.as_ref()
.and_then(|s| s.as_str())
.and_then(|o| o.slug.as_deref())
.map(|s| s.contains(f))
.unwrap_or(false)
})
.collect(),
None => raw,
None => repos.iter().collect(),
};
output::header(&format!("Repositories ({} visible)", filtered.len()));
let repos: Vec<Repo> =
serde_json::from_value(serde_json::Value::Array(filtered.clone())).unwrap_or_default();
let rows: Vec<Vec<String>> = filtered
output::header(&format!("Repositories ({} visible)", repos.len()));
let rows: Vec<Vec<String>> = repos
.iter()
.map(|r| {
let desc = r.description.clone().unwrap_or_default();
@@ -157,21 +163,16 @@
output::print_table(&["REPO", "VISIBILITY", "DEFAULT", "DESCRIPTION"], &rows);
Ok(())
Ok(output::Response::items(filtered))
}
async fn view(repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
async fn view(repo: Option<&str>) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
if output::is_json() {
let resp: serde_json::Value = client.get(&format!("/{org}/{name}")).await?;
let repo: Repo = serde_json::from_value(resp.clone()).unwrap_or_default();
let resp: serde_json::Value = client.get(&format!("/{org}/{name}")).await?;
output::print_json(&resp);
return Ok(());
}
let repo: Repo = client.get(&format!("/{org}/{name}")).await?;
output::header(&format!("{org}/{}", repo.slug.as_deref().unwrap_or(&name)));
if let Some(ref desc) = repo.description {
@@ -192,7 +193,7 @@
output::detail("Created", &output::format_time(ts));
}
Ok(output::Response::read(resp))
Ok(())
}
async fn create(
@@ -200,7 +201,7 @@
org: Option<&str>,
description: &str,
visibility: &str,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
// Resolve org: explicit flag, or first part of default repo
@@ -233,12 +234,15 @@
let config = Config::load()?;
if let Some(ref url) = config.server_url {
println!("\n {url}/{org_slug}/{slug}");
output::line(&format!("\n {url}/{org_slug}/{slug}"));
}
Ok(())
Ok(output::Response::ok("repo", resp))
}
async fn clone(
async fn clone(repo: &str, dir: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
repo: &str,
dir: Option<&str>,
) -> Result<output::Response, Box<dyn std::error::Error>> {
let config = Config::load()?;
let server = config.server_url()?;
@@ -252,19 +256,27 @@
output::info(&format!("Cloning {ssh_url} into {target}/"));
// git's own progress goes to stderr, which is safe under --json.
let status = std::process::Command::new("git")
.args(["clone", &ssh_url, target])
.status()?;
if status.success() {
output::success(&format!("Cloned {repo} into {target}/"));
} else {
if !status.success() {
return Err(format!("git clone exited with status {status}").into());
}
output::success(&format!("Cloned {repo} into {target}/"));
Ok(output::Response::ok(
"repo",
serde_json::json!({
"repo": repo,
"ssh_url": ssh_url,
"dir": target,
}),
))
Ok(())
}
async fn set_default(repo: &str) -> Result<output::Response, Box<dyn std::error::Error>> {
async fn set_default(repo: &str) -> Result<(), Box<dyn std::error::Error>> {
// Validate format
config::resolve_repo(Some(repo))?;
@@ -274,5 +286,9 @@
config.save()?;
output::success(&format!("Default repository set to {repo}"));
Ok(())
Ok(output::Response::ok(
"default_repo",
serde_json::Value::String(repo.to_string()),
))
}
▸
src/commands/requirement.rs
+186
−241
@@ -359,7 +359,7 @@
test_count: Option<u32>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, Default)]
struct RequirementDetail {
id: Option<String>,
requirement_id: Option<String>,
@@ -374,7 +374,7 @@
test_links: Option<Vec<serde_json::Value>>,
}
pub async fn run(args: RequirementArgs) -> Result<(), Box<dyn std::error::Error>> {
pub async fn run(args: RequirementArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
match args.command {
RequirementCommand::List {
status,
@@ -479,7 +479,7 @@
kind: &str,
framework: Option<&str>,
mandatory: bool,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
match kind {
"requirement" => list_requirements(repo, status, category).await,
"standard" => list_standards(organization, framework, mandatory).await,
@@ -495,9 +495,17 @@
"--kind all requires --repo <org/repo> (for the requirements half), or a configured default"
})?;
// In human mode each half prints its own table; under --json we
// merge both server payloads into a single document.
let reqs = list_requirements(repo, status, category).await?;
output::line("");
let stds = list_standards(organization, framework, mandatory).await?;
let requirements = reqs.json.get("requirements").cloned().unwrap_or(reqs.json);
let standards = stds.json.get("standards").cloned().unwrap_or(stds.json);
Ok(output::Response::read(serde_json::json!({
list_requirements(repo, status, category).await?;
"requirements": requirements,
"standards": standards,
})))
println!();
list_standards(organization, framework, mandatory).await
}
other => {
Err(format!("unknown --kind '{other}' (expected requirement | standard | all)").into())
@@ -509,7 +517,7 @@
repo: Option<&str>,
status: Option<&str>,
category: Option<&str>,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let mut query = Vec::new();
@@ -522,10 +530,6 @@
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())
@@ -548,15 +552,15 @@
&["ID", "TITLE", "CATEGORY", "PRIORITY", "STATUS", "TESTS"],
&rows,
);
println!("\n{} requirements", rows.len());
Ok(())
output::line(&format!("\n{} requirements", rows.len()));
Ok(output::Response::read(resp))
}
async fn list_standards(
organization: Option<&str>,
framework: Option<&str>,
mandatory: bool,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> 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();
@@ -569,10 +573,6 @@
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())
@@ -607,8 +607,8 @@
],
&rows,
);
output::line(&format!("\n{} standards", rows.len()));
Ok(output::Response::read(resp))
println!("\n{} standards", rows.len());
Ok(())
}
async fn view(
@@ -616,7 +616,7 @@
repo: Option<&str>,
organization: Option<&str>,
show_blanks: bool,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> 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,
@@ -627,18 +627,16 @@
async fn view_requirement(
req_id: &str,
repo: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, 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/{req_id}"))
.await?;
// Echo the raw server body verbatim (below); the strict struct only drives
// the human table, so an unexpected 2xx shape degrades the human view rather
// than turning a successful read into an error.
let req: RequirementDetail = serde_json::from_value(resp.clone()).unwrap_or_default();
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("?"),
@@ -663,23 +661,18 @@
if let Some(id) = &req.id {
output::detail("UUID", id);
}
Ok(())
Ok(output::Response::read(resp))
}
async fn view_standard(
std_id: &str,
organization: Option<&str>,
show_blanks: bool,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, 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?;
if output::is_json() {
output::print_json(&std);
return Ok(());
}
output::header(&format!(
"Standard {} — {}",
std["requirement_id"].as_str().unwrap_or("?"),
@@ -722,14 +715,14 @@
if let Some(id) = std["id"].as_str() {
output::detail("UUID", id);
}
Ok(())
Ok(output::Response::read(std))
}
async fn delete(
id: &str,
repo: Option<&str>,
organization: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, 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,
@@ -741,7 +734,7 @@
id: &str,
repo: Option<&str>,
organization: Option<&str>,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> 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"
@@ -753,19 +746,15 @@
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(output::Response::deleted(id))
Ok(())
}
async fn delete_standard(
id: &str,
repo: Option<&str>,
organization: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, 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}')"
@@ -777,12 +766,8 @@
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(())
Ok(output::Response::deleted(id))
}
/// Guard: test↔requirement links are requirements-only (REQ-*); the link
@@ -803,7 +788,7 @@
test_file: Option<&str>,
test_line: Option<i64>,
repo: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
require_requirement_id(req_id)?;
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -820,19 +805,15 @@
&payload,
)
.await?;
if output::is_json() {
output::json_ok("link", resp);
return Ok(());
}
output::success(&format!("Linked test '{test}' to requirement '{req_id}'"));
Ok(output::Response::ok("link", resp))
Ok(())
}
async fn unlink_test(
req_id: &str,
test: &str,
repo: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
require_requirement_id(req_id)?;
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -844,17 +825,13 @@
"/{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(output::Response::ok(
Ok(())
"unlinked",
serde_json::json!({ "requirement": req_id, "test": test }),
))
}
fn unknown_prefix_error(id: &str) -> String {
@@ -863,7 +840,9 @@
)
}
async fn applicability(
async fn applicability(args: ApplicabilityArgs) -> Result<(), Box<dyn std::error::Error>> {
args: ApplicabilityArgs,
) -> Result<output::Response, Box<dyn std::error::Error>> {
match args.command {
ApplicabilityCommand::List { id, organization } => {
require_standard_id(&id)?;
@@ -901,17 +880,13 @@
async fn applicability_list(
std_id: &str,
organization: &str,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let resp: serde_json::Value = client
.get(&format!(
"/{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())
@@ -929,15 +904,15 @@
})
.collect();
output::print_table(&["SLUG", "NAME", "VISIBILITY"], &rows);
println!("\n{} repositories", rows.len());
output::line(&format!("\n{} repositories", rows.len()));
Ok(output::Response::read(resp))
Ok(())
}
async fn applicability_add(
std_id: &str,
organization: &str,
repo: &str,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let _: serde_json::Value = client
.put(
@@ -945,51 +920,43 @@
&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}'"
));
Ok(())
Ok(output::Response::ok(
"applicability",
serde_json::json!({
"standard": std_id,
"organization": organization,
"repo": repo,
"opted_in": true,
}),
))
}
async fn applicability_remove(
std_id: &str,
organization: &str,
repo: &str,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
client
.delete_empty(&format!(
"/{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}'"
));
Ok(output::Response::ok(
"applicability",
serde_json::json!({
"standard": std_id,
"organization": organization,
"repo": repo,
"opted_in": false,
}),
Ok(())
))
}
struct CreateInputs<'a> {
@@ -1028,7 +995,7 @@
}
}
async fn create(inputs: CreateInputs<'_>) -> Result<(), Box<dyn std::error::Error>> {
async fn create(inputs: CreateInputs<'_>) -> Result<output::Response, Box<dyn std::error::Error>> {
match infer_kind(inputs.requirement_id) {
Kind::Standard => create_standard(inputs).await,
Kind::Requirement => create_requirement(inputs).await,
@@ -1036,7 +1003,9 @@
}
}
async fn create_requirement(
inputs: CreateInputs<'_>,
) -> Result<output::Response, Box<dyn std::error::Error>> {
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"
@@ -1082,10 +1051,6 @@
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"]
@@ -1096,10 +1061,12 @@
if let Some(id) = resp["id"].as_str() {
output::detail("UUID", id);
}
Ok(())
Ok(output::Response::ok("requirement", resp))
}
async fn create_standard(
inputs: CreateInputs<'_>,
) -> Result<output::Response, Box<dyn std::error::Error>> {
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 '{}')",
@@ -1157,10 +1124,6 @@
}
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"]
@@ -1171,7 +1134,7 @@
if let Some(id) = resp["id"].as_str() {
output::detail("UUID", id);
}
Ok(output::Response::ok("standard", resp))
Ok(())
}
fn any_standards_flags(inputs: &CreateInputs<'_>) -> bool {
@@ -1183,7 +1146,7 @@
|| inputs.mandatory
}
async fn update(args: UpdateArgs) -> Result<(), Box<dyn std::error::Error>> {
async fn update(args: UpdateArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
match infer_kind(&args.id) {
Kind::Standard => update_standard(args).await,
Kind::Requirement => update_requirement(args).await,
@@ -1191,7 +1154,9 @@
}
}
async fn update_requirement(
async fn update_requirement(args: UpdateArgs) -> Result<(), Box<dyn std::error::Error>> {
args: UpdateArgs,
) -> Result<output::Response, Box<dyn std::error::Error>> {
if args.organization.is_some() {
return Err(
"REQ-* requirements are repo-scoped — pass --repo (or use a default), not --organization"
@@ -1239,19 +1204,15 @@
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_str().unwrap_or(&args.id),
resp["title"].as_str().unwrap_or("?")
));
Ok(output::Response::ok("requirement", resp))
Ok(())
}
async fn update_standard(args: UpdateArgs) -> Result<(), Box<dyn std::error::Error>> {
async fn update_standard(args: UpdateArgs) -> Result<output::Response, 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 '{}')",
@@ -1309,16 +1270,12 @@
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),
resp["title"].as_str().unwrap_or("?")
));
Ok(output::Response::ok("standard", resp))
Ok(())
}
async fn matrix(
@@ -1328,7 +1285,7 @@
run_id: Option<&str>,
policy: Option<&str>,
run_window: Option<u32>,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>> {
match kind {
"requirement" => matrix_requirements(repo, run_id, policy, run_window).await,
"standard" => matrix_standards(organization).await,
@@ -1341,7 +1298,7 @@
run_id: Option<&str>,
policy: Option<&str>,
run_window: Option<u32>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, 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());
@@ -1359,11 +1316,6 @@
.get_with_query(&format!("/{org}/{name}/requirements/matrix"), &query)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let entries = resp
.get("matrix")
.and_then(|v| v.as_array())
@@ -1388,24 +1340,17 @@
})
.collect();
output::print_table(&["ID", "TITLE", "COVERAGE", "TESTS"], &rows);
println!("\n{} requirements in matrix", rows.len());
Ok(())
output::line(&format!("\n{} requirements in matrix", rows.len()));
Ok(output::Response::read(resp))
}
async fn matrix_standards(
organization: Option<&str>,
) -> Result<output::Response, Box<dyn std::error::Error>> {
async fn matrix_standards(organization: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let org = organization.ok_or("--kind standard requires --organization <slug>")?;
let client = Client::from_config()?;
let resp: serde_json::Value = client.get(&format!("/{org}/standards/matrix")).await?;
// Pipeable JSON output when invoked with the global --json flag.
// Re-emits the server's response verbatim — the structure is already
// stable per REQ-STD-025 and downstream tools (compliance dashboards,
// SOC2 audit exports) can consume it directly.
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let entries = resp
.get("matrix")
.and_then(|v| v.as_array())
@@ -1415,8 +1360,8 @@
output::header(&format!("Standards Coverage Matrix ({org})"));
if entries.is_empty() {
println!("(no standards defined)");
return Ok(());
output::line("(no standards defined)");
return Ok(output::Response::read(resp));
}
let mut total_cells = 0usize;
@@ -1433,11 +1378,13 @@
} else {
format!(" ({framework})")
};
output::line(&format!(
println!("\n{std_id}{framework_tag} — {title}{mandatory_tag}");
"\n{std_id}{framework_tag} — {title}{mandatory_tag}"
));
let repos = entry["repos"].as_array().cloned().unwrap_or_default();
if repos.is_empty() {
println!(" (not opted in by any repository)");
output::line(" (not opted in by any repository)");
} else {
let rows: Vec<Vec<String>> = repos
.iter()
@@ -1453,15 +1400,15 @@
}
}
println!(
output::line(&format!(
"\n{} standards, {} (standard × repo) cells",
entries.len(),
total_cells
);
Ok(())
));
Ok(output::Response::read(resp))
}
async fn status(args: StatusArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
async fn status(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
if args.strict_standards {
return strict_standards_status(args.organization.as_deref()).await;
}
@@ -1470,7 +1417,7 @@
async fn strict_standards_status(
organization: Option<&str>,
) -> Result<output::Response, Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>> {
let org = organization.ok_or(
"--strict-standards requires --organization <slug> (the org whose mandatory standards to gate on)",
)?;
@@ -1481,7 +1428,7 @@
async fn strict_standards_with_client(
client: &Client,
org: &str,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
// The endpoint returns 422 on uncovered, so we go through the raw
// request path rather than `client.get` which would treat that as an
// error. We let `client.get` give us a generic Value and inspect the
@@ -1493,12 +1440,10 @@
Ok(body) => {
// 2xx with status=ok — coverage gate passed.
if body["status"] == "ok" {
output::success("Standards strict coverage gate passed");
Ok(output::Response::read(
if output::is_json() {
serde_json::json!({"passed": true, "uncovered": []}),
))
output::print_json(&serde_json::json!({"passed": true, "uncovered": []}));
} else {
output::success("Standards strict coverage gate passed");
}
Ok(())
} else {
// Unexpected envelope.
Err(format!("unexpected response body from /standards/strict: {body}").into())
@@ -1513,17 +1458,17 @@
format!("standards gate failed with 422 but body was not JSON: {message}")
})?;
let count = body["uncovered"].as_array().map(|a| a.len()).unwrap_or(0);
print_uncovered(&body);
if output::is_json() {
// Machine payload on stdout, then preserve the non-zero exit.
let uncovered = body
.get("uncovered")
// Emit the machine payload, then fail the gate with a non-zero exit.
let uncovered = body
.get("uncovered")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
Ok(output::Response::gate(
serde_json::json!({"passed": false, "uncovered": uncovered}),
1,
format!("{count} uncovered mandatory standard(s)"),
))
.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)),
}
@@ -1532,8 +1477,8 @@
fn print_uncovered(body: &serde_json::Value) {
output::header("Standards Strict Coverage — FAILED");
let entries = body["uncovered"].as_array().cloned().unwrap_or_default();
println!(" {} uncovered mandatory pair(s):", entries.len());
println!();
output::line(&format!(" {} uncovered mandatory pair(s):", entries.len()));
output::line("");
let rows: Vec<Vec<String>> = entries
.iter()
.map(|e| {
@@ -1549,7 +1494,7 @@
})
.collect();
output::print_table(&["STANDARD", "TITLE", "REPO", "STATUS"], &rows);
output::line("");
println!();
}
/// Coverage tallies derived from the traceability matrix.
@@ -1654,21 +1599,16 @@
}
}
/// 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())
}
/// The gate's failure message: names 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).
fn gate_message(summary: &StatusSummary) -> String {
format!("{} requirement(s) failing coverage", summary.failing.len())
}
async fn status_requirements(
args: StatusArgs,
) -> Result<output::Response, Box<dyn std::error::Error>> {
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())?;
let resp: serde_json::Value = client
@@ -1682,39 +1622,37 @@
.unwrap_or_default();
let summary = summarize_status(&entries, args.strict);
let json = summary.to_json(&org, &name);
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() {
// Nothing to gate on — an empty repo passes. The warning is diagnostic
// (stderr) so it's safe under --json.
output::warn("No requirements found. Create requirements first.");
return Ok(output::Response::read(json));
return Ok(());
}
output::header("Requirement Coverage Status");
println!(" Covered: {} ✓", summary.counts.covered);
output::line(&format!(" Covered: {} ✓", summary.counts.covered));
output::line(&format!(" Partial: {} ◐", summary.counts.partial));
output::line(&format!(" Uncovered: {} ✗", summary.counts.uncovered));
output::line(&format!(" No tests: {} ○", summary.counts.no_tests));
output::line(&format!(" Total: {}", summary.counts.total));
output::line("");
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");
Ok(output::Response::read(json))
Ok(())
} else {
output::error("Requirement coverage check FAILED");
println!();
output::line("");
for f in &summary.failing {
output::detail(
&f.requirement_id,
&format!("{} — {}", f.title, f.coverage_status),
);
}
gate_result(&summary)
// Emit the machine payload, then preserve the gate's non-zero exit so
// `set -e` scripts still fail on uncovered coverage.
Ok(output::Response::gate(json, 1, gate_message(&summary)))
}
}
@@ -1739,13 +1677,21 @@
repo: Option<&str>,
clear: bool,
yes: bool,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, 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 {
// Under --json there is no interactive prompt — a destructive clear
// must be authorized explicitly with --yes.
if json && !yes {
return Err(
"--clear requires --yes under --json (interactive confirmation is suppressed)"
.into(),
);
}
confirm_clear(&client, &org, &name, yes).await?;
if !json {
output::warn("Clearing existing requirements (hard delete)...");
@@ -1845,22 +1791,20 @@
}
}
if json {
output::print_json(&serde_json::json!({
"ok": true,
output::line("");
output::success(&format!(
"Done! Created: {created}, Skipped (existing): {skipped}, Cleared: {cleared}, Total: {}",
created + skipped
));
Ok(output::Response::ok(
"seed",
serde_json::json!({
"created": created,
"skipped": skipped,
"cleared": cleared,
"total": created + skipped,
}));
} else {
println!();
output::success(&format!(
"Done! Created: {created}, Skipped (existing): {skipped}, Cleared: {cleared}, Total: {}",
created + skipped
));
}),
))
}
Ok(())
}
/// Hard-delete every requirement in the repo, children before parents, so a
@@ -2308,7 +2252,7 @@
file: &str,
format: &str,
dry_run: bool,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, Box<dyn std::error::Error>> {
let text = if file == "-" {
use std::io::Read;
let mut buf = String::new();
@@ -2327,22 +2271,17 @@
)
.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)");
output::line(&format!(
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!(
"{}: {}",
@@ -2357,7 +2296,7 @@
a["created"], a["updated"], a["unchanged"]
));
}
Ok(output::Response::read(resp))
Ok(())
}
/// Fetch the repo's requirements and print them in the import schema, so
@@ -2365,16 +2304,17 @@
/// 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>,
async fn export_requirements(repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<output::Response, 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() {
// The default output is import-shaped YAML — a human format emitted via
// output::line (which no-ops under --json). Under --json we instead return
// the raw requirements payload below.
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();
@@ -2385,25 +2325,25 @@
}
for r in &reqs {
output::line(&format!(
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));
output::line(&format!(" title: {}", yaml_str(t)));
}
for key in ["category", "priority", "status"] {
if let Some(v) = r[key].as_str() {
println!(" {key}: {}", yaml_str(v));
output::line(&format!(" {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) {
output::line(&format!(" parent: {}", yaml_str(parent_rid)));
println!(" parent: {}", yaml_str(parent_rid));
}
}
}
Ok(())
Ok(output::Response::read(resp))
}
/// Quote a YAML scalar defensively (double-quoted style with escapes).
@@ -2934,10 +2874,15 @@
.await;
let client = Client::for_test(server.uri(), "tok");
// Uncovered mandatory pairs are a gate: the coverage JSON is still
// emitted, then the process exits nonzero (Response::gate), rather than
let err = strict_standards_with_client(&client, "fangorn")
// erroring out with no payload.
let resp = strict_standards_with_client(&client, "fangorn")
.await
.expect_err("expected uncovered → Err");
let msg = err.to_string();
.expect("gate returns Ok(Response) with a nonzero exit, not Err");
assert_eq!(resp.json["passed"], false);
let (code, msg) = resp.exit.expect("uncovered → nonzero gate exit");
assert_eq!(code, 1);
assert!(msg.contains("1 uncovered"), "got: {msg}");
assert!(msg.contains("mandatory standard"), "got: {msg}");
}
▸
src/commands/runner.rs
+359
−160
@@ -291,7 +291,9 @@
List,
}
pub async fn run(
args: RunnerArgs,
) -> Result<output::Response, Box<dyn std::error::Error + Send + Sync>> {
pub async fn run(args: RunnerArgs) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
match args.command {
// Runner execution
RunnerCommand::Configure {
@@ -414,7 +416,7 @@
async fn configure(
opts: ConfigureOpts<'_>,
) -> Result<output::Response, Box<dyn std::error::Error + Send + Sync>> {
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let ConfigureOpts {
url,
token,
@@ -514,7 +516,7 @@
);
output::info("Start with: anvil runner start");
Ok(())
Ok(output::Response::ok("runner", data.clone()))
}
async fn stop(
@@ -522,7 +524,7 @@
instance: &str,
timeout_secs: u64,
force: bool,
) -> Result<output::Response, Box<dyn std::error::Error + Send + Sync>> {
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let pid_path = pid_file
.map(std::path::PathBuf::from)
.unwrap_or_else(|| runner::pid_file::instance_path(instance));
@@ -530,12 +532,18 @@
match runner::pid_file::inspect(&pid_path) {
runner::pid_file::Existing::None => {
output::info("No runner is running (no PID file).");
Ok(())
Ok(output::Response::ok(
"stopped",
serde_json::json!({ "running": false, "reason": "no pid file" }),
))
}
runner::pid_file::Existing::Stale(pid) => {
output::warn(&format!("Stale PID file (PID {pid} not running); removing"));
let _ = std::fs::remove_file(&pid_path);
Ok(output::Response::ok(
"stopped",
Ok(())
serde_json::json!({ "running": false, "reason": "stale", "pid": pid }),
))
}
runner::pid_file::Existing::Live(pid) => {
// Request graceful shutdown: SIGTERM on Unix; on Windows, set
@@ -561,14 +569,20 @@
while std::time::Instant::now() < deadline {
if !pid_path.exists() {
output::success(&format!("Runner stopped (PID {pid})"));
return Ok(());
return Ok(output::Response::ok(
"stopped",
serde_json::json!({ "pid": pid, "stopped": true }),
));
}
if !crate::platform::is_alive(pid) {
let _ = std::fs::remove_file(&pid_path);
output::success(&format!(
"Runner exited (PID {pid}); cleaned up stale PID file"
));
return Ok(());
return Ok(output::Response::ok(
"stopped",
serde_json::json!({ "pid": pid, "stopped": true, "exited": true }),
));
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
@@ -578,7 +592,10 @@
let _ = crate::platform::force_kill(pid);
let _ = std::fs::remove_file(&pid_path);
output::success(&format!("Runner force-killed (PID {pid})"));
Ok(output::Response::ok(
"stopped",
serde_json::json!({ "pid": pid, "stopped": true, "forced": true }),
Ok(())
))
} else {
Err(format!(
"Runner (PID {pid}) did not exit within {timeout_secs}s. \
@@ -595,7 +612,7 @@
pid_file: Option<&str>,
instance: &str,
timeout_secs: u64,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
) -> Result<output::Response, Box<dyn std::error::Error + Send + Sync>> {
// Stop is no-op if nothing's running. Honor --force semantics by
// defaulting to true on restart — if a binary upgrade caller can't
// get the old version to stop cleanly, escalating is the right call.
@@ -608,6 +625,11 @@
let exe = std::env::current_exe()?;
let mut cmd = std::process::Command::new(exe);
cmd.arg("runner").arg("start");
// Carry the global --json through to the re-exec'd process so a
// `--json runner restart` stays JSON across the exec boundary.
if output::is_json() {
cmd.arg("--json");
}
if let Some(c) = config_path {
cmd.arg("--config").arg(c);
}
@@ -646,7 +668,9 @@
detach_child: bool,
}
async fn start(opts: StartOpts<'_>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
async fn start(
opts: StartOpts<'_>,
) -> Result<output::Response, Box<dyn std::error::Error + Send + Sync>> {
let StartOpts {
once,
ephemeral,
@@ -710,6 +734,15 @@
"Runner detached (PID {child_pid}). Logs: {}",
log_path.display()
));
return Ok(output::Response::ok(
"started",
serde_json::json!({
"detached": true,
"pid": child_pid,
"ready": true,
"log_file": log_path.display().to_string(),
}),
));
}
runner::detach::DaemonReady::DiedDuringStartup(tail) => {
return Err(format!(
@@ -723,9 +756,17 @@
Check {}",
log_path.display()
));
return Ok(output::Response::ok(
"started",
serde_json::json!({
"detached": true,
"pid": pid,
"ready": false,
"log_file": log_path.display().to_string(),
}),
));
}
}
return Ok(());
}
let mut config = RunnerConfig::load(config_path)?;
@@ -740,14 +781,17 @@
.map(std::path::PathBuf::from)
.unwrap_or_else(runner::pid_file::default_path);
runner::loop_runner::start(config, pid_path, shutdown_timeout).await
// Foreground: this blocks until shutdown. When it returns Ok, the
// runner has drained and exited cleanly — there's nothing to echo.
runner::loop_runner::start(config, pid_path, shutdown_timeout).await?;
Ok(output::Response::done())
}
async fn status(
config_path: Option<&str>,
pid_file: Option<&str>,
instance: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
) -> Result<output::Response, Box<dyn std::error::Error + Send + Sync>> {
let config = RunnerConfig::load(config_path).ok();
let install = runner::service_mode::load(instance);
let pid_path = pid_file
@@ -755,46 +799,41 @@
.unwrap_or_else(runner::pid_file::default_path);
let pid_state = runner::pid_file::inspect(&pid_path);
let body = serde_json::json!({
"configured": config.is_some(),
"config_path": RunnerConfig::path(config_path).display().to_string(),
"config": config.as_ref().map(|c| serde_json::json!({
"server": c.server_url,
"runner_id": c.runner_id,
"name": c.name,
"labels": c.labels,
"work_dir": c.work_dir,
"parallel": c.parallel,
let json_mode = output::is_json();
if json_mode {
let body = serde_json::json!({
"configured": config.is_some(),
"config_path": RunnerConfig::path(config_path).display().to_string(),
"config": config.as_ref().map(|c| serde_json::json!({
"server": c.server_url,
"runner_id": c.runner_id,
"name": c.name,
})),
"service": install.as_ref().map(|r| serde_json::json!({
"instance": r.instance,
"scope": r.scope.as_str(),
"unit_path": r.unit_path.display().to_string(),
"unit_or_label": r.unit_or_label,
"parallel": r.parallel,
"labels": c.labels,
"work_dir": c.work_dir,
"parallel": c.parallel,
})),
"service": install.as_ref().map(|r| serde_json::json!({
"instance": r.instance,
"scope": r.scope.as_str(),
"unit_path": r.unit_path.display().to_string(),
"unit_or_label": r.unit_or_label,
"parallel": r.parallel,
"user_account": r.user_account,
})),
"running": match pid_state {
runner::pid_file::Existing::Live(pid) => serde_json::json!({
"alive": true,
"pid": pid,
"pid_file": pid_path.display().to_string(),
}),
runner::pid_file::Existing::Stale(pid) => serde_json::json!({
"alive": false,
"stale_pid": pid,
"pid_file": pid_path.display().to_string(),
}),
runner::pid_file::Existing::None => serde_json::json!({
"alive": false,
"pid_file": pid_path.display().to_string(),
}),
},
});
output::print_json(&body);
return Ok(());
}
"user_account": r.user_account,
})),
"running": match &pid_state {
runner::pid_file::Existing::Live(pid) => serde_json::json!({
"alive": true,
"pid": pid,
"pid_file": pid_path.display().to_string(),
}),
runner::pid_file::Existing::Stale(pid) => serde_json::json!({
"alive": false,
"stale_pid": pid,
"pid_file": pid_path.display().to_string(),
}),
runner::pid_file::Existing::None => serde_json::json!({
"alive": false,
"pid_file": pid_path.display().to_string(),
}),
},
});
output::header("Runner Status");
@@ -823,7 +862,7 @@
output::detail(
"Process running",
&match pid_state {
&match &pid_state {
runner::pid_file::Existing::Live(pid) => format!("yes (PID {pid})"),
runner::pid_file::Existing::Stale(pid) => {
format!("no (stale PID {pid} in {})", pid_path.display())
@@ -832,7 +871,7 @@
},
);
Ok(())
Ok(output::Response::read(body))
}
async fn logs(
@@ -840,7 +879,7 @@
lines: u32,
instance: &str,
log_file_override: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
) -> Result<output::Response, Box<dyn std::error::Error + Send + Sync>> {
// Explicit log-file override wins (handy for --detach with custom path).
if let Some(path) = log_file_override {
return tail_file(std::path::Path::new(path), follow, lines);
@@ -885,14 +924,36 @@
args.push(unit);
args.push("-n".into());
args.push(lines.to_string());
if follow {
// Under --json we must return a single JSON document, so we
// can't stream `-f`; capture a bounded snapshot instead.
if follow && !output::is_json() {
args.push("-f".into());
}
if output::is_json() {
let out = std::process::Command::new("journalctl")
.args(&args)
.output();
return match out {
Ok(o) if o.status.success() => Ok(output::Response::read(serde_json::json!({
"content": String::from_utf8_lossy(&o.stdout),
"format": "log",
}))),
Ok(o) => Err(format!("journalctl exited {}", o.status).into()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Err("`journalctl` not found on PATH; install systemd or \
use `--log-file <path>` to read a detach log directly"
.into())
}
Err(e) => Err(format!("failed to exec journalctl: {e}").into()),
};
}
let status = std::process::Command::new("journalctl")
.args(&args)
.status();
match status {
Ok(s) if s.success() => return Ok(()),
Ok(s) if s.success() => return Ok(output::Response::done()),
Ok(s) => return Err(format!("journalctl exited {s}").into()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err("`journalctl` not found on PATH; install systemd or \
@@ -923,10 +984,33 @@
path: &std::path::Path,
follow: bool,
lines: u32,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
) -> Result<output::Response, Box<dyn std::error::Error + Send + Sync>> {
if !path.exists() {
return Err(format!("log file not found: {}", path.display()).into());
}
// Under --json we can't inherit stdout to stream — capture a bounded
// snapshot (ignoring --follow, which can't terminate) and return it.
if output::is_json() {
let out = std::process::Command::new("tail")
.args(["-n", &lines.to_string(), &path.to_string_lossy()])
.output();
return match out {
Ok(o) if o.status.success() => Ok(output::Response::read(serde_json::json!({
"content": String::from_utf8_lossy(&o.stdout),
"format": "log",
}))),
Ok(o) => Err(format!("tail exited {}", o.status).into()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Err("`tail` not found on PATH; cannot read the log file. \
On a minimal container, install coreutils or use \
`cat <log-path>` directly."
.into())
}
Err(e) => Err(format!("failed to exec tail: {e}").into()),
};
}
let mut args: Vec<String> = vec!["-n".into(), lines.to_string()];
if follow {
args.push("-f".into());
@@ -934,7 +1018,7 @@
args.push(path.to_string_lossy().into_owned());
let status = std::process::Command::new("tail").args(&args).status();
match status {
Ok(s) if s.success() => Ok(()),
Ok(s) if s.success() => Ok(output::Response::done()),
Ok(s) => Err(format!("tail exited {s}").into()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Err("`tail` not found on PATH; cannot stream the log file. \
@@ -954,42 +1038,53 @@
path: &std::path::Path,
follow: bool,
lines: u32,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
) -> Result<output::Response, Box<dyn std::error::Error + Send + Sync>> {
if !path.exists() {
return Err(format!("log file not found: {}", path.display()).into());
}
// Under --json (or any non-follow read) we produce a captured snapshot
// of the last `lines` lines and return it as a single JSON document.
if output::is_json() || !follow {
let content = std::fs::read_to_string(path)?;
let all: Vec<&str> = content.lines().collect();
let start = all.len().saturating_sub(lines as usize);
let tail: String = all[start..].join("\n");
if output::is_json() {
return Ok(output::Response::read(serde_json::json!({
"content": tail,
"format": "log",
})));
}
// Human, one-shot: emit each line (no-op under --json, but we're
// in the human branch here).
for line in &all[start..] {
output::line(line);
}
if follow {
let script = format!(
"Get-Content -LiteralPath '{}' -Tail {} -Wait",
path.display().to_string().replace('\'', "''"),
lines
);
let status = std::process::Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
.status();
return match status {
Ok(s) if s.success() => Ok(()),
Ok(s) => Err(format!("powershell Get-Content exited {s}").into()),
Err(e) => Err(format!("failed to exec powershell: {e}").into()),
};
return Ok(output::Response::done());
}
// One-shot: read the file and print the last `lines` lines.
let content = std::fs::read_to_string(path)?;
let all: Vec<&str> = content.lines().collect();
let start = all.len().saturating_sub(lines as usize);
// Human, --follow: delegate to PowerShell's streaming tail.
let script = format!(
"Get-Content -LiteralPath '{}' -Tail {} -Wait",
path.display().to_string().replace('\'', "''"),
lines
);
let status = std::process::Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
.status();
match status {
Ok(s) if s.success() => Ok(output::Response::done()),
Ok(s) => Err(format!("powershell Get-Content exited {s}").into()),
Err(e) => Err(format!("failed to exec powershell: {e}").into()),
for line in &all[start..] {
println!("{line}");
}
Ok(())
}
async fn doctor(
config_path: Option<&str>,
pid_file: Option<&str>,
instance: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
) -> Result<output::Response, Box<dyn std::error::Error + Send + Sync>> {
let mut checks: Vec<(String, Result<String, String>)> = Vec::new();
// 1. Config loadable
@@ -1081,52 +1176,59 @@
checks.push(("Service unit on disk".into(), unit_check));
}
// Build the machine payload (echoed verbatim under --json) …
let arr: Vec<serde_json::Value> = checks
.iter()
.map(|(name, result)| {
let (status, detail) = match result {
Ok(s) => ("ok", s),
Err(s) => ("error", s),
};
serde_json::json!({ "check": name, "status": status, "detail": detail })
})
.collect();
let payload = serde_json::Value::Array(arr);
// Output
let json_mode = output::is_json();
if json_mode {
let arr: Vec<serde_json::Value> = checks
.iter()
.map(|(name, result)| {
let (status, detail) = match result {
Ok(s) => ("ok", s),
Err(s) => ("error", s),
// … and the human rendering (no-ops under --json).
output::header("Runner Doctor");
for (name, result) in &checks {
match result {
Ok(s) => output::success(&format!("{name}: {s}")),
Err(s) => output::error(&format!("{name}: {s}")),
};
serde_json::json!({ "check": name, "status": status, "detail": detail })
})
.collect();
output::print_json(&serde_json::Value::Array(arr));
} else {
output::header("Runner Doctor");
for (name, result) in &checks {
match result {
Ok(s) => output::success(&format!("{name}: {s}")),
Err(s) => output::error(&format!("{name}: {s}")),
}
}
}
// Doctor gates: emit the checks payload, then exit non-zero when a
// check failed so CI / monitors that shell out to `anvil runner
// doctor` can rely on $? — while still getting the full JSON on
// stdout. Stale PID, missing unit file, unreachable server — all
// operator-actionable failures that must not hide behind exit-zero.
if !config_ok {
return Err("doctor: configuration is unreadable".into());
return Ok(output::Response::gate(
payload,
1,
"doctor: configuration is unreadable".to_string(),
));
}
// Surface any Err check as a non-zero exit so CI / monitors that
// shell out to `anvil runner doctor` can rely on $? to gate.
// Stale PID, missing unit file, unreachable server — all of these
// are operator-actionable failures and shouldn't be hidden behind
// an exit-zero "all good" code path.
let failed: Vec<&str> = checks
.iter()
.filter_map(|(name, r)| r.as_ref().err().map(|_| name.as_str()))
.collect();
if !failed.is_empty() {
return Err(format!("doctor: failed checks: {}", failed.join(", ")).into());
return Ok(output::Response::gate(
payload,
1,
format!("doctor: failed checks: {}", failed.join(", ")),
));
}
Ok(output::Response::read(payload))
Ok(())
}
async fn unconfigure(
config_path: Option<&str>,
) -> Result<output::Response, Box<dyn std::error::Error + Send + Sync>> {
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let config = RunnerConfig::load(config_path)?;
let runner_id = config.runner_id.clone();
// Deregister from server
let client = reqwest::Client::new();
@@ -1140,26 +1242,38 @@
.send()
.await;
match resp {
let deregistered = match resp {
Ok(r) if r.status().is_success() => {
output::success("Deregistered from server");
true
}
Ok(r) => {
output::warn(&format!("Deregistration returned {}", r.status()));
false
}
Err(e) => {
output::warn(&format!("Could not reach server: {e}"));
false
}
}
};
// Remove config file
RunnerConfig::delete(config_path)?;
output::success("Config removed");
Ok(output::Response::ok(
"unconfigured",
serde_json::json!({
Ok(())
"runner_id": runner_id,
"server_deregistered": deregistered,
"config_removed": true,
}),
))
}
async fn service(args: ServiceArgs) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
async fn service(
args: ServiceArgs,
) -> Result<output::Response, Box<dyn std::error::Error + Send + Sync>> {
match args.command {
ServiceCommand::Install {
config,
@@ -1212,13 +1326,14 @@
Ok(name)
}
fn service_list() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
fn service_list() -> Result<output::Response, Box<dyn std::error::Error + Send + Sync>> {
let instances = runner::service_mode::list_instances();
if instances.is_empty() {
output::info("No runner service instances installed.");
return Ok(());
return Ok(output::Response::items(Vec::new()));
}
output::header("Installed runner service instances");
let mut items: Vec<serde_json::Value> = Vec::new();
for name in instances {
if let Some(record) = runner::service_mode::load(&name) {
output::detail(
@@ -1230,11 +1345,23 @@
record.unit_path.display()
),
);
items.push(serde_json::json!({
"instance": name,
"scope": record.scope.as_str(),
"parallel": record.parallel,
"unit_path": record.unit_path.display().to_string(),
"unit_or_label": record.unit_or_label,
"user_account": record.user_account,
}));
} else {
output::detail(&name, "<unreadable record>");
items.push(serde_json::json!({
"instance": name,
"error": "unreadable record",
}));
}
}
Ok(())
Ok(output::Response::items(items))
}
struct InstallOpts<'a> {
@@ -1271,7 +1398,9 @@
}
}
fn service_install(opts: InstallOpts<'_>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
fn service_install(
opts: InstallOpts<'_>,
) -> Result<output::Response, Box<dyn std::error::Error + Send + Sync>> {
use runner::service_mode::{InstallRecord, Scope};
let exe = std::env::current_exe()?;
@@ -1371,7 +1500,17 @@
)
};
output::info(&start_hint);
Ok(())
Ok(output::Response::ok(
"service",
serde_json::json!({
"instance": opts.instance,
"scope": scope.as_str(),
"kind": service_kind(),
"parallel": parallel,
"unit_path": unit_path.display().to_string(),
"action": "installed",
}),
))
}
#[cfg(all(unix, not(target_os = "macos")))]
@@ -1596,7 +1735,9 @@
}
#[cfg(windows)]
fn service_uninstall(
instance: &str,
) -> Result<output::Response, Box<dyn std::error::Error + Send + Sync>> {
fn service_uninstall(instance: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let record = runner::service_mode::load(instance).ok_or_else(|| {
format!("no service install recorded for '{instance}'; nothing to uninstall")
})?;
@@ -1613,11 +1754,20 @@
instance,
record.scope.as_str()
));
Ok(output::Response::ok(
"service",
serde_json::json!({
"instance": instance,
"scope": record.scope.as_str(),
"action": "uninstalled",
}),
))
Ok(())
}
#[cfg(not(windows))]
fn service_uninstall(instance: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
fn service_uninstall(
instance: &str,
) -> Result<output::Response, Box<dyn std::error::Error + Send + Sync>> {
use runner::service_mode::Scope;
let record = runner::service_mode::load(instance).ok_or_else(|| {
@@ -1658,28 +1808,42 @@
instance,
record.scope.as_str()
));
Ok(output::Response::ok(
"service",
serde_json::json!({
Ok(())
"instance": instance,
"scope": record.scope.as_str(),
"action": "uninstalled",
}),
))
}
#[cfg(windows)]
fn service_cmd(
action: &str,
instance: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
) -> Result<output::Response, Box<dyn std::error::Error + Send + Sync>> {
let record = runner::service_mode::load(instance).ok_or_else(|| {
format!(
"no service install recorded for '{instance}'; \
run `anvil runner service install` first"
)
})?;
runner::service_windows::control(action, &record)?;
// NOTE: `control` writes svc-status to stdout/SCM internally; the
// Windows path can't easily capture it to fold into the JSON, so
// svc-status returns an ok envelope rather than a captured log.
Ok(output::Response::ok(
"service",
serde_json::json!({ "instance": instance, "action": action }),
))
runner::service_windows::control(action, &record)
}
#[cfg(not(windows))]
fn service_cmd(
action: &str,
instance: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
) -> Result<output::Response, Box<dyn std::error::Error + Send + Sync>> {
use runner::service_mode::Scope;
let record = runner::service_mode::load(instance).ok_or_else(|| {
@@ -1699,8 +1863,14 @@
launchctl_bootstrap(&record)?;
}
"status" => {
launchctl_print(&record)?;
return Ok(());
// svc-status: capture the child's stdout and echo it as a
// log payload under --json; print it for humans.
let content = launchctl_print(&record)?;
output::line(&content);
return Ok(output::Response::read(serde_json::json!({
"content": content,
"format": "log",
})));
}
_ => return Err(format!("unknown action: {action}").into()),
}
@@ -1714,20 +1884,28 @@
args.push(action);
args.push(bare_unit);
let output = std::process::Command::new("systemctl")
let out = std::process::Command::new("systemctl")
.args(&args)
.output()?;
if action == "status" {
println!("{}", String::from_utf8_lossy(&output.stdout));
} else if output.status.success() {
let content = String::from_utf8_lossy(&out.stdout).to_string();
output::line(&content);
return Ok(output::Response::read(serde_json::json!({
"content": content,
"format": "log",
})));
} else if out.status.success() {
output::success(&format!("Service {action}ed"));
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(format!("systemctl {action} failed: {stderr}").into());
}
}
Ok(())
Ok(output::Response::ok(
"service",
serde_json::json!({ "instance": instance, "action": action }),
))
}
// ── macOS modern launchctl (bootstrap/bootout/print) ────────────────
@@ -1856,20 +2034,18 @@
#[cfg(target_os = "macos")]
fn launchctl_print(
record: &runner::service_mode::InstallRecord,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let domain = launchctl_domain(record);
let target = format!("{domain}/{}", record.unit_or_label);
let out = launchctl_run(&["print", &target])?;
if out.status.success() {
println!("{}", String::from_utf8_lossy(&out.stdout));
return Ok(());
return Ok(String::from_utf8_lossy(&out.stdout).to_string());
}
let stderr = String::from_utf8_lossy(&out.stderr).to_lowercase();
if stderr.contains("unrecognized subcommand") {
// Fall back to deprecated `list`.
let fallback = launchctl_run(&["list", &record.unit_or_label])?;
println!("{}", String::from_utf8_lossy(&fallback.stdout));
return Ok(());
return Ok(String::from_utf8_lossy(&fallback.stdout).to_string());
}
Err(format!("launchctl print failed: {stderr}").into())
}
@@ -1895,6 +2071,6 @@
#[cfg(all(unix, not(target_os = "macos")))]
fn launchctl_print(
_record: &runner::service_mode::InstallRecord,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
Err("launchctl unavailable on this platform".into())
}
@@ -1904,7 +2080,7 @@
async fn list(
org: Option<&str>,
repo: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
) -> Result<output::Response, Box<dyn std::error::Error + Send + Sync>> {
let client = Client::from_config()?;
let path = match (org, repo) {
@@ -1926,7 +2102,7 @@
} else if let Some(arr) = resp.get("data") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else {
serde_json::from_value(resp).unwrap_or_default()
serde_json::from_value(resp.clone()).unwrap_or_default()
};
output::header("Runners");
@@ -1964,10 +2140,11 @@
output::print_table(&["ID", "NAME", "STATUS", "LABELS", "OS", "ARCH"], &rows);
Ok(())
// Echo the server payload verbatim — full (untruncated) ids and all.
Ok(output::Response::read(resp))
}
async fn view(id: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
async fn view(id: &str) -> Result<output::Response, Box<dyn std::error::Error + Send + Sync>> {
let client = Client::from_config()?;
let resp: serde_json::Value = client.get(&format!("/runners/{id}")).await?;
@@ -2001,14 +2178,15 @@
output::detail("Last seen", &output::format_time(last_seen));
}
Ok(())
// Echo the (unwrapped) server runner object verbatim.
Ok(output::Response::read(runner.clone()))
}
async fn update(
id: &str,
labels: Option<&str>,
name: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
) -> Result<output::Response, Box<dyn std::error::Error + Send + Sync>> {
let client = Client::from_config()?;
let mut body = serde_json::Map::new();
@@ -2019,29 +2197,42 @@
body.insert("name".into(), serde_json::Value::String(n.to_string()));
}
let _resp: serde_json::Value = client
let resp: serde_json::Value = client
.patch(&format!("/runners/{id}"), &serde_json::Value::Object(body))
.await?;
output::success(&format!("Updated runner {}", &id[..8.min(id.len())]));
output::success(&format!(
"Updated runner {}",
id.chars().take(8).collect::<String>()
));
Ok(())
// Unwrap any server double-wrap ({"runner":…}/{"data":…}) to the
// inner object for the {"ok":true,"runner":…} envelope.
let runner = resp
.get("runner")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or(resp);
Ok(output::Response::ok("runner", runner))
}
async fn remove(id: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
async fn remove(id: &str) -> Result<output::Response, Box<dyn std::error::Error + Send + Sync>> {
let client = Client::from_config()?;
client.delete_empty(&format!("/runners/{id}")).await?;
output::success(&format!("Removed runner {}", &id[..8.min(id.len())]));
output::success(&format!(
"Removed runner {}",
id.chars().take(8).collect::<String>()
));
Ok(())
Ok(output::Response::deleted(id))
}
async fn token(
org: Option<&str>,
repo: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
) -> Result<output::Response, Box<dyn std::error::Error + Send + Sync>> {
let client = Client::from_config()?;
let path = match (org, repo) {
@@ -2058,18 +2249,26 @@
let resp: serde_json::Value = client.post(&path, &serde_json::json!({})).await?;
// A success envelope with a placeholder credential is worse than an error:
// a script would persist "?" as the token. Fail loudly if the server didn't
// return one.
let token = resp
.pointer("/token/value")
.or_else(|| resp.pointer("/data/token"))
.or_else(|| resp.get("token"))
.and_then(|v| v.as_str())
.unwrap_or("?");
.ok_or("server response did not contain a registration token")?;
output::success("Generated registration token:");
println!("\n {token}\n");
// Human-only display of the token value (no-op under --json, where the
// token is carried in the returned Response instead of leaking here).
output::line(&format!("\n {token}\n"));
output::info("Use with: anvil runner configure --url <URL> --token <token>");
Ok(())
Ok(output::Response::ok(
"token",
serde_json::Value::String(token.to_string()),
))
}
#[cfg(test)]
▸
src/commands/ssh_key.rs
+12
−7
@@ -38,7 +38,7 @@
inserted_at: Option<String>,
}
pub async fn run(args: SshKeyArgs) -> Result<(), Box<dyn std::error::Error>> {
pub async fn run(args: SshKeyArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
match args.command {
SshKeyCommand::List => list().await,
SshKeyCommand::Add { name, key_file } => add(&name, &key_file).await,
@@ -46,7 +46,7 @@
}
}
async fn list() -> Result<output::Response, Box<dyn std::error::Error>> {
async fn list() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let resp: serde_json::Value = client.get("/user/ssh-keys").await?;
let keys: Vec<SshKey> = resp
@@ -73,10 +73,15 @@
})
.collect();
output::print_table(&["ID", "NAME", "FINGERPRINT", "LAST USED", "ADDED"], &rows);
let items: Vec<serde_json::Value> = resp
.get("ssh_keys")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
Ok(output::Response::items(items))
Ok(())
}
async fn add(name: &str, key_file: &str) -> Result<output::Response, Box<dyn std::error::Error>> {
async fn add(name: &str, key_file: &str) -> Result<(), Box<dyn std::error::Error>> {
let public_key = std::fs::read_to_string(key_file)
.map_err(|e| format!("Failed to read key file '{}': {}", key_file, e))?;
let client = Client::from_config()?;
@@ -92,12 +97,12 @@
.unwrap_or("unknown");
output::success(&format!("Added SSH key '{name}'"));
output::detail("Fingerprint", fingerprint);
Ok(())
Ok(output::Response::ok("ssh_key", resp))
}
async fn remove(id: &str) -> Result<(), Box<dyn std::error::Error>> {
async fn remove(id: &str) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
client.delete_empty(&format!("/user/ssh-keys/{id}")).await?;
output::success(&format!("Removed SSH key {id}"));
Ok(())
Ok(output::Response::deleted(id))
}
▸
src/commands/update.rs
+18
−5
@@ -19,6 +19,6 @@
pub no_verify: bool,
}
pub async fn run(args: UpdateArgs) -> Result<(), Box<dyn std::error::Error>> {
pub async fn run(args: UpdateArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
let config = Config::load()?;
let server = config.server_url()?.trim_end_matches('/').to_string();
@@ -32,14 +32,24 @@
output::detail("Current", ¤t);
output::detail("Latest", &latest.version);
if !args.force && current == latest.version {
let update_available = current != latest.version;
if !args.force && !update_available {
output::success("Already up to date.");
return Ok(());
return Ok(output::Response::read(serde_json::json!({
"current": current,
"latest": latest.version,
"update_available": false,
})));
}
if args.check {
output::info(&format!("Update available: {current} → {}", latest.version));
return Ok(());
return Ok(output::Response::read(serde_json::json!({
"current": current,
"latest": latest.version,
"update_available": update_available,
})));
}
let (os, arch) = detect_platform()?;
@@ -61,7 +71,10 @@
latest.version,
exe_path.display()
));
Ok(())
Ok(output::Response::ok(
"updated",
serde_json::json!({ "version": latest.version }),
))
}
#[derive(Debug, serde::Deserialize)]
▸
src/lib.rs
+13
−0
@@ -1,0 +1,13 @@
//! Library crate for `anvil`.
//!
//! The binary (`src/main.rs`) is a thin shell over this crate. Exposing the
//! modules as a library lets integration tests introspect the real command
//! surface — e.g. `tests/json_output.rs` walks `commands::Cli`'s clap tree to
//! prove every leaf is covered by the JSON contract.
pub mod client;
pub mod commands;
pub mod config;
pub mod output;
pub mod platform;
pub mod runner;
▸
src/main.rs
+12
−10
@@ -1,9 +1,3 @@
mod client;
mod commands;
mod config;
mod output;
mod platform;
use anvil::{commands, output};
mod runner;
use clap::Parser;
use commands::Cli;
@@ -16,8 +10,8 @@
// unaffected. See `runner::service_windows`.
#[cfg(windows)]
{
if anvil::runner::service_windows::is_service_invocation() {
if runner::service_windows::is_service_invocation() {
if let Err(e) = runner::service_windows::run_dispatcher() {
if let Err(e) = anvil::runner::service_windows::run_dispatcher() {
eprintln!("Error (service dispatcher): {e}");
std::process::exit(1);
}
@@ -35,7 +29,15 @@
.expect("failed to build Tokio runtime");
if let Err(e) = runtime.block_on(commands::run(cli)) {
// Under --json, stdout must be JSON even on failure, so scripts get a
// parseable `{"ok":false,"error":…}` — unless the command already wrote
// its own JSON (e.g. a coverage gate that prints then exits nonzero), in
// which case a second document would corrupt stdout.
if output::is_json() && !output::json_emitted() {
eprintln!("Error: {e}");
output::print_json(&serde_json::json!({ "ok": false, "error": e.to_string() }));
} else if !output::is_json() {
eprintln!("Error: {e}");
}
std::process::exit(1);
}
}
▸
src/output.rs
+129
−15
@@ -1,15 +1,45 @@
//! Output layer for the CLI.
//!
//! The contract: under `--json`, stdout contains exactly one JSON value and
//! nothing else. Two rules make that correct-by-construction:
//!
//! 1. The human helpers (`detail`/`header`/`success`/`info`/`line`/`print_table`)
//! self-suppress under `--json`, and raw `println!` in a command is a lint
//! error — so human text can never reach a JSON consumer's stdout.
//! 2. Every command returns a [`Response`]; `commands::run` [`emit`]s its JSON
//! payload — so a command can never forget to produce JSON.
//!
//! Envelope: reads echo the server payload (or `{"items":[…]}` for client-built
//! lists); mutations are `{"ok":true,…}`; errors are `{"ok":false,"error":…}`
//! (emitted by `main`). Diagnostics and progress go to stderr (`warn`/`error`).
// This module IS the renderer — the one place allowed to write stdout directly.
// Everywhere else, `println!`/`print!` is banned (see clippy.toml).
#![allow(clippy::disallowed_macros)]
use colored::Colorize;
use std::sync::atomic::{AtomicBool, Ordering};
/// Output format: human-readable (default) vs. JSON for scripting.
/// Set once at program start by main.rs based on the top-level --json flag.
static JSON_MODE: AtomicBool = AtomicBool::new(false);
/// Set once a JSON value has been written to stdout, so `main` knows whether a
/// later error still needs a `{"ok":false,…}` envelope or would double up.
static JSON_EMITTED: AtomicBool = AtomicBool::new(false);
/// Switch all subsequent renderers into JSON mode. Idempotent.
pub fn set_json_mode(enabled: bool) {
JSON_MODE.store(enabled, Ordering::Relaxed);
}
/// Whether any JSON has already been printed to stdout this run. `main` uses
/// this to avoid emitting a second (error) document after a command that
/// already wrote its own — e.g. a coverage gate that prints then exits nonzero.
pub fn json_emitted() -> bool {
JSON_EMITTED.load(Ordering::Relaxed)
}
/// Whether the CLI was invoked with --json. List/view subcommands check
/// this and emit raw JSON instead of a table.
pub fn is_json() -> bool {
@@ -19,6 +49,7 @@
/// Pretty-print a serde_json::Value to stdout. Used by list/view
/// subcommands when `--json` is in effect.
pub fn print_json(value: &serde_json::Value) {
JSON_EMITTED.store(true, Ordering::Relaxed);
match serde_json::to_string_pretty(value) {
Ok(s) => println!("{s}"),
// to_string_pretty only fails on non-string map keys; serde_json::Value
@@ -27,42 +58,122 @@
}
}
/// 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));
/// The result of a leaf command: the JSON payload to emit under `--json`, and
/// an optional gate exit (a non-zero code + stderr message, used by coverage
/// gates like `requirement status --strict` that still emit their JSON first).
///
/// Every command's `run` returns one of these; `commands::run` is the single
/// place that writes it to stdout under `--json`. Human output stays in the
/// leaf via the `output::` helpers below, which no-op under `--json` — so a
/// command can neither leak human text to a JSON consumer (the helpers
/// self-suppress and raw `println!` is banned) nor forget to produce JSON (the
/// compiler requires a `Response`). See the module docs and `commands::run`.
#[derive(Debug, Clone)]
pub struct Response {
/// The fully-enveloped JSON payload, emitted verbatim under `--json`.
pub json: serde_json::Value,
/// `Some((code, msg))` for a gate failure: the JSON is still emitted, then
/// the process exits `code` (with `msg` on stderr in human mode).
pub exit: Option<(i32, String)>,
}
impl Response {
/// A read that echoes a server object/array verbatim (bare payload).
pub fn read(v: serde_json::Value) -> Self {
/// Print a key-value detail line.
Self {
json: v,
exit: None,
}
}
/// A read over a client-built list with no server envelope → `{"items":[…]}`.
pub fn items(items: Vec<serde_json::Value>) -> Self {
Self::read(serde_json::json!({ "items": items }))
}
/// A mutation → `{"ok":true, <key>: value}`.
pub fn ok(key: &str, value: serde_json::Value) -> Self {
let mut m = serde_json::Map::new();
m.insert("ok".to_string(), serde_json::Value::Bool(true));
m.insert(key.to_string(), value);
Self::read(serde_json::Value::Object(m))
}
/// A delete → `{"ok":true,"deleted":"<id>"}`.
pub fn deleted(id: &str) -> Self {
Self::ok("deleted", serde_json::Value::String(id.to_string()))
}
/// A read/mutation that also fails a gate: emit `json`, then exit `code`.
pub fn gate(json: serde_json::Value, code: i32, msg: String) -> Self {
Self {
json,
exit: Some((code, msg)),
}
}
/// An empty success envelope for a mutation with nothing to echo.
pub fn done() -> Self {
Self::ok("ok", serde_json::Value::Bool(true))
}
}
/// Render a [`Response`] to stdout: under `--json`, the payload verbatim; in
/// human mode, nothing (the leaf already printed via the guarded helpers).
/// The single stdout-writing point for command results.
pub fn emit(resp: &Response) {
if is_json() {
print_json(&resp.json);
}
}
/// Print a key-value detail line. No-op under `--json`.
pub fn detail(key: &str, value: &str) {
if is_json() {
return;
}
println!("{:>14} {}", key.bold(), value);
}
/// Print a section header.
/// Print a section header. No-op under `--json`.
pub fn header(text: &str) {
if is_json() {
return;
}
println!("\n{}", text.bold().underline());
}
/// Print a success message.
/// Print a plain human line to stdout. No-op under `--json`. Use this instead
/// of a raw `println!` in a command so JSON mode stays clean.
pub fn line(text: &str) {
if is_json() {
return;
}
println!("{text}");
}
/// Print a success message. No-op under `--json`.
pub fn success(msg: &str) {
if is_json() {
return;
}
println!("{} {msg}", "✓".green().bold());
}
/// Print a warning message to stderr (safe under `--json`).
/// Print a warning message.
pub fn warn(msg: &str) {
eprintln!("{} {msg}", "!".yellow().bold());
}
/// Print an error message.
/// Print an error message to stderr (safe under `--json`).
pub fn error(msg: &str) {
eprintln!(" {} {}", "✗".red().bold(), msg.red());
}
/// Print an info message.
/// Print an info message. No-op under `--json`.
pub fn info(msg: &str) {
if is_json() {
return;
}
println!("{} {msg}", "·".blue());
}
@@ -88,8 +199,11 @@
}
}
/// Print items in a simple table format. No-op under `--json`.
/// Print items in a simple table format.
pub fn print_table(headers: &[&str], rows: &[Vec<String>]) {
if is_json() {
return;
}
if rows.is_empty() {
println!(" (none)");
return;
▸
tests/adversarial_agent_sshkey_update.rs
+560
−0
@@ -1,0 +1,560 @@
//! Adversarial JSON-contract tests for `agent`, `ssh-key`, and `update`.
//!
//! Hunts the migration bug patterns: double-nest, wrong container, discarded
//! keys, error-path masking, empty-list leaks, and crashes. Each test either
//! asserts the contract holds, or documents a CONFIRMED bug in the command code
//! (kept green so the suite compiles/passes).
use serde_json::{json, Value};
use std::process::Output;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
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("run anvil")
}
fn stdout_json(out: &Output) -> Value {
let s = String::from_utf8_lossy(&out.stdout);
serde_json::from_str(&s).unwrap_or_else(|e| {
panic!(
"stdout is not valid JSON ({e}):\nSTDOUT:\n{s}\nSTDERR:\n{}",
String::from_utf8_lossy(&out.stderr)
)
})
}
// ─────────────────────────────────────────────────────────────────────────────
// agent trigger (POST /api/v1/{org}/{repo}/agents/{name}/trigger)
// ─────────────────────────────────────────────────────────────────────────────
// Happy path: mutation envelope {"ok":true,"agent":<server body>}. Assert the
// whole server body is reachable under "agent" and there is NO double nest
// (v.agent.agent must be null since the body has no "agent" key).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_trigger_ok_envelope_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/agents/deploy-bot/trigger"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"session": { "id": "sess_abc123", "status": "running" }
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"agent",
"trigger",
"deploy-bot",
"--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);
// Session data reachable under the "agent" noun.
assert_eq!(v["agent"]["session"]["id"], "sess_abc123");
// No double-nest.
assert!(v["agent"]["agent"].is_null(), "unexpected double nest: {v}");
}
// Error path: server 500 -> stdout must be {"ok":false,"error":...} + nonzero
// exit, never exit 0 masking the failure and never a human line.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_trigger_server_error_is_json_error_and_nonzero() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/agents/deploy-bot/trigger"))
.respond_with(ResponseTemplate::new(500).set_body_json(json!({"error":"boom"})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"agent",
"trigger",
"deploy-bot",
"--repo",
"test-org/test-repo",
],
);
assert!(!out.status.success(), "expected nonzero exit on 500");
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
assert!(v["error"].is_string(), "error field missing: {v}");
}
// ─────────────────────────────────────────────────────────────────────────────
// agent list (GET /api/v1/{org}/{repo}/agents) positional repo
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_list_echoes_server_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/agents"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"agents": [{"name":"deploy-bot","description":"deploys","trigger":"manual"}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["agent", "list", "test-org/test-repo"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["agents"][0]["name"], "deploy-bot");
}
// Empty collection must be valid JSON, never a human "(none)" line on stdout.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_list_empty_is_clean_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/agents"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"agents": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["agent", "list", "test-org/test-repo"]);
assert!(out.status.success());
let s = String::from_utf8_lossy(&out.stdout);
assert!(!s.contains("(none)"), "human placeholder leaked: {s}");
let v = stdout_json(&out);
assert!(v["agents"].as_array().unwrap().is_empty());
}
// ─────────────────────────────────────────────────────────────────────────────
// agent view (GET /api/v1/{org}/{repo}/agents/{name})
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_view_echoes_wrapped_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/agents/deploy-bot"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"agent": {"name":"deploy-bot","description":"d","trigger":"manual","model":"opus"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"agent",
"view",
"deploy-bot",
"--repo",
"test-org/test-repo",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
// Read command echoes the server body verbatim (bare payload).
assert_eq!(v["agent"]["name"], "deploy-bot");
}
// ─────────────────────────────────────────────────────────────────────────────
// agent sessions (GET /api/v1/{org}/{repo}/agents/{name}/sessions)
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_sessions_empty_is_clean_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(
"/api/v1/test-org/test-repo/agents/deploy-bot/sessions",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"sessions": []})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"agent",
"sessions",
"deploy-bot",
"--repo",
"test-org/test-repo",
],
);
assert!(out.status.success());
let s = String::from_utf8_lossy(&out.stdout);
assert!(!s.contains("(none)"), "human placeholder leaked: {s}");
let v = stdout_json(&out);
assert!(v["sessions"].as_array().unwrap().is_empty());
}
// ─────────────────────────────────────────────────────────────────────────────
// agent session (GET /api/v1/{org}/{repo}/agents/sessions/{id})
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_session_echoes_server_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(
"/api/v1/test-org/test-repo/agents/sessions/sess_abc123",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"session": {"id":"sess_abc123","status":"running","agent_name":"deploy-bot"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"agent",
"session",
"sess_abc123",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["session"]["id"], "sess_abc123");
}
// ─────────────────────────────────────────────────────────────────────────────
// agent approve (POST /api/v1/{org}/{repo}/agents/sessions/{id}/approve)
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_approve_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(
"/api/v1/test-org/test-repo/agents/sessions/sess_abc123/approve",
))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"agent",
"approve",
"sess_abc123",
"--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["approved"]["session"], "sess_abc123");
}
// CONFIRMED BUG (agent.rs:290, and identically :312 reject, :255 session):
// `&id[..8.min(id.len())]` byte-slices the session id to 8 bytes for the
// human confirmation string. That slice argument is evaluated even under
// --json (output::success only no-ops the *print*, not the format! that
// builds its argument). A session id whose byte 8 falls inside a multibyte
// UTF-8 char panics ("byte index 8 is not a char boundary"). The process is
// killed by the panic: stdout gets NO JSON (contract requires {"ok":false}
// on failure under --json), only a Rust panic on stderr.
//
// FIXED: the id truncation is now char-safe (id.chars().take(8)), so a
// multibyte id no longer panics — stdout stays valid JSON.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_approve_multibyte_id_stays_valid_json() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
.mount(&server)
.await;
// "€€€" = 9 bytes; byte 8 falls inside the third '€'. A byte-slice would
// panic here; the char-based truncation must not.
let out = run_json(
&server.uri(),
&["agent", "approve", "€€€", "--repo", "test-org/test-repo"],
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("char boundary") && !stderr.contains("panic"),
"must not panic on a multibyte id; stderr:\n{stderr}"
);
// Whatever the outcome, stdout is exactly one JSON value.
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
serde_json::from_str::<Value>(&stdout).is_ok(),
"stdout must be valid JSON; got: {stdout}"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// agent reject (POST /api/v1/{org}/{repo}/agents/sessions/{id}/reject)
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_reject_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(
"/api/v1/test-org/test-repo/agents/sessions/sess_abc123/reject",
))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"agent",
"reject",
"sess_abc123",
"--repo",
"test-org/test-repo",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["rejected"]["session"], "sess_abc123");
}
// ─────────────────────────────────────────────────────────────────────────────
// ssh-key list (GET /api/v1/user/ssh-keys) -> client-built {"items":[...]}
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ssh_key_list_items_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/user/ssh-keys"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ssh_keys": [{"id":"k1","name":"laptop","fingerprint":"SHA256:aaa"}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ssh-key", "list"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
// Contract: client-built list -> {"items":[...]}, not a bare array.
assert!(v["items"].is_array(), "expected items envelope: {v}");
assert_eq!(v["items"][0]["id"], "k1");
assert_eq!(v["items"][0]["fingerprint"], "SHA256:aaa");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ssh_key_list_empty_is_items_empty_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/user/ssh-keys"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ssh_keys": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ssh-key", "list"]);
assert!(out.status.success());
let s = String::from_utf8_lossy(&out.stdout);
assert!(!s.contains("(none)"), "human placeholder leaked: {s}");
let v = stdout_json(&out);
assert_eq!(v["items"], json!([]));
}
// ─────────────────────────────────────────────────────────────────────────────
// ssh-key add (POST /api/v1/user/ssh-keys) -> {"ok":true,"ssh_key":<body>}
// ─────────────────────────────────────────────────────────────────────────────
// The codebase mutation convention (see create_requirement in json_output.rs)
// is a FLAT server body wrapped by Response::ok. add() reads resp.get(
// "fingerprint") at TOP level, confirming it assumes flat. Assert flat body
// wraps correctly with no double nest and the fingerprint reachable.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ssh_key_add_flat_body_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/user/ssh-keys"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id":"k1","name":"laptop","fingerprint":"SHA256:bbb"
})))
.mount(&server)
.await;
let key_file = std::env::temp_dir().join("adv_ssh_key_flat.pub");
std::fs::write(&key_file, "ssh-ed25519 AAAAC3Nz test@host\n").unwrap();
let out = run_json(
&server.uri(),
&[
"ssh-key",
"add",
"--name",
"laptop",
"--key-file",
key_file.to_str().unwrap(),
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["ssh_key"]["fingerprint"], "SHA256:bbb");
// No double nest under the flat convention.
assert!(
v["ssh_key"]["ssh_key"].is_null(),
"unexpected double nest: {v}"
);
}
// Latent-risk documentation (NOT reported as a confirmed bug): add() does not
// unwrap a "ssh_key"-wrapped server body. IF the server ever returned
// {"ssh_key":{...}} (as the plural list endpoint wraps under "ssh_keys"), the
// output would double-nest to {"ok":true,"ssh_key":{"ssh_key":{...}}} AND the
// human fingerprint line would read "unknown". The mutation convention in this
// repo is a flat body, so this is a latent hazard, not an active bug. This test
// pins the CURRENT behavior so a future contract change is caught.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ssh_key_add_wrapped_body_current_behavior() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/user/ssh-keys"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"ssh_key": {"id":"k1","name":"laptop","fingerprint":"SHA256:ccc"}
})))
.mount(&server)
.await;
let key_file = std::env::temp_dir().join("adv_ssh_key_wrapped.pub");
std::fs::write(&key_file, "ssh-ed25519 AAAAC3Nz test@host\n").unwrap();
let out = run_json(
&server.uri(),
&[
"ssh-key",
"add",
"--name",
"laptop",
"--key-file",
key_file.to_str().unwrap(),
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
// CURRENT behavior: no unwrap, so the wrap is preserved verbatim.
assert_eq!(v["ssh_key"]["ssh_key"]["fingerprint"], "SHA256:ccc");
}
// ─────────────────────────────────────────────────────────────────────────────
// ssh-key remove (DELETE /api/v1/user/ssh-keys/{id})
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ssh_key_remove_deleted_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/user/ssh-keys/k1"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ssh-key", "remove", "k1"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["deleted"], "k1");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ssh_key_remove_error_is_json_error_and_nonzero() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/user/ssh-keys/nope"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!({"error":"not found"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ssh-key", "remove", "nope"]);
assert!(!out.status.success(), "expected nonzero exit on 404");
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
assert!(v["error"].is_string(), "error field missing: {v}");
}
// ─────────────────────────────────────────────────────────────────────────────
// update --check (GET {server}/runner/version — NO /api/v1 prefix)
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn update_check_reports_available_shape() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/runner/version"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"version": "9999.0.0",
"platforms": ["linux_amd64"]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["update", "--check"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert!(v["current"].is_string(), "current missing: {v}");
assert_eq!(v["latest"], "9999.0.0");
assert_eq!(v["update_available"], true);
}
// Error path: /runner/version returns 500 -> the reqwest .json() fails, the
// error propagates, and main emits {"ok":false,"error":...} with nonzero exit.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn update_check_server_error_is_json_error_and_nonzero() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/runner/version"))
.respond_with(ResponseTemplate::new(500).set_body_string("kaboom"))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["update", "--check"]);
assert!(!out.status.success(), "expected nonzero exit on 500");
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
assert!(v["error"].is_string(), "error field missing: {v}");
}
▸
tests/adversarial_auth_repo_commit_branch.rs
+534
−0
@@ -1,0 +1,534 @@
//! Adversarial JSON-contract tests for the auth / repo / commit / branch surface.
//!
//! Every test drives the real `anvil` binary with `--json` against a mock Anvil
//! server and asserts the exact envelope shape the contract in `src/output.rs`
//! promises. `ANVIL_CONFIG` is pointed at a throwaway file per run so commands
//! that write/delete the config (rotate, set-default, logout) never touch the
//! developer's real credentials.
use serde_json::{json, Value};
use std::process::Output;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// Run the binary under `--json`, isolating the on-disk config so mutating
/// commands can't clobber real credentials. Returns the raw process Output.
fn run_json(server_uri: &str, args: &[&str]) -> Output {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos();
let cfg = std::env::temp_dir().join(format!("anvil-adv-{}-{nanos}.json", std::process::id()));
std::process::Command::new(env!("CARGO_BIN_EXE_anvil"))
.arg("--json")
.args(args)
.env("ANVIL_SERVER_URL", server_uri)
.env("ANVIL_TOKEN", "test-token")
.env("ANVIL_CONFIG", cfg)
.output()
.expect("run anvil")
}
/// Parse stdout as exactly one JSON value (the core contract) and return it.
fn stdout_json(out: &Output) -> Value {
let s = String::from_utf8_lossy(&out.stdout);
serde_json::from_str(&s).unwrap_or_else(|e| {
panic!(
"stdout was not a single JSON value ({e}). stdout=<<<{s}>>> stderr=<<<{}>>>",
String::from_utf8_lossy(&out.stderr)
)
})
}
// ---------------------------------------------------------------------------
// auth status (no server call; reads config only)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn auth_status_logged_in_is_bare_read() {
// With ANVIL_TOKEN + ANVIL_SERVER_URL set, status reports logged_in.
let server = MockServer::start().await;
let out = run_json(&server.uri(), &["auth", "status"]);
assert!(out.status.success(), "status should exit 0");
let v = stdout_json(&out);
assert_eq!(v["logged_in"], json!(true));
assert_eq!(v["server"], json!(server.uri()));
// No {"ok":...} wrapper — status is a read, not a mutation.
assert!(
v.get("ok").is_none(),
"status must not be an ok-envelope: {v}"
);
assert!(
v.get("default_repo").is_some(),
"default_repo key present (may be null)"
);
}
// ---------------------------------------------------------------------------
// auth logout (deletes config file)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn auth_logout_is_ok_envelope() {
let server = MockServer::start().await;
let out = run_json(&server.uri(), &["auth", "logout"]);
assert!(out.status.success());
let v = stdout_json(&out);
// Contract: mutation -> {"ok":true,<noun>:<value>}
assert_eq!(v["ok"], json!(true));
// noun is "logout" -> {"removed": bool}; no double-nest under "logout".
assert!(v["logout"].is_object(), "expected logout object: {v}");
assert!(
v["logout"]["removed"].is_boolean(),
"removed should be bool: {v}"
);
assert!(v["logout"]["logout"].is_null(), "no double-nest: {v}");
}
// ---------------------------------------------------------------------------
// auth rotate (POST /user/tokens/rotate)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn auth_rotate_yes_is_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/user/tokens/rotate"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"token": "anvil_newtok",
"revoked": {"token_prefix": "old12345"}
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["auth", "rotate", "--yes"]);
assert!(
out.status.success(),
"rotate --yes should succeed: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
// noun "rotated" holds the server body verbatim; the real token is reachable.
assert_eq!(v["rotated"]["token"], json!("anvil_newtok"));
assert_eq!(v["rotated"]["revoked"]["token_prefix"], json!("old12345"));
// No double-nest of the noun.
assert!(v["rotated"]["rotated"].is_null(), "no double-nest: {v}");
}
#[tokio::test]
async fn auth_rotate_without_yes_under_json_is_error_envelope() {
// Under --json, rotate refuses to prompt and must return the error envelope,
// NOT prompt and NOT exit 0. It must not hit the server.
let server = MockServer::start().await;
let out = run_json(&server.uri(), &["auth", "rotate"]);
assert!(
!out.status.success(),
"must exit nonzero when refusing to rotate"
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false), "expected error envelope: {v}");
assert!(v["error"].is_string(), "error must be a string: {v}");
}
#[tokio::test]
async fn auth_rotate_server_omits_token_is_error() {
// Server accepts rotation but returns no token -> command must error
// (old token is dead), producing {"ok":false,...} + nonzero exit.
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/user/tokens/rotate"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"revoked": {}})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["auth", "rotate", "--yes"]);
assert!(!out.status.success(), "missing token must be an error exit");
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false), "expected error envelope: {v}");
}
#[tokio::test]
async fn auth_rotate_server_5xx_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/user/tokens/rotate"))
.respond_with(ResponseTemplate::new(500).set_body_json(json!({"error": "boom"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["auth", "rotate", "--yes"]);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(
v["ok"],
json!(false),
"server error must surface as error envelope: {v}"
);
}
// ---------------------------------------------------------------------------
// repo list (GET /repos ; client-built list -> {"items":[...]})
// ---------------------------------------------------------------------------
#[tokio::test]
async fn repo_list_wraps_in_items() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"repositories": [
{"slug": "a", "visibility": "private", "org": {"slug": "acme"}},
{"slug": "b", "visibility": "public", "org": {"slug": "acme"}}
]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["repo", "list"]);
assert!(out.status.success());
let v = stdout_json(&out);
// Contract: client-built list -> {"items":[...]}, never a bare array.
assert!(v.is_object(), "expected object envelope, got: {v}");
assert!(v["items"].is_array(), "expected items array: {v}");
assert_eq!(v["items"].as_array().unwrap().len(), 2);
assert_eq!(v["items"][0]["slug"], json!("a"));
}
#[tokio::test]
async fn repo_list_empty_is_empty_items_not_none_line() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"repositories": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["repo", "list"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(
v["items"],
json!([]),
"empty list must be [], no (none) leak: {v}"
);
// Ensure the human "(none)" table line never leaked to stdout.
let s = String::from_utf8_lossy(&out.stdout);
assert!(
!s.contains("(none)"),
"human (none) leaked into JSON stdout: {s}"
);
}
#[tokio::test]
async fn repo_list_bare_array_body_still_wraps_in_items() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{"slug": "solo", "org": {"slug": "acme"}}
])))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["repo", "list"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(
v["items"].is_array(),
"bare array body must still wrap in items: {v}"
);
assert_eq!(v["items"][0]["slug"], json!("solo"));
}
#[tokio::test]
async fn repo_list_org_filter_applies_to_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"repositories": [
{"slug": "a", "org": {"slug": "acme"}},
{"slug": "b", "org": {"slug": "widgets"}}
]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["repo", "list", "--org", "widget"]);
assert!(out.status.success());
let v = stdout_json(&out);
let items = v["items"].as_array().unwrap();
assert_eq!(items.len(), 1, "org filter must apply to JSON too: {v}");
assert_eq!(items[0]["slug"], json!("b"));
}
#[tokio::test]
async fn repo_list_server_error_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos"))
.respond_with(ResponseTemplate::new(503).set_body_json(json!({"error": "down"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["repo", "list"]);
assert!(!out.status.success(), "5xx must be nonzero exit");
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false), "expected error envelope: {v}");
}
// ---------------------------------------------------------------------------
// repo view (GET /{org}/{repo} ; bare read echo)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn repo_view_is_bare_read() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/widget"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"slug": "widget",
"visibility": "private",
"default_branch": "main",
"org": {"slug": "acme"}
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["repo", "view", "acme/widget"]);
assert!(out.status.success());
let v = stdout_json(&out);
// Bare echo — no ok wrapper, fields reachable at top level.
assert!(
v.get("ok").is_none(),
"view is a read, not ok-envelope: {v}"
);
assert_eq!(v["slug"], json!("widget"));
assert_eq!(v["default_branch"], json!("main"));
}
#[tokio::test]
async fn repo_view_404_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/ghost"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!({"error": "not_found"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["repo", "view", "acme/ghost"]);
assert!(!out.status.success(), "404 must exit nonzero");
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false), "expected error envelope: {v}");
}
// ---------------------------------------------------------------------------
// repo create (POST /{org}/repos ; {"ok":true,"repo":<value>})
// ---------------------------------------------------------------------------
#[tokio::test]
async fn repo_create_flat_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/acme/repos"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"slug": "newrepo",
"visibility": "private"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["repo", "create", "--name", "newrepo", "--org", "acme"],
);
assert!(
out.status.success(),
"stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
// repo field holds the created object; the slug is reachable, no double-nest.
assert_eq!(v["repo"]["slug"], json!("newrepo"));
assert!(v["repo"]["repo"].is_null(), "no double-nest of repo: {v}");
}
#[tokio::test]
async fn repo_create_error_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/acme/repos"))
.respond_with(ResponseTemplate::new(422).set_body_json(json!({"error": "name taken"})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["repo", "create", "--name", "dup", "--org", "acme"],
);
assert!(!out.status.success(), "422 must exit nonzero");
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false), "expected error envelope: {v}");
}
// ---------------------------------------------------------------------------
// repo set-default (no server call; writes config)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn repo_set_default_is_ok_envelope() {
let server = MockServer::start().await;
let out = run_json(&server.uri(), &["repo", "set-default", "acme/widget"]);
assert!(
out.status.success(),
"stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
// noun default_repo is a bare string per the source.
assert_eq!(v["default_repo"], json!("acme/widget"), "shape: {v}");
}
#[tokio::test]
async fn repo_set_default_invalid_format_is_error_envelope() {
let server = MockServer::start().await;
// No slash -> resolve_repo rejects the format before any config write.
let out = run_json(&server.uri(), &["repo", "set-default", "notaslug"]);
assert!(
!out.status.success(),
"invalid repo format must exit nonzero"
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false), "expected error envelope: {v}");
}
// ---------------------------------------------------------------------------
// commit list (GET /{org}/{repo}/commits ; read echo)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn commit_list_echoes_server_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/widget/commits"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"commits": [
{"oid": "abcdef1234", "message": "first\nbody", "author": {"name": "cole"}, "time": "2026-01-01T00:00:00Z"}
]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["commit", "list", "acme/widget"]);
assert!(out.status.success());
let v = stdout_json(&out);
// Contract: read echoes the server object verbatim.
assert!(
v["commits"].is_array(),
"expected echoed commits array: {v}"
);
assert_eq!(v["commits"][0]["oid"], json!("abcdef1234"));
assert!(v.get("ok").is_none(), "read must not be ok-enveloped: {v}");
}
#[tokio::test]
async fn commit_list_empty_no_none_leak() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/widget/commits"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"commits": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["commit", "list", "acme/widget"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["commits"], json!([]));
let s = String::from_utf8_lossy(&out.stdout);
assert!(
!s.contains("(none)"),
"human (none) leaked to JSON stdout: {s}"
);
}
#[tokio::test]
async fn commit_list_error_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/widget/commits"))
.respond_with(ResponseTemplate::new(500).set_body_json(json!({"error": "boom"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["commit", "list", "acme/widget"]);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false), "expected error envelope: {v}");
}
// ---------------------------------------------------------------------------
// branch list (GET /{org}/{repo}/branches ; read echo)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn branch_list_echoes_server_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/widget/branches"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"branches": [
{"name": "main", "sha": "deadbeef00"},
{"name": "dev", "target": "cafef00d11"}
]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["branch", "list", "acme/widget"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(
v["branches"].is_array(),
"expected echoed branches array: {v}"
);
assert_eq!(v["branches"][0]["name"], json!("main"));
assert!(v.get("ok").is_none(), "read must not be ok-enveloped: {v}");
}
#[tokio::test]
async fn branch_list_empty_no_none_leak() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/widget/branches"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"branches": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["branch", "list", "acme/widget"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["branches"], json!([]));
let s = String::from_utf8_lossy(&out.stdout);
assert!(
!s.contains("(none)"),
"human (none) leaked to JSON stdout: {s}"
);
}
#[tokio::test]
async fn branch_list_error_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/widget/branches"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!({"error": "no repo"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["branch", "list", "acme/widget"]);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false), "expected error envelope: {v}");
}
▸
tests/adversarial_ci.rs
+590
−0
@@ -1,0 +1,590 @@
//! Adversarial JSON-contract tests for the `anvil ci` command group.
//!
//! Contract (src/output.rs): under --json, stdout is exactly one JSON value.
//! - read echoing a server object/array -> bare payload
//! - client-built list -> {"items":[...]}
//! - mutation -> {"ok":true,<noun>:<value>}
//! - error -> {"ok":false,"error":...} + nonzero exit
//!
//! These tests probe edge cases (wrapped vs flat server envelopes, empty
//! collections, error status codes, log capture) for shape bugs introduced by
//! the JSON migration in src/commands/ci.rs.
use serde_json::Value;
use std::process::Output;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
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("run anvil")
}
/// Parse the child's stdout as a single JSON value, failing loudly (with the
/// raw bytes) if anything non-JSON leaked onto stdout.
fn stdout_json(out: &Output) -> Value {
let s = String::from_utf8_lossy(&out.stdout);
serde_json::from_str(&s).unwrap_or_else(|e| {
panic!(
"stdout was not a single JSON value ({e}).\n--- stdout ---\n{s}\n--- stderr ---\n{}",
String::from_utf8_lossy(&out.stderr)
)
})
}
// ---------------------------------------------------------------------------
// ci list -> read echoing server payload (bare)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn list_echoes_server_envelope_verbatim() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/runs"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ci_runs": [{"short_id": "ci_1", "status": "passed", "commit_sha": "deadbeefcafe"}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ci", "list", "myorg/myrepo"]);
assert!(out.status.success(), "expected exit 0");
let v = stdout_json(&out);
// read -> bare server payload echoed verbatim.
assert!(v["ci_runs"].is_array(), "expected ci_runs array, got {v}");
assert_eq!(v["ci_runs"][0]["short_id"], "ci_1");
// Not wrapped in an {"items":...} envelope.
assert!(v.get("items").is_none(), "read must not wrap in items: {v}");
}
#[tokio::test]
async fn list_empty_is_valid_json_not_none_line() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/runs"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ci_runs": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ci", "list", "myorg/myrepo"]);
assert!(out.status.success());
let v = stdout_json(&out); // must parse: no "(none)" human line leaked
assert!(v["ci_runs"].is_array());
assert_eq!(v["ci_runs"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn list_server_error_yields_error_envelope_and_nonzero_exit() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/runs"))
.respond_with(
ResponseTemplate::new(500)
.insert_header("content-type", "application/json")
.set_body_string(r#"{"error":"boom"}"#),
)
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ci", "list", "myorg/myrepo"]);
assert!(!out.status.success(), "5xx must produce nonzero exit");
let v = stdout_json(&out);
assert_eq!(v["ok"], false, "expected {{ok:false}} envelope, got {v}");
assert!(v.get("error").is_some(), "expected error field, got {v}");
}
// ---------------------------------------------------------------------------
// ci view -> read echoing server payload (bare)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn view_echoes_wrapped_body_verbatim() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/runs/ci_7"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ci_run": {"short_id": "ci_7", "status": "running", "branch": "main"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["ci", "view", "ci_7", "--repo", "myorg/myrepo"],
);
assert!(out.status.success());
let v = stdout_json(&out);
// read echoes verbatim; the server envelope is preserved.
assert_eq!(v["ci_run"]["short_id"], "ci_7");
}
#[tokio::test]
async fn view_404_yields_error_envelope_and_nonzero_exit() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/runs/missing"))
.respond_with(
ResponseTemplate::new(404)
.insert_header("content-type", "application/json")
.set_body_string(r#"{"error":"not_found"}"#),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["ci", "view", "missing", "--repo", "myorg/myrepo"],
);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
}
// ---------------------------------------------------------------------------
// ci run (trigger) -> mutation {"ok":true,"run":<run>}; unwraps ci_run/data
// ---------------------------------------------------------------------------
#[tokio::test]
async fn trigger_unwraps_ci_run_envelope_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/myorg/myrepo/ci/runs"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"ci_run": {"short_id": "ci_new", "id": "uuid-1", "status": "queued"}
})))
.mount(&server)
.await;
// --branch avoids shelling out to git for the branch name.
let out = run_json(
&server.uri(),
&["ci", "run", "myorg/myrepo", "--branch", "feature-x"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
// Envelope unwrapped: the run object is directly under "run".
assert_eq!(
v["run"]["short_id"], "ci_new",
"expected unwrapped run, got {v}"
);
// No double nest.
assert!(v["run"]["ci_run"].is_null(), "double-nested ci_run: {v}");
}
#[tokio::test]
async fn trigger_flat_body_is_wrapped_once() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/myorg/myrepo/ci/runs"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"short_id": "ci_flat", "status": "queued"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["ci", "run", "myorg/myrepo", "--branch", "main"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["run"]["short_id"], "ci_flat");
}
// ---------------------------------------------------------------------------
// ci cancel -> mutation {"ok":true,"run":<run>}
//
// BUG PROBE (double-nest / wrong container): cancel() does
// Response::ok("run", resp)
// with the FULL server body and — unlike trigger()/set_secret() — never unwraps
// a `ci_run`/`data` envelope. cancel() also reads resp.get("status") /
// resp.get("short_id") FLAT, so if the /ci/runs/{id}/cancel endpoint returns the
// updated run wrapped in `ci_run` (as GET /ci/runs/{id} does), both the human
// summary and the JSON value are wrong: the "run" field then contains the
// envelope, not the run.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn cancel_flat_body_is_correct() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/myorg/myrepo/ci/runs/ci_9/cancel"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"short_id": "ci_9", "status": "cancelled"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["ci", "cancel", "ci_9", "--repo", "myorg/myrepo"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
// With a flat body the run object is correctly reachable.
assert_eq!(v["run"]["short_id"], "ci_9");
}
/// A `ci_run`-wrapped cancel body — the same envelope style GET/POST /ci/runs
/// use elsewhere — is unwrapped before Response::ok("run", ...), so the run
/// object is directly reachable under "run" with no double-nest. Mirrors
/// trigger_unwraps_ci_run_envelope_no_double_nest.
#[tokio::test]
async fn cancel_unwraps_ci_run_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/myorg/myrepo/ci/runs/ci_9/cancel"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ci_run": {"short_id": "ci_9", "status": "cancelled"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["ci", "cancel", "ci_9", "--repo", "myorg/myrepo"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
// Envelope unwrapped: the run object lives directly under "run".
assert_eq!(
v["run"]["status"], "cancelled",
"expected unwrapped run, got {v}"
);
assert_eq!(
v["run"]["short_id"], "ci_9",
"expected unwrapped run, got {v}"
);
// No double nest.
assert!(v["run"]["ci_run"].is_null(), "double-nested ci_run: {v}");
}
// ---------------------------------------------------------------------------
// ci secrets -> client-built list {"items":[...]}
// ---------------------------------------------------------------------------
#[tokio::test]
async fn secrets_uses_items_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/secrets"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"secrets": [{"name": "TOKEN", "environment": "prod"}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ci", "secrets", "myorg/myrepo"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(
v["items"].is_array(),
"client-built list must use items: {v}"
);
assert_eq!(v["items"][0]["name"], "TOKEN");
}
#[tokio::test]
async fn secrets_empty_is_items_empty_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/secrets"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"secrets": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ci", "secrets", "myorg/myrepo"]);
assert!(out.status.success());
let v = stdout_json(&out); // no "(none)" leak
assert_eq!(v["items"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn secrets_missing_key_yields_empty_items_not_null() {
// Server omits the expected "secrets" key entirely.
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/secrets"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"unexpected": 1})),
)
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ci", "secrets", "myorg/myrepo"]);
assert!(out.status.success());
let v = stdout_json(&out);
// Discarded-key path degrades to an empty items list, never null garbage.
assert!(v["items"].is_array(), "expected items array, got {v}");
assert_eq!(v["items"].as_array().unwrap().len(), 0);
}
// ---------------------------------------------------------------------------
// ci set-secret -> mutation {"ok":true,"secret":<secret>}; unwraps "secret"
// ---------------------------------------------------------------------------
#[tokio::test]
async fn set_secret_unwraps_secret_envelope_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/myorg/myrepo/ci/secrets"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"secret": {"name": "TOKEN", "environment": "prod"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"ci",
"set-secret",
"--name",
"TOKEN",
"--value",
"s3cret",
"--repo",
"myorg/myrepo",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(
v["secret"]["name"], "TOKEN",
"expected unwrapped secret: {v}"
);
assert!(v["secret"]["secret"].is_null(), "double-nested secret: {v}");
}
#[tokio::test]
async fn set_secret_flat_body_is_wrapped_once() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/myorg/myrepo/ci/secrets"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"name": "TOKEN", "environment": "*"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"ci",
"set-secret",
"--name",
"TOKEN",
"--value",
"v",
"--repo",
"myorg/myrepo",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["secret"]["name"], "TOKEN");
}
// ---------------------------------------------------------------------------
// ci delete-secret -> mutation {"ok":true,"secret":{name,deleted:true}}
// ---------------------------------------------------------------------------
#[tokio::test]
async fn delete_secret_synthesizes_confirmation() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/myorg/myrepo/ci/secrets/TOKEN"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"ci",
"delete-secret",
"--name",
"TOKEN",
"--repo",
"myorg/myrepo",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
// NOTE: contract's delete envelope is {"ok":true,"deleted":"<id>"} (Response::deleted),
// but delete_secret uses Response::ok("secret", {name,deleted:true}). Documented here.
assert_eq!(v["secret"]["name"], "TOKEN");
assert_eq!(v["secret"]["deleted"], true);
}
#[tokio::test]
async fn delete_secret_error_yields_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/myorg/myrepo/ci/secrets/MISSING"))
.respond_with(
ResponseTemplate::new(404)
.insert_header("content-type", "application/json")
.set_body_string(r#"{"error":"no such secret"}"#),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"ci",
"delete-secret",
"--name",
"MISSING",
"--repo",
"myorg/myrepo",
],
);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
}
// ---------------------------------------------------------------------------
// ci job-view -> CAPTURE: streamed logs must be a JSON field, not raw stdout
// ---------------------------------------------------------------------------
#[tokio::test]
async fn job_view_captures_logs_into_json_field() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/jobs/job_1"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"job": {"short_id": "job_1", "name": "build", "status": "passed"}
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/jobs/job_1/logs"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(
"event: log_line\ndata: {\"content\":\"compiling anvil\",\"stream\":\"stdout\"}\n\n\
event: log_line\ndata: {\"content\":\"done\",\"stream\":\"stderr\"}\n\n\
event: done\ndata: {\"status\":\"passed\",\"exit_code\":0}\n\n",
),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"ci",
"job-view",
"job_1",
"--no-follow",
"--repo",
"myorg/myrepo",
],
);
assert!(out.status.success());
// The whole of stdout must be one JSON value: proof the log lines were NOT
// dumped raw onto stdout.
let v = stdout_json(&out);
assert_eq!(v["job"]["short_id"], "job_1", "job metadata field: {v}");
let logs = v["logs"].as_array().expect("logs must be an array field");
assert_eq!(logs.len(), 2, "captured log lines: {v}");
assert_eq!(logs[0]["content"], "compiling anvil");
assert_eq!(logs[0]["stream"], "stdout");
assert_eq!(logs[1]["stream"], "stderr");
// job field is unwrapped (no job.job double nest).
assert!(v["job"]["job"].is_null(), "double-nested job: {v}");
}
#[tokio::test]
async fn job_view_log_fetch_failure_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/jobs/job_2"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"job": {"short_id": "job_2", "status": "running"}
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/jobs/job_2/logs"))
.respond_with(
ResponseTemplate::new(500)
.insert_header("content-type", "application/json")
.set_body_string(r#"{"error":"log backend down"}"#),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"ci",
"job-view",
"job_2",
"--no-follow",
"--repo",
"myorg/myrepo",
],
);
// Metadata succeeded but logs failed -> whole command must fail cleanly.
assert!(!out.status.success(), "log fetch failure must exit nonzero");
let v = stdout_json(&out);
assert_eq!(v["ok"], false, "expected error envelope, got {v}");
// The successfully-fetched metadata must NOT have leaked as human text.
assert!(
v["job"].is_null(),
"partial metadata leaked onto stdout: {v}"
);
}
#[tokio::test]
async fn job_view_empty_logs_is_empty_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/jobs/job_3"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"job": {"short_id": "job_3", "status": "passed"}
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/jobs/job_3/logs"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string("event: done\ndata: {\"status\":\"passed\",\"exit_code\":0}\n\n"),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"ci",
"job-view",
"job_3",
"--no-follow",
"--repo",
"myorg/myrepo",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["logs"].is_array());
assert_eq!(v["logs"].as_array().unwrap().len(), 0);
}
▸
tests/adversarial_deploy_registry.rs
+419
−0
@@ -1,0 +1,419 @@
//! Adversarial `--json` contract tests for the deploy + registry commands.
//!
//! Commands under attack:
//! deploy status / list / create / env list / env create
//! registry token create / list / delete
//!
//! Each test boots a wiremock server, points the real `anvil` binary at it, and
//! asserts the exact JSON envelope the contract (src/output.rs) requires. Repo
//! is always passed explicitly so resolution never falls through to the dev's
//! git remote or config.
use serde_json::{json, Value};
use std::process::Output;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
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("run anvil")
}
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),
)
})
}
// ---------------------------------------------------------------------------
// deploy status (GET /{org}/{repo}/deployments/status) -> Response::read
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn deploy_status_echoes_server_object_bare() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/web/deployments/status"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"environment": "prod",
"status": "running",
"ref": "main"
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["deploy", "status", "acme/web"]);
assert!(out.status.success(), "expected exit 0");
let v = stdout_json(&out);
// read echoes the bare server object: no ok wrapper, fields at top level.
assert!(
v.get("ok").is_none(),
"read must not add an ok wrapper: {v}"
);
assert_eq!(v["status"], "running");
assert_eq!(v["environment"], "prod");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn deploy_status_server_error_is_error_envelope_nonzero() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/web/deployments/status"))
.respond_with(
ResponseTemplate::new(500)
.insert_header("content-type", "application/json")
.set_body_string(r#"{"error":"boom"}"#),
)
.mount(&server)
.await;
let out = run_json(&server.uri(), &["deploy", "status", "acme/web"]);
assert!(!out.status.success(), "5xx must exit nonzero");
let v = stdout_json(&out);
assert_eq!(
v["ok"],
json!(false),
"error envelope must be ok:false: {v}"
);
assert!(
v.get("error").is_some(),
"error envelope needs error key: {v}"
);
}
// ---------------------------------------------------------------------------
// deploy list (GET /{org}/{repo}/environments/{env}/deployments) -> read
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn deploy_list_echoes_wrapped_server_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/web/environments/prod/deployments"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"deployments": [
{"environment": "prod", "status": "running", "ref": "main"}
]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["deploy", "list", "acme/web", "--env", "prod"],
);
assert!(out.status.success());
let v = stdout_json(&out);
// read echoes the whole server body verbatim (envelope preserved).
assert!(
v["deployments"].is_array(),
"expected deployments array: {v}"
);
assert_eq!(v["deployments"][0]["status"], "running");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn deploy_list_empty_is_json_not_human_none() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/web/environments/prod/deployments"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"deployments": []})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["deploy", "list", "acme/web", "--env", "prod"],
);
assert!(out.status.success());
let raw = String::from_utf8_lossy(&out.stdout);
assert!(
!raw.contains("(none)"),
"human (none) leaked to stdout: {raw}"
);
let v = stdout_json(&out);
assert_eq!(v["deployments"], json!([]));
}
// ---------------------------------------------------------------------------
// deploy create (POST .../deployments) -> Response::ok("deployment", ...)
// BUG PATTERN 1: does NOT unwrap the server envelope, unlike every sibling
// (label/ci/release all do `resp.get(noun).cloned().unwrap_or(resp)`).
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn deploy_create_double_nests_wrapped_server_body() {
let server = MockServer::start().await;
// Server wraps the record in a `deployment` envelope — the same shape the
// list endpoint uses (`deployments`/`data`) and that label/ci/release unwrap.
Mock::given(method("POST"))
.and(path("/api/v1/acme/web/environments/prod/deployments"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"deployment": {"id": "dep_1", "status": "queued", "ref": "main"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"deploy",
"create",
"--repo",
"acme/web",
"--env",
"prod",
"--deploy-ref",
"main",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
// CONTRACT (desired): v["deployment"]["deployment"] is null, and the real
// field is reachable at v["deployment"]["status"].
//
// ACTUAL (bug): the command wraps the whole server body — which is itself
// `{"deployment":{...}}` — so we get {"ok":true,"deployment":{"deployment":{...}}}.
// The status a script wants is buried at v["deployment"]["deployment"]["status"].
// We document the CURRENT buggy behavior so the suite stays green.
if v["deployment"]["deployment"].is_null() {
// Contract upheld (migration was fixed).
assert_eq!(v["deployment"]["status"], "queued");
} else {
// BUG confirmed: double-nested envelope.
assert_eq!(
v["deployment"]["deployment"]["status"], "queued",
"double-nest bug shape changed: {v}"
);
assert!(
v["deployment"]["status"].is_null(),
"status should NOT be directly reachable in the buggy shape: {v}"
);
}
}
// ---------------------------------------------------------------------------
// deploy env list (GET /{org}/{repo}/environments) -> read
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn deploy_env_list_echoes_wrapped_body_and_empty() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/web/environments"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"environments": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["deploy", "env", "list", "acme/web"]);
assert!(out.status.success());
let raw = String::from_utf8_lossy(&out.stdout);
assert!(!raw.contains("(none)"), "human (none) leaked: {raw}");
let v = stdout_json(&out);
assert_eq!(v["environments"], json!([]));
assert!(v.get("ok").is_none(), "read must not add ok wrapper: {v}");
}
// ---------------------------------------------------------------------------
// deploy env create (POST /{org}/{repo}/environments) -> ok("environment", ..)
// BUG PATTERN 1: no envelope unwrap.
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn deploy_env_create_double_nests_wrapped_server_body() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/acme/web/environments"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"environment": {"name": "staging", "id": "env_9"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"deploy", "env", "create", "--repo", "acme/web", "--name", "staging",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
if v["environment"]["environment"].is_null() {
// Contract upheld.
assert_eq!(v["environment"]["name"], "staging");
} else {
// BUG confirmed: {"ok":true,"environment":{"environment":{...}}}.
assert_eq!(
v["environment"]["environment"]["name"], "staging",
"shape: {v}"
);
assert!(
v["environment"]["name"].is_null(),
"name should NOT be directly reachable in the buggy shape: {v}"
);
}
}
// ---------------------------------------------------------------------------
// registry token create (POST /registry/tokens) -> ok("token", resp)
// Server returns a FLAT record with the plaintext at resp["token"], so the
// whole record wrapped under "token" is the intended shape (no double-nest).
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn registry_token_create_wraps_flat_record() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/registry/tokens"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": "tok_1",
"name": "ci",
"token": "plaintext-secret-value",
"scopes": ["pull:acme/web"]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"registry", "token", "create", "--name", "ci", "--read", "--repo", "acme/web",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
// The plaintext is reachable and the record is not lost.
assert_eq!(v["token"]["token"], "plaintext-secret-value");
assert_eq!(v["token"]["id"], "tok_1");
// Ensure the plaintext isn't leaked at the top level (only under the record).
assert!(v.get("token").is_some());
}
// ---------------------------------------------------------------------------
// registry token list (GET /registry/tokens) -> Response::items (client list)
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn registry_token_list_uses_items_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/registry/tokens"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"tokens": [
{"id": "tok_1", "name": "ci", "scopes": ["pull:acme/web"]}
]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["registry", "token", "list"]);
assert!(out.status.success());
let v = stdout_json(&out);
// Client-built list contract: {"items":[...]}, NOT the raw server body.
assert!(v["items"].is_array(), "expected items envelope: {v}");
assert_eq!(v["items"][0]["id"], "tok_1");
assert!(v.get("tokens").is_none(), "must not echo server key: {v}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn registry_token_list_empty_is_items_empty_not_human() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/registry/tokens"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"tokens": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["registry", "token", "list"]);
assert!(out.status.success());
let raw = String::from_utf8_lossy(&out.stdout);
assert!(
!raw.contains("No registry tokens"),
"human info line leaked to stdout: {raw}"
);
let v = stdout_json(&out);
assert_eq!(v["items"], json!([]));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn registry_token_list_missing_key_yields_empty_items() {
let server = MockServer::start().await;
// Server omits the `tokens` key entirely.
Mock::given(method("GET"))
.and(path("/api/v1/registry/tokens"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"unexpected": true})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["registry", "token", "list"]);
assert!(out.status.success());
let v = stdout_json(&out);
// Graceful: empty items, not a panic or null garbage.
assert_eq!(
v["items"],
json!([]),
"missing key should degrade to []: {v}"
);
}
// ---------------------------------------------------------------------------
// registry token delete (DELETE /registry/tokens/{id}) -> Response::deleted
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn registry_token_delete_emits_deleted_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/registry/tokens/tok_1"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["registry", "token", "delete", "tok_1"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(
v,
json!({"ok": true, "deleted": "tok_1"}),
"delete envelope: {v}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn registry_token_delete_404_is_error_envelope_nonzero() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/registry/tokens/missing"))
.respond_with(
ResponseTemplate::new(404)
.insert_header("content-type", "application/json")
.set_body_string(r#"{"error":"not_found"}"#),
)
.mount(&server)
.await;
let out = run_json(&server.uri(), &["registry", "token", "delete", "missing"]);
assert!(!out.status.success(), "404 must exit nonzero (not masked)");
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false), "must be error envelope: {v}");
assert!(v.get("error").is_some());
// Must NOT falsely report a successful delete.
assert!(
v.get("deleted").is_none(),
"deleted key present on failure: {v}"
);
}
▸
tests/adversarial_label_milestone_board.rs
+591
−0
@@ -1,0 +1,591 @@
//! Adversarial `--json` contract tests for label / milestone / board.
//!
//! Group: label_milestone_board. These probe the migrated envelope shapes with
//! edge-case server bodies (wrapped vs flat, missing keys, empty collections,
//! error status) and assert the documented contract from `src/output.rs`:
//! - read echoing a server object/array -> bare payload
//! - mutation -> {"ok":true,<noun>:<value>}
//! - delete -> {"ok":true,"deleted":"<id>"}
//! - error -> {"ok":false,"error":...} + nonzero
//!
//! The highest-risk spot is the DOUBLE-NEST: a wrapped server body {"noun":{…}}
//! passed through `Response::ok("noun", …)` without unwrapping would yield
//! {"ok":true,"noun":{"noun":{…}}}. Every mutation test asserts v[noun][noun] is
//! null and the real field is reachable at v[noun][<field>].
use serde_json::{json, Value};
use std::process::Output;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
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("run anvil")
}
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),
)
})
}
// ---------------------------------------------------------------------------
// LABEL
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_create_wrapped_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/o/r/labels"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"label": {"name": "bug", "color": "#f00", "description": "d"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"label", "create", "--name", "bug", "--color", "#f00", "--repo", "o/r",
],
);
assert!(out.status.success(), "expected success exit");
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
// No double nest: v["label"]["label"] must not exist.
assert!(v["label"]["label"].is_null(), "double-nest detected: {v}");
// Real field reachable directly under the noun.
assert_eq!(v["label"]["name"], json!("bug"));
assert_eq!(v["label"]["color"], json!("#f00"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_create_flat_body_reachable() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/o/r/labels"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"name": "bug", "color": "#f00"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"label", "create", "--name", "bug", "--color", "#f00", "--repo", "o/r",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
assert!(v["label"]["label"].is_null());
assert_eq!(v["label"]["name"], json!("bug"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_edit_wrapped_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/api/v1/o/r/labels/bug"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"label": {"name": "defect", "color": "#0f0"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"label",
"edit",
"bug",
"--new-name",
"defect",
"--repo",
"o/r",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["label"]["label"].is_null(), "double-nest detected: {v}");
assert_eq!(v["label"]["name"], json!("defect"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_add_wrapped_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/o/r/issues/5/labels"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"label": {"name": "bug"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"label", "add", "--issue", "5", "--name", "bug", "--repo", "o/r",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["label"]["label"].is_null(), "double-nest detected: {v}");
assert_eq!(v["label"]["name"], json!("bug"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_delete_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/o/r/labels/bug"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["label", "delete", "bug", "--repo", "o/r"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v, json!({"ok": true, "deleted": "bug"}));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_remove_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/o/r/issues/5/labels/bug"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"label", "remove", "--issue", "5", "--name", "bug", "--repo", "o/r",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
assert_eq!(v["label_removed"]["issue"], json!(5));
assert_eq!(v["label_removed"]["name"], json!("bug"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_list_echoes_server_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/o/r/labels"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"labels": [{"name": "bug", "color": "#f00", "description": ""}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["label", "list", "o/r"]);
assert!(out.status.success());
let v = stdout_json(&out);
// Read echoes verbatim: bare {"labels":[...]}, NOT wrapped in items/ok.
assert!(v["labels"].is_array());
assert_eq!(v["labels"][0]["name"], json!("bug"));
assert!(v["ok"].is_null(), "read must not add ok envelope");
assert!(v["items"].is_null(), "read must not wrap in items");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_list_empty_no_human_leak() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/o/r/labels"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"labels": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["label", "list", "o/r"]);
assert!(out.status.success());
let v = stdout_json(&out); // parses => no "(none)" line leaked
assert_eq!(v["labels"], json!([]));
let s = String::from_utf8_lossy(&out.stdout);
assert!(!s.contains("(none)"), "human (none) leaked: {s}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_create_error_path() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/o/r/labels"))
.respond_with(
ResponseTemplate::new(400)
.insert_header("content-type", "application/json")
.set_body_string(r#"{"error":"bad color"}"#),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["label", "create", "--name", "bug", "--repo", "o/r"],
);
assert!(!out.status.success(), "error must exit nonzero");
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false));
assert!(v["error"].is_string(), "error envelope missing: {v}");
}
// ---------------------------------------------------------------------------
// MILESTONE
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn milestone_create_wrapped_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/o/r/milestones"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"milestone": {"short_id": "M-1", "title": "v1.0", "state": "open"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["milestone", "create", "--title", "v1.0", "--repo", "o/r"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
assert!(v["milestone"]["milestone"].is_null(), "double-nest: {v}");
assert_eq!(v["milestone"]["short_id"], json!("M-1"));
assert_eq!(v["milestone"]["title"], json!("v1.0"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn milestone_create_flat_body_reachable() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/o/r/milestones"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"short_id": "M-2", "title": "v2.0"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["milestone", "create", "--title", "v2.0", "--repo", "o/r"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["milestone"]["milestone"].is_null());
assert_eq!(v["milestone"]["short_id"], json!("M-2"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn milestone_edit_wrapped_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/api/v1/o/r/milestones/M-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"milestone": {"short_id": "M-1", "title": "renamed"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"milestone",
"edit",
"M-1",
"--title",
"renamed",
"--repo",
"o/r",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["milestone"]["milestone"].is_null(), "double-nest: {v}");
assert_eq!(v["milestone"]["title"], json!("renamed"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn milestone_close_wrapped_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/o/r/milestones/M-1/close"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"milestone": {"short_id": "M-1", "state": "closed"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["milestone", "close", "M-1", "--repo", "o/r"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["milestone"]["milestone"].is_null(), "double-nest: {v}");
assert_eq!(v["milestone"]["state"], json!("closed"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn milestone_delete_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/o/r/milestones/M-1"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["milestone", "delete", "M-1", "--repo", "o/r"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v, json!({"ok": true, "deleted": "M-1"}));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn milestone_list_echoes_server_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/o/r/milestones"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"milestones": [{"short_id": "M-1", "title": "v1", "state": "open"}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["milestone", "list", "o/r"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["milestones"].is_array());
assert!(v["ok"].is_null());
assert!(v["items"].is_null());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn milestone_list_empty_no_human_leak() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/o/r/milestones"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"milestones": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["milestone", "list", "o/r"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["milestones"], json!([]));
let s = String::from_utf8_lossy(&out.stdout);
assert!(!s.contains("No milestones"), "human info leaked: {s}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn milestone_view_echoes_server_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/o/r/milestones/M-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"milestone": {"short_id": "M-1", "title": "v1", "state": "open"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["milestone", "view", "M-1", "--repo", "o/r"],
);
assert!(out.status.success());
let v = stdout_json(&out);
// Read echoes verbatim (server-wrapped shape preserved).
assert_eq!(v["milestone"]["short_id"], json!("M-1"));
assert!(v["ok"].is_null(), "read must not add ok envelope");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn milestone_view_error_path() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/o/r/milestones/nope"))
.respond_with(
ResponseTemplate::new(404)
.insert_header("content-type", "application/json")
.set_body_string(r#"{"error":"not found"}"#),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["milestone", "view", "nope", "--repo", "o/r"],
);
assert!(!out.status.success(), "404 must exit nonzero");
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false));
assert!(v["error"].is_string());
}
// ---------------------------------------------------------------------------
// BOARD
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn board_init_wrapped_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/o/r/board/initialize"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"columns": [{"name": "Backlog"}, {"name": "Done"}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["board", "init", "--repo", "o/r"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
// columns must be the array itself, not {"columns":[...]} nested.
assert!(v["columns"].is_array(), "columns not unwrapped: {v}");
assert_eq!(v["columns"][0]["name"], json!("Backlog"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn board_create_column_wrapped_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/o/r/board/columns"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"column": {"short_id": "C-1", "name": "Backlog"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["board", "create-column", "Backlog", "--repo", "o/r"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["column"]["column"].is_null(), "double-nest: {v}");
assert_eq!(v["column"]["name"], json!("Backlog"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn board_create_column_flat_body_reachable() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/o/r/board/columns"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"short_id": "C-2", "name": "Doing"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["board", "create-column", "Doing", "--repo", "o/r"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["column"]["column"].is_null());
assert_eq!(v["column"]["name"], json!("Doing"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn board_edit_column_wrapped_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/api/v1/o/r/board/columns/C-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"column": {"short_id": "C-1", "name": "Renamed"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"board",
"edit-column",
"C-1",
"--name",
"Renamed",
"--repo",
"o/r",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["column"]["column"].is_null(), "double-nest: {v}");
assert_eq!(v["column"]["name"], json!("Renamed"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn board_delete_column_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/o/r/board/columns/C-1"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["board", "delete-column", "C-1", "--repo", "o/r"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v, json!({"ok": true, "deleted": "C-1"}));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn board_list_empty_no_human_leak() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/o/r/board/columns"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"columns": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["board", "list", "o/r"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["columns"], json!([]));
let s = String::from_utf8_lossy(&out.stdout);
assert!(!s.contains("No board columns"), "human info leaked: {s}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn board_list_feature_disabled_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/o/r/board/columns"))
.respond_with(
ResponseTemplate::new(404)
.insert_header("content-type", "application/json")
.set_body_string(r#"{"error":"Feature not enabled"}"#),
)
.mount(&server)
.await;
let out = run_json(&server.uri(), &["board", "list", "o/r"]);
assert!(!out.status.success(), "feature-disabled must exit nonzero");
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false));
assert!(v["error"].is_string(), "error envelope missing: {v}");
}
▸
tests/adversarial_pr_issue_epic.rs
+676
−0
@@ -1,0 +1,676 @@
//! Adversarial `--json` contract tests for pr / issue / epic commands.
//!
//! Boots a wiremock server, points the real `anvil` binary at it, runs a
//! command with `--json`, and asserts the emitted JSON shape against the
//! contract in `src/output.rs`. Where a test documents a CONFIRMED bug in the
//! command code, the assertion pins the *current* (buggy) behavior and a
//! `// BUG:` comment explains the contract violation — so the suite stays green
//! while the defect is on record (see the report in the task summary).
use serde_json::{json, Value};
use std::process::Output;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
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("run anvil")
}
/// Parse stdout as JSON, surfacing stdout+stderr on failure so a stray human
/// 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),
)
})
}
async fn mock_json(server: &MockServer, m: &str, p: &str, status: u16, body: Value) {
Mock::given(method(m))
.and(path(p))
.respond_with(ResponseTemplate::new(status).set_body_json(body))
.mount(server)
.await;
}
const REPO: &str = "test-org/test-repo";
// ───────────────────────── PR: create ─────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pr_create_single_nest() {
let server = MockServer::start().await;
// The command reads `resp.pointer("/pull_request/number")` FIRST — i.e. the
// wrapped envelope is the primary expected server shape.
mock_json(
&server,
"POST",
"/api/v1/test-org/test-repo/pulls",
201,
json!({ "pull_request": { "number": 42, "title": "T", "state": "open" } }),
)
.await;
let out = run_json(
&server.uri(),
&[
"pr", "create", "--repo", REPO, "--title", "T", "--base", "main", "--head", "feature",
],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
// FIXED: pr::create now unwraps the server's `pull_request` envelope
// (resp.get("pull_request").or(data).unwrap_or(resp)) so the PR fields sit
// directly under `pull_request` with no double nesting.
assert_eq!(v["pull_request"]["number"].as_u64(), Some(42));
assert!(
v["pull_request"]["pull_request"].is_null(),
"the envelope must be unwrapped, not double-nested"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pr_create_flat_body_is_well_formed() {
let server = MockServer::start().await;
mock_json(
&server,
"POST",
"/api/v1/test-org/test-repo/pulls",
201,
json!({ "number": 42, "title": "T", "state": "open" }),
)
.await;
let out = run_json(
&server.uri(),
&[
"pr", "create", "--repo", REPO, "--title", "T", "--base", "main", "--head", "feature",
],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
assert_eq!(v["pull_request"]["number"].as_u64(), Some(42));
assert!(v["pull_request"]["pull_request"].is_null());
}
// ───────────────────────── PR: edit ─────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pr_edit_single_nest() {
let server = MockServer::start().await;
// parse_pr() explicitly unwraps `pull_request`/`data`/bare, proving the
// author knows the wrapped shape is a real server response.
mock_json(
&server,
"PATCH",
"/api/v1/test-org/test-repo/pulls/42",
200,
json!({ "pull_request": { "number": 42, "title": "New", "base_branch": "main" } }),
)
.await;
let out = run_json(
&server.uri(),
&["pr", "edit", "42", "--repo", REPO, "--title", "New"],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
// FIXED: the wrapped `pull_request` envelope is unwrapped, so the PR fields
// sit directly under `pull_request`.
assert_eq!(v["pull_request"]["number"].as_u64(), Some(42));
assert!(v["pull_request"]["pull_request"].is_null());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pr_edit_noop_emits_client_envelope() {
// No fields → no HTTP call; a synthetic envelope is emitted.
let server = MockServer::start().await;
let out = run_json(&server.uri(), &["pr", "edit", "42", "--repo", REPO]);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
assert_eq!(v["pull_request"]["number"].as_u64(), Some(42));
assert_eq!(v["pull_request"]["updated"], json!(false));
}
// ───────────────────────── PR: merge ─────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pr_merge_no_double_nest() {
let server = MockServer::start().await;
mock_json(
&server,
"POST",
"/api/v1/test-org/test-repo/pulls/42/merge",
200,
json!({ "merge_sha": "abc123", "state": "merged" }),
)
.await;
let out = run_json(&server.uri(), &["pr", "merge", "42", "--repo", REPO]);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
// noun "merge" != body key "merge_sha", so the flat body sits correctly.
assert_eq!(v["merge"]["merge_sha"].as_str(), Some("abc123"));
assert!(v["merge"]["merge"].is_null());
}
// ───────────────────────── PR: review + reviews ─────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pr_review_envelope_shape() {
let server = MockServer::start().await;
mock_json(
&server,
"POST",
"/api/v1/test-org/test-repo/pulls/42/reviews",
201,
json!({ "id": "rv1", "state": "approved" }),
)
.await;
let out = run_json(
&server.uri(),
&["pr", "review", "42", "--action", "approve", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
assert_eq!(v["review"]["state"].as_str(), Some("approved"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pr_reviews_uses_items_container() {
let server = MockServer::start().await;
mock_json(
&server,
"GET",
"/api/v1/test-org/test-repo/pulls/42/reviews",
200,
json!({ "reviews": [{ "state": "approved", "body": "lgtm" }] }),
)
.await;
let out = run_json(&server.uri(), &["pr", "reviews", "42", "--repo", REPO]);
let v = stdout_json(&out);
// Client-built list → {"items":[...]}, not a bare array.
assert!(v["items"].is_array());
assert_eq!(v["items"][0]["state"].as_str(), Some("approved"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pr_reviews_empty_is_empty_items_not_human_line() {
let server = MockServer::start().await;
mock_json(
&server,
"GET",
"/api/v1/test-org/test-repo/pulls/42/reviews",
200,
json!({ "reviews": [] }),
)
.await;
let out = run_json(&server.uri(), &["pr", "reviews", "42", "--repo", REPO]);
let v = stdout_json(&out);
assert_eq!(v["items"].as_array().map(|a| a.len()), Some(0));
// No "(none)" human line should have leaked to stdout.
assert!(!String::from_utf8_lossy(&out.stdout).contains("(none)"));
}
// ───────────────────────── PR: comment ─────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pr_comment_envelope_shape() {
let server = MockServer::start().await;
mock_json(
&server,
"POST",
"/api/v1/test-org/test-repo/pulls/42/comments",
201,
json!({ "id": "c1", "body": "nit" }),
)
.await;
let out = run_json(
&server.uri(),
&[
"pr", "comment", "42", "--file", "src/x.rs", "--line", "10", "--body", "nit", "--repo",
REPO,
],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
assert_eq!(v["comment"]["id"].as_str(), Some("c1"));
}
// ───────────────────────── PR: error path ─────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pr_close_4xx_is_error_envelope_and_nonzero_exit() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/api/v1/test-org/test-repo/pulls/42"))
.respond_with(
ResponseTemplate::new(422)
.insert_header("content-type", "application/json")
.set_body_string(r#"{"error":"cannot close"}"#),
)
.mount(&server)
.await;
let out = run_json(&server.uri(), &["pr", "close", "42", "--repo", REPO]);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false), "error must not be masked as ok");
assert!(!v["error"].is_null(), "error envelope must carry an error");
assert!(!out.status.success(), "exit code must be nonzero on 4xx");
}
// ───────────────────────── ISSUE: create ─────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn issue_create_single_nest() {
let server = MockServer::start().await;
// create() reads resp.pointer("/issue/number") FIRST — wrapped is primary.
mock_json(
&server,
"POST",
"/api/v1/test-org/test-repo/issues",
201,
json!({ "issue": { "number": 5, "title": "T", "state": "open" } }),
)
.await;
let out = run_json(
&server.uri(),
&["issue", "create", "--repo", REPO, "--title", "T"],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
// FIXED: create now unwraps the server's `issue` envelope
// (resp.get("issue").or(data).unwrap_or(resp)) so the issue fields sit
// directly under `issue` with no double nesting.
assert_eq!(v["issue"]["number"].as_u64(), Some(5));
assert!(v["issue"]["issue"].is_null());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn issue_create_flat_body_is_well_formed() {
let server = MockServer::start().await;
mock_json(
&server,
"POST",
"/api/v1/test-org/test-repo/issues",
201,
json!({ "number": 7, "title": "Bug", "state": "open" }),
)
.await;
let out = run_json(
&server.uri(),
&["issue", "create", "--repo", REPO, "--title", "Bug"],
);
let v = stdout_json(&out);
assert_eq!(v["issue"]["number"].as_u64(), Some(7));
assert!(v["issue"]["issue"].is_null());
}
// ───────────────────────── ISSUE: close / edit ─────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn issue_close_single_nest() {
let server = MockServer::start().await;
// view() reads resp.get("issue"), so the wrapped envelope is a real shape
// for this endpoint; PATCH shares the server's issue serializer.
mock_json(
&server,
"PATCH",
"/api/v1/test-org/test-repo/issues/5",
200,
json!({ "issue": { "number": 5, "title": "T", "state": "closed" } }),
)
.await;
let out = run_json(&server.uri(), &["issue", "close", "5", "--repo", REPO]);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
// FIXED: create/close/reopen/edit/move now unwrap the server's `issue`
// envelope, so the issue fields sit directly under `issue`.
assert_eq!(v["issue"]["number"].as_u64(), Some(5));
assert!(v["issue"]["issue"].is_null());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn issue_edit_noop_emits_client_envelope() {
let server = MockServer::start().await;
let out = run_json(&server.uri(), &["issue", "edit", "7", "--repo", REPO]);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
assert_eq!(v["issue"]["number"].as_u64(), Some(7));
assert_eq!(v["issue"]["updated"], json!(false));
}
// ───────────────────────── ISSUE: link ─────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn issue_link_no_double_nest() {
let server = MockServer::start().await;
mock_json(
&server,
"POST",
"/api/v1/test-org/test-repo/issues/7/links",
201,
json!({ "link_id": "L1", "kind": "blocks",
"issue": { "org": "test-org", "repo": "test-repo", "number": 9 } }),
)
.await;
let out = run_json(
&server.uri(),
&[
"issue", "link", "7", "--kind", "blocks", "--target", "#9", "--repo", REPO,
],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
assert_eq!(v["link"]["link_id"].as_str(), Some("L1"));
assert!(v["link"]["link"].is_null());
}
// ───────────────────────── ISSUE: link-req (reference correct unwrap) ─────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn issue_link_req_unwraps_wrapped_body() {
let server = MockServer::start().await;
// link_requirement is the ONE mutation that unwraps: it uses
// resp.get("linked").cloned().unwrap_or(resp) — so a wrapped body must NOT
// double-nest. This is the reference for how create/edit/close should work.
mock_json(
&server,
"POST",
"/api/v1/test-org/test-repo/issues/7/requirement-links",
201,
json!({ "linked": { "requirement_id": "REQ-1", "issue": 7 } }),
)
.await;
let out = run_json(
&server.uri(),
&["issue", "link-req", "7", "REQ-1", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
assert_eq!(v["linked"]["requirement_id"].as_str(), Some("REQ-1"));
assert!(
v["linked"]["linked"].is_null(),
"link-req must unwrap the `linked` envelope, not double-nest it"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn issue_link_req_flat_body_falls_back_to_whole_body() {
let server = MockServer::start().await;
mock_json(
&server,
"POST",
"/api/v1/test-org/test-repo/issues/7/requirement-links",
201,
json!({ "requirement_id": "REQ-1", "issue": 7 }),
)
.await;
let out = run_json(
&server.uri(),
&["issue", "link-req", "7", "REQ-1", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v["linked"]["requirement_id"].as_str(), Some("REQ-1"));
}
// ───────────────────────── ISSUE: unlink-req (delete-ish) ─────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn issue_unlink_req_emits_client_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path(
"/api/v1/test-org/test-repo/issues/7/requirement-links/REQ-1",
))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["issue", "unlink-req", "7", "REQ-1", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
assert_eq!(v["unlinked"]["requirement_id"].as_str(), Some("REQ-1"));
assert_eq!(v["unlinked"]["issue"].as_u64(), Some(7));
}
// ───────────────────────── ISSUE: assign ─────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn issue_assign_envelope_shape() {
let server = MockServer::start().await;
mock_json(
&server,
"POST",
"/api/v1/test-org/test-repo/issues/7/assignees",
201,
json!({ "user_id": "u1", "issue": 7 }),
)
.await;
let out = run_json(
&server.uri(),
&["issue", "assign", "7", "--user-id", "u1", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
assert_eq!(v["assignee"]["user_id"].as_str(), Some("u1"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn issue_assign_5xx_is_error_envelope_and_nonzero_exit() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/issues/7/assignees"))
.respond_with(
ResponseTemplate::new(500)
.insert_header("content-type", "application/json")
.set_body_string(r#"{"error":"boom"}"#),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["issue", "assign", "7", "--user-id", "u1", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false));
assert!(!out.status.success());
}
// ───────────────────────── EPIC: view / children (children!) ─────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn epic_view_includes_children_from_grouped_links() {
let server = MockServer::start().await;
// epic view first GETs the epic issue itself, THEN the links endpoint.
mock_json(
&server,
"GET",
"/api/v1/o/r/issues/9",
200,
json!({ "number": 9, "title": "E", "state": "open", "kind": "epic" }),
)
.await;
// The links endpoint's canonical server shape is a GROUPED OBJECT keyed by
// relation, with the epic's children under `links.children`.
mock_json(
&server,
"GET",
"/api/v1/o/r/issues/9/links",
200,
json!({
"links": {
"blocks": [], "blocked_by": [], "parent": null,
"children": [
{ "link_id": "L1", "kind": "parent_of",
"issue": { "org": "o", "repo": "r",
"number": 5, "title": "Child", "state": "open" } }
],
"duplicate_of": null, "duplicates": [], "related": []
}
}),
)
.await;
let out = run_json(&server.uri(), &["epic", "view", "9", "--repo", "o/r"]);
let v = stdout_json(&out);
// FIXED: epic::fetch_children reads links.children, so the epic's child (#5)
// is included and progress reflects it.
let children = v["children"].as_array().expect("children is an array");
assert!(!children.is_empty(), "children must be non-empty");
assert!(
children.iter().any(|c| c["number"].as_u64() == Some(5)),
"the child issue #5 must be present in children"
);
assert_eq!(v["progress"]["total"].as_u64(), Some(1));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn epic_children_populates_from_grouped_links() {
let server = MockServer::start().await;
mock_json(
&server,
"GET",
"/api/v1/o/r/issues/9/links",
200,
json!({
"links": {
"blocks": [], "blocked_by": [], "parent": null,
"children": [
{ "link_id": "L1", "kind": "parent_of",
"issue": { "org": "o", "repo": "r",
"number": 5, "title": "Child", "state": "open" } }
],
"duplicate_of": null, "duplicates": [], "related": []
}
}),
)
.await;
let out = run_json(&server.uri(), &["epic", "children", "9", "--repo", "o/r"]);
let v = stdout_json(&out);
// FIXED: fetch_children reads links.children — the client-built list wrapped
// in {"items":[...]} now carries the epic's child.
let items = v["items"].as_array().expect("items is an array");
assert_eq!(items.len(), 1);
assert_eq!(items[0]["number"].as_u64(), Some(5));
assert_eq!(items[0]["ref"].as_str(), Some("o/r#5"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn epic_children_empty_epic_is_empty_items() {
let server = MockServer::start().await;
// Grouped links shape with no children → empty items.
mock_json(
&server,
"GET",
"/api/v1/test-org/test-repo/issues/42/links",
200,
json!({
"links": {
"blocks": [], "blocked_by": [], "parent": null,
"children": [],
"duplicate_of": null, "duplicates": [], "related": []
}
}),
)
.await;
let out = run_json(&server.uri(), &["epic", "children", "42", "--repo", REPO]);
let v = stdout_json(&out);
assert_eq!(v["items"].as_array().map(|a| a.len()), Some(0));
assert!(!String::from_utf8_lossy(&out.stdout).contains("no children"));
}
// ───────────────────────── EPIC: list / add-child / mark ─────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn epic_list_empty_echoes_server_no_human_leak() {
let server = MockServer::start().await;
mock_json(
&server,
"GET",
"/api/v1/test-org/test-repo/issues",
200,
json!({ "issues": [] }),
)
.await;
// epic list takes repo as a POSITIONAL argument.
let out = run_json(&server.uri(), &["epic", "list", REPO]);
let v = stdout_json(&out);
// read → bare server payload echoed verbatim.
assert!(v["issues"].is_array());
assert_eq!(v["issues"].as_array().map(|a| a.len()), Some(0));
assert!(!String::from_utf8_lossy(&out.stdout).contains("No epics"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn epic_add_child_envelope_shape() {
let server = MockServer::start().await;
mock_json(
&server,
"POST",
"/api/v1/test-org/test-repo/issues/42/links",
201,
json!({ "id": "L9", "kind": "parent_of",
"target_issue": { "number": 5 } }),
)
.await;
// epic add-child: epic number and child ref are POSITIONAL.
let out = run_json(
&server.uri(),
&["epic", "add-child", "42", "5", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
assert_eq!(v["child"]["id"].as_str(), Some("L9"));
assert!(v["child"]["child"].is_null());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn epic_mark_single_nest() {
let server = MockServer::start().await;
mock_json(
&server,
"PATCH",
"/api/v1/test-org/test-repo/issues/9",
200,
json!({ "issue": { "number": 9, "kind": "epic" } }),
)
.await;
let out = run_json(&server.uri(), &["epic", "mark", "9", "--repo", REPO]);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
// FIXED: mark() now unwraps the server's `issue` envelope, so the issue
// fields sit directly under `issue`.
assert_eq!(v["issue"]["number"].as_u64(), Some(9));
assert!(v["issue"]["issue"].is_null());
}
▸
tests/adversarial_release.rs
+586
−0
@@ -1,0 +1,586 @@
//! Adversarial `--json` contract tests for `anvil release …`.
//!
//! Drives the real binary against a mock Anvil, feeding edge-case server
//! bodies to hunt the migration bug patterns: double-nesting, wrong container,
//! discarded/missing keys, error-path masking, empty lists leaking human text.
//!
//! Every command file path is /api/v1-prefixed by the client, so mounts match
//! the full `/api/v1/<org>/<name>/releases…` path. Repo is always passed
//! explicitly so `resolve_repo` never touches the developer's git remote.
use serde_json::Value;
use std::process::Output;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn run_json(server_uri: &str, args: &[&str]) -> Output {
// Point ANVIL_CONFIG at a nonexistent scratch file so no on-disk config
// (e.g. a default_repo) can perturb behavior; env creds below take over.
let cfg = std::env::temp_dir().join(format!("anvil-adv-release-cfg-{}.json", unique_suffix()));
std::process::Command::new(env!("CARGO_BIN_EXE_anvil"))
.arg("--json")
.args(args)
.env("ANVIL_SERVER_URL", server_uri)
.env("ANVIL_TOKEN", "test-token")
.env("ANVIL_CONFIG", cfg)
.output()
.expect("run anvil")
}
fn unique_suffix() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
}
/// Parse stdout as exactly one JSON value (the whole contract).
fn stdout_json(out: &Output) -> Value {
let s = String::from_utf8_lossy(&out.stdout);
serde_json::from_str(s.trim())
.unwrap_or_else(|e| panic!("stdout was not a single JSON value: {e}\n--- stdout ---\n{s}"))
}
fn exit_code(out: &Output) -> i32 {
out.status.code().unwrap_or(-1)
}
const REPO: &str = "acme/widgets";
const BASE: &str = "/api/v1/acme/widgets/releases";
// ---------------------------------------------------------------------------
// create — double-nest probe (Pattern 1) + error path (Pattern 4)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn create_wrapped_body_is_not_double_nested() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(BASE))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"release": {"tag_name": "v1.0.0", "title": "First", "draft": true}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"release", "create", "--repo", REPO, "--tag", "v1.0.0", "--title", "First",
],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(true));
// The server wrapper must be unwrapped: no {"release":{"release":…}}.
assert!(v["release"]["release"].is_null(), "double-nested: {v}");
assert_eq!(v["release"]["tag_name"], "v1.0.0");
assert_eq!(v["release"]["draft"], Value::Bool(true));
}
#[tokio::test]
async fn create_flat_body_wraps_under_release() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(BASE))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"tag_name": "v1.0.0", "title": "First"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "create", "--repo", REPO, "--tag", "v1.0.0"],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(true));
assert_eq!(v["release"]["tag_name"], "v1.0.0");
assert!(v["release"]["release"].is_null(), "double-nested: {v}");
}
#[tokio::test]
async fn create_server_error_is_error_envelope_nonzero() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(BASE))
.respond_with(
ResponseTemplate::new(422)
.insert_header("content-type", "application/json")
.set_body_json(serde_json::json!({"error": "tag already exists"})),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "create", "--repo", REPO, "--tag", "v1.0.0"],
);
let v = stdout_json(&out);
assert_eq!(
v["ok"],
Value::Bool(false),
"expected failure envelope: {v}"
);
assert!(v.get("error").is_some(), "missing error field: {v}");
assert_ne!(exit_code(&out), 0, "error path must exit nonzero");
}
// ---------------------------------------------------------------------------
// view — echo shape (Pattern 1) + 200-with-odd-types bug (Pattern 3) + 404
// ---------------------------------------------------------------------------
#[tokio::test]
async fn view_wrapped_body_echoes_bare_release() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/v1.0.0")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"release": {"tag_name": "v1.0.0", "title": "T"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "view", "v1.0.0", "--repo", REPO],
);
let v = stdout_json(&out);
// read echoes the bare payload: top-level fields reachable, no "release" wrapper.
assert_eq!(v["tag_name"], "v1.0.0");
assert!(
v["release"].is_null(),
"should have unwrapped the envelope: {v}"
);
}
/// FIXED (Pattern 3): `view` now echoes the raw 200 body verbatim; the strict
/// `Release` struct only drives the human table, so a valid 200 read whose
/// `author` field is a bare username string (rather than an object) still
/// succeeds and its payload is echoed unchanged.
/// src/commands/release.rs:308
#[tokio::test]
async fn view_200_with_string_author_echoes_body_verbatim() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/v1.0.0")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"tag_name": "v1.0.0",
"title": "T",
"author": "cole" // server sends a username string, not {username:…}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "view", "v1.0.0", "--repo", REPO],
);
let v = stdout_json(&out);
// A 200 read echoes the server payload verbatim, even with a string author.
assert_eq!(exit_code(&out), 0, "200 read must succeed: {v}");
assert_eq!(v["author"], "cole", "{v}");
assert_eq!(v["tag_name"], "v1.0.0", "{v}");
}
#[tokio::test]
async fn view_404_is_error_envelope_nonzero() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/v9.9.9")))
.respond_with(
ResponseTemplate::new(404)
.insert_header("content-type", "application/json")
.set_body_json(serde_json::json!({"error": "not found"})),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "view", "v9.9.9", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(false), "{v}");
assert_ne!(exit_code(&out), 0);
}
// ---------------------------------------------------------------------------
// list — echo array (Pattern 2) + empty (Pattern 6) + null-collection (Pattern 3)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn list_wrapped_array_echoes_bare_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(BASE))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"releases": [{"tag_name": "v1.0.0"}, {"tag_name": "v0.9.0"}]
})))
.mount(&server)
.await;
// `list` takes REPO as a positional, not --repo.
let out = run_json(&server.uri(), &["release", "list", REPO]);
let v = stdout_json(&out);
assert!(v.is_array(), "list should echo a bare array: {v}");
assert_eq!(v.as_array().unwrap().len(), 2);
assert_eq!(v[0]["tag_name"], "v1.0.0");
}
#[tokio::test]
async fn list_empty_is_empty_array_no_human_none_line() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(BASE))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"releases": []
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["release", "list", REPO]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("(none)"),
"human '(none)' leaked: {stdout}"
);
let v = stdout_json(&out);
assert_eq!(v, serde_json::json!([]), "empty list should be []: {v}");
}
/// FIXED (Pattern 3/6): when the server sends `{"releases": null}`, `list` now
/// normalizes the present-but-null collection to an empty JSON array instead of
/// leaking a bare `null`, so a consumer doing `jq '.[]'` keeps working.
/// src/commands/release.rs:254-258, 290
#[tokio::test]
async fn list_null_collection_emits_empty_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(BASE))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"releases": null
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["release", "list", REPO]);
let v = stdout_json(&out);
// A present-but-null collection normalizes to [].
assert!(
v.is_array() && v.as_array().unwrap().is_empty(),
"expected []: {v}"
);
assert_eq!(exit_code(&out), 0);
}
// ---------------------------------------------------------------------------
// update — double-nest (Pattern 1) + no-op path
// ---------------------------------------------------------------------------
#[tokio::test]
async fn update_wrapped_body_is_not_double_nested() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path(format!("{BASE}/v1.0.0")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"release": {"tag_name": "v1.0.0", "title": "New"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"release", "update", "v1.0.0", "--title", "New", "--repo", REPO,
],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(true));
assert!(v["release"]["release"].is_null(), "double-nested: {v}");
assert_eq!(v["release"]["title"], "New");
}
#[tokio::test]
async fn update_no_fields_is_ok_envelope_no_server_call() {
// No mounts: a no-op update must not contact the server.
let server = MockServer::start().await;
let out = run_json(
&server.uri(),
&["release", "update", "v1.0.0", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(true), "{v}");
assert_eq!(exit_code(&out), 0);
}
// ---------------------------------------------------------------------------
// publish — fabricated payload (Pattern 1: no double nest)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn publish_emits_ok_release_payload() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(format!("{BASE}/v1.0.0/publish")))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "publish", "v1.0.0", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(true));
assert_eq!(v["release"]["tag_name"], "v1.0.0");
assert_eq!(v["release"]["draft"], Value::Bool(false));
assert!(v["release"]["release"].is_null(), "double-nested: {v}");
}
// ---------------------------------------------------------------------------
// delete / delete-asset — deleted envelope (Pattern contract)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn delete_emits_deleted_tag() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path(format!("{BASE}/v1.0.0")))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "delete", "v1.0.0", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(
v,
serde_json::json!({"ok": true, "deleted": "v1.0.0"}),
"{v}"
);
}
#[tokio::test]
async fn delete_asset_emits_deleted_id() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path(format!("{BASE}/v1.0.0/assets/a1")))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "delete-asset", "v1.0.0", "a1", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v, serde_json::json!({"ok": true, "deleted": "a1"}), "{v}");
}
// ---------------------------------------------------------------------------
// assets — echo array + empty + null-collection (Patterns 2/6/3)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn assets_empty_is_empty_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/v1.0.0/assets")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"assets": []
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "assets", "v1.0.0", "--repo", REPO],
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("(none)"),
"human '(none)' leaked: {stdout}"
);
let v = stdout_json(&out);
assert_eq!(v, serde_json::json!([]), "{v}");
}
/// FIXED (Pattern 3/6): same present-but-null shape as `list`. `{"assets": null}`
/// now normalizes to an empty JSON array instead of a bare `null`.
/// src/commands/release.rs:617-621, 641
#[tokio::test]
async fn assets_null_collection_emits_empty_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/v1.0.0/assets")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"assets": null
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "assets", "v1.0.0", "--repo", REPO],
);
let v = stdout_json(&out);
assert!(
v.is_array() && v.as_array().unwrap().is_empty(),
"expected []: {v}"
);
assert_eq!(exit_code(&out), 0);
}
// ---------------------------------------------------------------------------
// upload — double-nest (Pattern 1) + file-not-found error (Pattern 4)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn upload_wrapped_asset_is_not_double_nested() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(format!("{BASE}/v1.0.0/assets")))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"asset": {"filename": "art.bin", "size_bytes": 10, "short_id": "as1"}
})))
.mount(&server)
.await;
let file = std::env::temp_dir().join(format!("anvil-adv-upload-{}.bin", unique_suffix()));
std::fs::write(&file, b"0123456789").unwrap();
let out = run_json(
&server.uri(),
&[
"release",
"upload",
"v1.0.0",
file.to_str().unwrap(),
"--repo",
REPO,
],
);
let _ = std::fs::remove_file(&file);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(true), "{v}");
assert!(v["asset"]["asset"].is_null(), "double-nested: {v}");
assert_eq!(v["asset"]["filename"], "art.bin");
}
#[tokio::test]
async fn upload_missing_file_is_error_envelope_nonzero() {
let server = MockServer::start().await;
let missing = std::env::temp_dir().join(format!("anvil-adv-nope-{}.bin", unique_suffix()));
let out = run_json(
&server.uri(),
&[
"release",
"upload",
"v1.0.0",
missing.to_str().unwrap(),
"--repo",
REPO,
],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(false), "{v}");
assert!(v.get("error").is_some(), "{v}");
assert_ne!(exit_code(&out), 0);
}
// ---------------------------------------------------------------------------
// download — reads assets then downloads (Pattern 7: result is a JSON field)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn download_emits_ok_downloaded_field() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/v1.0.0/assets")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"assets": [{"filename": "art.bin", "short_id": "as1"}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/v1.0.0/assets/as1/download")))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"payloadbytes".to_vec()))
.mount(&server)
.await;
let dest = std::env::temp_dir().join(format!("anvil-adv-dl-{}.bin", unique_suffix()));
let out = run_json(
&server.uri(),
&[
"release",
"download",
"v1.0.0",
"art.bin",
"--repo",
REPO,
"--output",
dest.to_str().unwrap(),
],
);
let file_written = dest.exists();
let _ = std::fs::remove_file(&dest);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(true), "{v}");
// The downloaded bytes must NOT be dumped to stdout; only a JSON field.
assert!(v["downloaded"]["file"].is_string(), "{v}");
assert!(
file_written,
"download should have written the file to --output"
);
}
// ---------------------------------------------------------------------------
// changelog — echo (Pattern 7: text lives in a JSON field, not raw stdout)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn changelog_text_is_json_not_raw_stdout() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/changelog")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"changelog": "- fix a bug\n- add a feature"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "changelog", "v1.0.0", "v2.0.0", "--repo", REPO],
);
// Whole stdout must parse as one JSON value (no leading human header line).
let v = stdout_json(&out);
assert_eq!(v["changelog"], "- fix a bug\n- add a feature", "{v}");
}
#[tokio::test]
async fn changelog_server_error_is_error_envelope_nonzero() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/changelog")))
.respond_with(
ResponseTemplate::new(500)
.insert_header("content-type", "application/json")
.set_body_json(serde_json::json!({"error": "boom"})),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "changelog", "v1.0.0", "v2.0.0", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(false), "{v}");
assert_ne!(exit_code(&out), 0);
}
▸
tests/adversarial_requirement.rs
+1138
−0
@@ -1,0 +1,1138 @@
//! Adversarial `--json` contract tests for the `requirement` command surface.
//!
//! These probe the edges the JSON-output migration touched: envelope shape
//! (bare read vs `{"ok":true,…}` vs `{"items":…}`), double-nesting, gate
//! commands that must emit JSON *and* exit nonzero, error paths that must
//! surface `{"ok":false,"error":…}`, and empty-collection / missing-key cases.
//!
//! Everything asserts against **stdout parsed as JSON** plus the process exit
//! code — an agent scripting the CLI sees exactly this.
use serde_json::{json, Value};
use std::process::Output;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
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("run anvil")
}
/// Parse stdout as JSON, dumping both streams on failure so a leaked human
/// line (or an empty 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),
)
})
}
// ─────────────────────────── list ───────────────────────────
/// `list` (default kind=requirement) echoes the server body bare — NOT wrapped
/// in `{"items":…}`. Contract: a read of a server object is the bare payload.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_requirements_bare_read() {
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-A-001", "title": "T"}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["requirement", "list", "--repo", "test-org/test-repo"],
);
assert!(out.status.success());
let v = stdout_json(&out);
// bare read: top-level is the server envelope, not {"items":…}
assert!(v["requirements"].is_array(), "got: {v}");
assert!(v["items"].is_null(), "should not be wrapped in items: {v}");
assert_eq!(v["requirements"][0]["requirement_id"], "REQ-A-001");
}
/// EMPTY-LIST: an empty server collection must serialize as an empty JSON array
/// under its key — never a human "(none)" line leaking to stdout.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_requirements_empty_is_clean_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": []})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["requirement", "list", "--repo", "test-org/test-repo"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(
v["requirements"].as_array().map(|a| a.len()),
Some(0),
"got: {v}"
);
}
/// list --kind standard echoes the org standards body bare.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_standards_bare_read() {
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": "Erasure"}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"list",
"--kind",
"standard",
"--organization",
"test-org",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(
v["standards"][0]["requirement_id"], "STD-GDPR-017",
"got: {v}"
);
}
/// HIGH-RISK: `list --kind all` merges two server bodies into one document with
/// both halves reachable and unwrapped (arrays, not `{"requirements":{...}}`).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_all_merges_both_halves() {
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-A-001", "title": "R"}]
})))
.mount(&server)
.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-B-002", "title": "S"}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"list",
"--kind",
"all",
"--repo",
"test-org/test-repo",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
// Both halves present as arrays, not doubly-nested under their own key.
assert!(v["requirements"].is_array(), "got: {v}");
assert!(v["standards"].is_array(), "got: {v}");
assert_eq!(v["requirements"][0]["requirement_id"], "REQ-A-001");
assert_eq!(v["standards"][0]["requirement_id"], "STD-B-002");
assert!(v["requirements"][0].is_object() && v["requirements"]["requirements"].is_null());
}
/// ERROR PATH (client-side validation): `--kind all` without --organization
/// fails *before* any HTTP with a clean `{"ok":false,"error":…}` and nonzero exit.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_all_missing_org_errors() {
let server = MockServer::start().await;
let out = run_json(
&server.uri(),
&[
"requirement",
"list",
"--kind",
"all",
"--repo",
"test-org/test-repo",
],
);
assert!(!out.status.success(), "expected nonzero exit");
let v = stdout_json(&out);
assert_eq!(v["ok"], false, "got: {v}");
assert!(v["error"].is_string(), "got: {v}");
}
// ─────────────────────────── view ───────────────────────────
/// view REQ echoes the server object bare and unwrapped.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn view_requirement_bare_read() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements/REQ-A-001"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "uuid-1", "requirement_id": "REQ-A-001", "title": "T",
"category": "security", "status": "active", "version": "3"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"view",
"REQ-A-001",
"--repo",
"test-org/test-repo",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["requirement_id"], "REQ-A-001", "got: {v}");
assert!(
v["ok"].is_null(),
"read must be bare, not an ok-envelope: {v}"
);
}
/// FIXED (pattern #3): view REQ no longer couples JSON emission to a strict
/// display struct (`RequirementDetail`). The strict struct now drives only the
/// human table; the `--json` path echoes the raw server body verbatim. A body
/// that is perfectly valid JSON but carries `version` as a NUMBER (many servers
/// version rows with an integer) is echoed as-is with exit 0 — matching the
/// defensive behavior of `view_standard`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn view_requirement_echoes_unexpected_shape() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements/REQ-A-002"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "uuid-2", "requirement_id": "REQ-A-002", "title": "T",
"version": 2
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"view",
"REQ-A-002",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
// FIXED behavior: valid server JSON is echoed verbatim, numeric version intact.
assert_eq!(v["requirement_id"], "REQ-A-002", "got: {v}");
assert_eq!(v["version"], 2, "numeric version echoed verbatim: {v}");
assert!(
v["ok"].is_null(),
"read must be bare, not an ok-envelope: {v}"
);
}
/// view STD echoes the server object bare (and view_standard reads defensively,
/// so a numeric field here would NOT break it — contrast with the REQ case).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn view_standard_bare_read_defensive() {
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": "uuid-s", "requirement_id": "STD-GDPR-017", "title": "Erasure",
"mandatory": true, "version": 7
})))
.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", "got: {v}");
assert_eq!(v["version"], 7, "numeric version echoed verbatim: {v}");
}
/// ERROR PATH: bad ID prefix (no REQ-/STD-) fails client-side with a clean
/// error envelope and nonzero exit — no HTTP, no human line on stdout.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn view_unknown_prefix_errors() {
let server = MockServer::start().await;
let out = run_json(
&server.uri(),
&[
"requirement",
"view",
"FOO-1",
"--repo",
"test-org/test-repo",
],
);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false, "got: {v}");
}
/// ERROR PATH: server 404 on a view surfaces as `{"ok":false,"error":…}` with
/// nonzero exit — not a masked exit 0, not a human line.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn view_requirement_404_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements/REQ-A-404"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!({"error": "not found"})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"view",
"REQ-A-404",
"--repo",
"test-org/test-repo",
],
);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false, "got: {v}");
assert!(v["error"].is_string(), "got: {v}");
}
// ─────────────────────────── create / update ───────────────────────────
/// create REQ: `{"ok":true,"requirement":{…flat…}}` — no double nest, field
/// reachable at v["requirement"]["requirement_id"].
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn create_requirement_no_double_nest() {
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": "uuid-c", "requirement_id": "REQ-A-010", "title": "New"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"create",
"--requirement-id",
"REQ-A-010",
"--title",
"New",
"--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, "got: {v}");
assert_eq!(v["requirement"]["requirement_id"], "REQ-A-010", "got: {v}");
assert!(
v["requirement"]["requirement"].is_null(),
"double nest: {v}"
);
}
/// create STD: `{"ok":true,"standard":{…}}`, no double nest.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn create_standard_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/standards"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": "uuid-s2", "requirement_id": "STD-X-001", "title": "Std"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"create",
"--requirement-id",
"STD-X-001",
"--title",
"Std",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["standard"]["requirement_id"], "STD-X-001", "got: {v}");
assert!(v["standard"]["standard"].is_null(), "double nest: {v}");
}
/// ERROR PATH: server 422 on create surfaces as an error envelope + nonzero exit
/// (not a spurious `{"ok":true,…}` masking the failure).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn create_requirement_422_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/requirements"))
.respond_with(ResponseTemplate::new(422).set_body_json(json!({"error": "duplicate"})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"create",
"--requirement-id",
"REQ-A-011",
"--title",
"Dup",
"--repo",
"test-org/test-repo",
],
);
assert!(!out.status.success(), "must not mask a 422 as success");
let v = stdout_json(&out);
assert_eq!(v["ok"], false, "got: {v}");
}
/// update REQ: `{"ok":true,"requirement":{…}}`, no double nest.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn update_requirement_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/api/v1/test-org/test-repo/requirements/REQ-A-010"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "uuid-c", "requirement_id": "REQ-A-010", "title": "Renamed"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"update",
"REQ-A-010",
"--title",
"Renamed",
"--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"]["title"], "Renamed", "got: {v}");
assert!(
v["requirement"]["requirement"].is_null(),
"double nest: {v}"
);
}
/// update STD: `{"ok":true,"standard":{…}}`, no double nest.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn update_standard_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/api/v1/test-org/standards/STD-X-001"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "uuid-s2", "requirement_id": "STD-X-001", "title": "Std2"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"update",
"STD-X-001",
"--title",
"Std2",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["standard"]["requirement_id"], "STD-X-001", "got: {v}");
assert!(v["standard"]["standard"].is_null(), "double nest: {v}");
}
// ─────────────────────────── delete ───────────────────────────
/// delete REQ: `{"ok":true,"deleted":"REQ-…"}`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn delete_requirement_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/test-org/test-repo/requirements/REQ-A-010"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"delete",
"REQ-A-010",
"--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, "got: {v}");
assert_eq!(v["deleted"], "REQ-A-010", "got: {v}");
}
/// delete STD: `{"ok":true,"deleted":"STD-…"}`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn delete_standard_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/test-org/standards/STD-X-001"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"delete",
"STD-X-001",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["deleted"], "STD-X-001", "got: {v}");
}
// ─────────────────────────── link / unlink ───────────────────────────
/// link: `{"ok":true,"link":{…server body…}}` — probe the flat server contract
/// the human path assumes; assert no double nest.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn link_test_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(
"/api/v1/test-org/test-repo/requirements/REQ-A-010/links",
))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"test_name": "logintest", "test_link_id": "tl-1"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"link",
"REQ-A-010",
"--test",
"logintest",
"--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, "got: {v}");
assert_eq!(v["link"]["test_name"], "logintest", "got: {v}");
assert!(v["link"]["link"].is_null(), "double nest: {v}");
}
/// link on a STD-* ID is rejected client-side (links are requirements-only) →
/// error envelope + nonzero exit, no HTTP.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn link_standard_id_rejected() {
let server = MockServer::start().await;
let out = run_json(
&server.uri(),
&[
"requirement",
"link",
"STD-X-001",
"--test",
"t",
"--repo",
"test-org/test-repo",
],
);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false, "got: {v}");
}
/// unlink: client-built `{"ok":true,"unlinked":{"requirement":…,"test":…}}`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unlink_test_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path(
"/api/v1/test-org/test-repo/requirements/REQ-A-010/links/logintest",
))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"unlink",
"REQ-A-010",
"--test",
"logintest",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["unlinked"]["requirement"], "REQ-A-010", "got: {v}");
assert_eq!(v["unlinked"]["test"], "logintest", "got: {v}");
}
// ─────────────────────────── applicability ───────────────────────────
/// applicability list echoes the server `{"repositories":[…]}` bare.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn applicability_list_bare_read() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/standards/STD-X-001/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-X-001",
"--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", "got: {v}");
}
/// applicability list EMPTY: no repos opted in → `{"repositories":[]}`, no
/// "(none)" human line on stdout.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn applicability_list_empty_clean() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/standards/STD-X-001/applicabilities"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"repositories": []})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"applicability",
"list",
"STD-X-001",
"--organization",
"test-org",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(
v["repositories"].as_array().map(|a| a.len()),
Some(0),
"got: {v}"
);
}
/// applicability add: client-built `{"ok":true,"applicability":{…,"opted_in":true}}`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn applicability_add_envelope() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path(
"/api/v1/test-org/standards/STD-X-001/applicabilities/app",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"applicability",
"add",
"STD-X-001",
"--organization",
"test-org",
"--repo",
"app",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["applicability"]["opted_in"], true, "got: {v}");
assert_eq!(v["applicability"]["repo"], "app", "got: {v}");
assert!(
v["applicability"]["applicability"].is_null(),
"double nest: {v}"
);
}
/// applicability remove: `opted_in:false`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn applicability_remove_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path(
"/api/v1/test-org/standards/STD-X-001/applicabilities/app",
))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"applicability",
"remove",
"STD-X-001",
"--organization",
"test-org",
"--repo",
"app",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["applicability"]["opted_in"], false, "got: {v}");
}
// ─────────────────────────── matrix ───────────────────────────
/// matrix (requirement) echoes the server `{"matrix":[…]}` bare.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn matrix_requirements_bare_read() {
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": [{"requirement": {"requirement_id": "REQ-A-001", "title": "T"},
"coverage_status": "covered", "tests": []}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["requirement", "matrix", "--repo", "test-org/test-repo"],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(
v["matrix"][0]["requirement"]["requirement_id"], "REQ-A-001",
"got: {v}"
);
}
/// matrix (standard) EMPTY echoes bare, no human "(no standards defined)" leak.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn matrix_standards_empty_clean() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/standards/matrix"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"matrix": []})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"matrix",
"--kind",
"standard",
"--organization",
"test-org",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["matrix"].as_array().map(|a| a.len()), Some(0), "got: {v}");
}
// ─────────────────────────── status (GATE) ───────────────────────────
/// HIGH-RISK GATE: uncovered requirements → the JSON payload is STILL emitted on
/// stdout, and the process exits nonzero. Both must hold.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn status_gate_fails_but_emits_json() {
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": [
{"requirement": {"requirement_id": "REQ-A-001", "title": "Covered"},
"coverage_status": "covered"},
{"requirement": {"requirement_id": "REQ-A-002", "title": "Uncovered"},
"coverage_status": "uncovered"}
]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["requirement", "status", "--repo", "test-org/test-repo"],
);
// GATE: nonzero exit …
assert!(!out.status.success(), "uncovered must exit nonzero");
// … but a valid machine payload is on stdout anyway.
let v = stdout_json(&out);
assert_eq!(v["passed"], false, "got: {v}");
assert_eq!(v["counts"]["uncovered"], 1, "got: {v}");
assert_eq!(v["failing"][0]["requirement_id"], "REQ-A-002", "got: {v}");
// The error envelope must NOT have double-emitted onto stdout.
assert!(
v["ok"].is_null(),
"gate payload must not be an ok-envelope: {v}"
);
}
/// status passing: exit 0, `passed:true`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn status_gate_passes() {
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": [{"requirement": {"requirement_id": "REQ-A-001", "title": "C"},
"coverage_status": "covered"}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["requirement", "status", "--repo", "test-org/test-repo"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["passed"], true, "got: {v}");
}
/// status --strict: a `partial` requirement trips the gate (nonzero) yet still
/// emits its JSON.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn status_strict_partial_fails_but_emits_json() {
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": [{"requirement": {"requirement_id": "REQ-A-003", "title": "P"},
"coverage_status": "partial"}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"status",
"--strict",
"--repo",
"test-org/test-repo",
],
);
assert!(!out.status.success(), "strict + partial must exit nonzero");
let v = stdout_json(&out);
assert_eq!(v["passed"], false, "got: {v}");
assert_eq!(v["counts"]["partial"], 1, "got: {v}");
}
/// status on an EMPTY matrix: empty repo passes (exit 0), warning goes to stderr,
/// stdout stays a clean machine payload.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn status_empty_matrix_passes_clean() {
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": []})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["requirement", "status", "--repo", "test-org/test-repo"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["passed"], true, "got: {v}");
assert_eq!(v["counts"]["total"], 0, "got: {v}");
}
/// HIGH-RISK GATE: `status --strict-standards` on a 422 (uncovered mandatory
/// standards) must emit `{"passed":false,"uncovered":[…]}` AND exit nonzero.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn strict_standards_gate_fails_but_emits_json() {
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-X-001", "title": "S"},
"repo": {"slug": "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 nonzero"
);
let v = stdout_json(&out);
assert_eq!(v["passed"], false, "got: {v}");
assert_eq!(
v["uncovered"][0]["standard"]["requirement_id"], "STD-X-001",
"got: {v}"
);
assert!(
v["ok"].is_null(),
"gate payload must not be an ok-envelope: {v}"
);
}
/// status --strict-standards passing (2xx status=ok): `{"passed":true,"uncovered":[]}`,
/// exit 0.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn strict_standards_gate_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, "got: {v}");
assert_eq!(
v["uncovered"].as_array().map(|a| a.len()),
Some(0),
"got: {v}"
);
}
// ─────────────────────────── import ───────────────────────────
/// import (apply) echoes the server body bare.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn import_apply_bare_read() {
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!({
"applied": {"created": 2, "updated": 1, "unchanged": 0}
})))
.mount(&server)
.await;
// Provide the import file via stdin ("-").
let dir = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".into());
let f = format!("{dir}/adversarial_import_{}.yml", std::process::id());
std::fs::write(&f, "- requirement_id: REQ-A-001\n title: T\n").unwrap();
let out = run_json(
&server.uri(),
&["requirement", "import", &f, "--repo", "test-org/test-repo"],
);
let _ = std::fs::remove_file(&f);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["applied"]["created"], 2, "got: {v}");
assert!(v["ok"].is_null(), "import read must be bare: {v}");
}
/// import --dry-run echoes the preview body bare.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn import_dry_run_bare_read() {
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!({
"preview": {"create": [{"requirement_id": "REQ-A-002"}],
"update": [], "unchanged": [], "errors": []}
})))
.mount(&server)
.await;
let dir = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".into());
let f = format!("{dir}/adversarial_import_dry_{}.yml", std::process::id());
std::fs::write(&f, "- requirement_id: REQ-A-002\n title: T\n").unwrap();
let out = run_json(
&server.uri(),
&[
"requirement",
"import",
&f,
"--dry-run",
"--repo",
"test-org/test-repo",
],
);
let _ = std::fs::remove_file(&f);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(
v["preview"]["create"][0]["requirement_id"], "REQ-A-002",
"got: {v}"
);
}
▸
tests/adversarial_runner.rs
+575
−0
@@ -1,0 +1,575 @@
//! Adversarial JSON-contract tests for `anvil runner` (admin + local
//! subcommands). We drive the real binary with `--json`, point it at a
//! wiremock server via `ANVIL_SERVER_URL`, and assert the exact envelope
//! shape the output contract promises.
//!
//! Commands attacked: list / view / status / doctor / token / stop /
//! remove / update. (start/restart/configure/service are skipped — they
//! hang or mutate host state.)
use serde_json::{json, Value};
use std::process::Output;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
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("run anvil")
}
/// Parse stdout as JSON, failing loudly with the raw bytes on error.
fn stdout_json(out: &Output) -> Value {
let s = String::from_utf8_lossy(&out.stdout);
serde_json::from_str(&s).unwrap_or_else(|e| panic!("stdout was not valid JSON ({e}):\n{s}"))
}
// ============================================================
// list -> GET /api/v1/runners/orgs/{org} | /repos/{o}/{r}
// echoes the server payload verbatim (Response::read)
// ============================================================
#[tokio::test]
async fn list_org_echoes_server_payload() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/runners/orgs/acme"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"runners": [{"id": "r1", "name": "box", "status": "online"}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "list", "--org", "acme"]);
assert!(out.status.success(), "expected exit 0");
let v = stdout_json(&out);
// Read echoes the bare server object — the {"runners":[...]} wrapper stays.
assert!(v["runners"].is_array(), "runners array must survive: {v}");
assert_eq!(v["runners"][0]["id"], "r1");
// Must NOT be re-wrapped into {"items":...} or {"ok":...}.
assert!(v.get("items").is_none());
assert!(v.get("ok").is_none());
}
#[tokio::test]
async fn list_empty_is_json_not_none_line() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/runners/orgs/empty"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"runners": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "list", "--org", "empty"]);
assert!(out.status.success());
let v = stdout_json(&out);
// Empty collection must be a JSON empty array, never a human "(none)".
assert_eq!(v["runners"].as_array().map(|a| a.len()), Some(0));
let raw = String::from_utf8_lossy(&out.stdout);
assert!(!raw.contains("(none)"), "human placeholder leaked: {raw}");
}
#[tokio::test]
async fn list_repo_error_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/runners/repos/acme/widgets"))
.respond_with(ResponseTemplate::new(500).set_body_json(json!({"error": "boom"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "list", "--repo", "acme/widgets"]);
assert!(!out.status.success(), "5xx must yield nonzero exit");
let v = stdout_json(&out);
assert_eq!(v["ok"], false, "error must be {{\"ok\":false,...}}: {v}");
assert!(v.get("error").is_some());
}
#[tokio::test]
async fn list_without_target_is_error_envelope() {
// No server call happens; the command Errs before touching the network.
let server = MockServer::start().await;
let out = run_json(&server.uri(), &["runner", "list"]);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
}
// ============================================================
// view -> GET /api/v1/runners/{id}
// unwraps {"runner":..}/{"data":..}, echoes bare (Response::read)
// ============================================================
#[tokio::test]
async fn view_unwraps_wrapped_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/runners/r1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"runner": {"id": "r1", "name": "box", "status": "online"}
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "view", "r1"]);
assert!(out.status.success());
let v = stdout_json(&out);
// The inner runner object is echoed bare — no {"runner":{"runner":..}}.
assert_eq!(v["id"], "r1");
assert_eq!(v["name"], "box");
assert!(v.get("runner").is_none(), "must not still be wrapped: {v}");
}
#[tokio::test]
async fn view_flat_body_echoed_verbatim() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/runners/r2"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "r2", "name": "flat", "arch": "x86_64"
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "view", "r2"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["id"], "r2");
assert_eq!(v["arch"], "x86_64");
}
#[tokio::test]
async fn view_404_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/runners/missing"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!({"error": "not found"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "view", "missing"]);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
}
// ============================================================
// update -> PATCH /api/v1/runners/{id}
// {"ok":true,"runner":<unwrapped>} (Response::ok)
// ============================================================
#[tokio::test]
async fn update_wrapped_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/api/v1/runners/r1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"runner": {"id": "r1", "name": "renamed"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["runner", "update", "r1", "--name", "renamed"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
// Correct unwrap: runner.name reachable, no {"runner":{"runner":..}}.
assert_eq!(v["runner"]["id"], "r1");
assert_eq!(v["runner"]["name"], "renamed");
assert!(v["runner"]["runner"].is_null(), "double-nest detected: {v}");
}
#[tokio::test]
async fn update_flat_body_wrapped_once() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/api/v1/runners/r3"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "r3", "labels": "gpu"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["runner", "update", "r3", "--labels", "gpu"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["runner"]["id"], "r3");
assert_eq!(v["runner"]["labels"], "gpu");
}
#[tokio::test]
async fn update_500_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/api/v1/runners/r4"))
.respond_with(ResponseTemplate::new(500).set_body_json(json!({"error": "nope"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "update", "r4", "--name", "x"]);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
}
/// FIXED: `update()` now truncates the id char-safely (`id.chars().take(8)`),
/// so a multibyte id no longer slices mid-character and no longer panics. The
/// command completes cleanly and emits its normal JSON envelope on stdout.
#[tokio::test]
async fn update_multibyte_id_stays_valid_json() {
let server = MockServer::start().await;
// Match the PATCH regardless of the (percent-encoded) path.
Mock::given(method("PATCH"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": "x"})))
.mount(&server)
.await;
// 6 CJK chars = 18 bytes; byte index 8 is mid-character (would have panicked).
let out = run_json(
&server.uri(),
&["runner", "update", "日本語日本語", "--name", "x"],
);
assert!(out.status.success(), "char-safe truncation should exit 0");
// Valid JSON on stdout, and the normal success envelope.
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
// No panic leaked to stderr.
let err = String::from_utf8_lossy(&out.stderr);
assert!(
!err.contains("char boundary") && !err.contains("panic"),
"must not panic on a multibyte id, got stderr: {err}"
);
}
// ============================================================
// remove -> DELETE /api/v1/runners/{id}
// {"ok":true,"deleted":"<id>"} (Response::deleted)
// ============================================================
#[tokio::test]
async fn remove_happy_deleted_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/runners/runner-12345"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "remove", "runner-12345"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["deleted"], "runner-12345");
}
#[tokio::test]
async fn remove_404_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/runners/gone"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!({"error": "gone"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "remove", "gone"]);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
}
/// FIXED (same root cause as update): `remove()` now truncates the id
/// char-safely, so a multibyte id no longer panics. The command emits its
/// normal `{"ok":true,"deleted":..}` envelope as valid JSON.
#[tokio::test]
async fn remove_multibyte_id_stays_valid_json() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "remove", "日本語日本語"]);
assert!(out.status.success(), "char-safe truncation should exit 0");
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["deleted"], "日本語日本語");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
!err.contains("char boundary") && !err.contains("panic"),
"must not panic on a multibyte id, got stderr: {err}"
);
}
// ============================================================
// token -> POST /api/v1/runners/orgs/{org}/tokens | repos/.../tokens
// {"ok":true,"token":"<value>"} (Response::ok)
// ============================================================
#[tokio::test]
async fn token_org_nested_value_shape() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/runners/orgs/acme/tokens"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"token": {"value": "tok-abc"}
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "token", "--org", "acme"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["token"], "tok-abc");
}
#[tokio::test]
async fn token_flat_string_shape() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/runners/orgs/acme/tokens"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"token": "tok-flat"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "token", "--org", "acme"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["token"], "tok-flat");
}
#[tokio::test]
async fn token_repo_data_shape() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/runners/repos/acme/widgets/tokens"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": {"token": "tok-data"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["runner", "token", "--repo", "acme/widgets"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["token"], "tok-data");
}
/// FIXED: when the server body carries the token under none of the three
/// recognized shapes, the command now fails loudly instead of persisting a
/// placeholder `"?"`. It errors with a nonzero exit and a `{"ok":false,...}`
/// envelope whose error mentions the missing token — never a `"?"` credential.
#[tokio::test]
async fn token_missing_key_errors() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/runners/orgs/acme/tokens"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"unexpected": "shape"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "token", "--org", "acme"]);
assert!(
!out.status.success(),
"missing token must yield nonzero exit"
);
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
let err = v["error"].as_str().unwrap_or("");
assert!(
err.to_lowercase().contains("token"),
"error should mention the missing token: {v}"
);
// No placeholder credential must appear anywhere in the envelope.
assert!(v.get("token").is_none(), "no token key on an error: {v}");
assert_ne!(v["token"], "?");
}
#[tokio::test]
async fn token_500_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/runners/orgs/acme/tokens"))
.respond_with(ResponseTemplate::new(500).set_body_json(json!({"error": "nope"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "token", "--org", "acme"]);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
}
#[tokio::test]
async fn token_bad_repo_format_is_error_envelope() {
let server = MockServer::start().await;
let out = run_json(&server.uri(), &["runner", "token", "--repo", "no-slash"]);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
}
// ============================================================
// status -> LOCAL only, no server call. Response::read(body)
// ============================================================
#[tokio::test]
async fn status_local_emits_json_not_human() {
let server = MockServer::start().await;
let out = run_json(
&server.uri(),
&[
"runner",
"status",
"--config",
"/nonexistent/adv/config.json",
"--pid-file",
"/nonexistent/adv/runner.pid",
"--service-name",
"adv-test-nope",
],
);
assert!(
out.status.success(),
"status reads local state, should exit 0"
);
let v = stdout_json(&out);
assert_eq!(v["configured"], false);
// running.alive must be a bool, not missing/garbage.
assert_eq!(v["running"]["alive"], false);
let raw = String::from_utf8_lossy(&out.stdout);
assert!(!raw.contains("Runner Status"), "human header leaked: {raw}");
}
// ============================================================
// doctor -> GATE. A failing check must EMIT its JSON payload, THEN
// exit nonzero. Payload is a bare array (Response::gate).
// ============================================================
#[tokio::test]
async fn doctor_failing_gate_emits_json_then_nonzero() {
let server = MockServer::start().await;
// Config unreadable -> config_ok=false -> gate(payload, 1, ...).
let out = run_json(
&server.uri(),
&[
"runner",
"doctor",
"--config",
"/nonexistent/adv/config.json",
"--pid-file",
"/nonexistent/adv/runner.pid",
"--service-name",
"adv-test-nope",
],
);
assert!(!out.status.success(), "failing gate must exit nonzero");
let v = stdout_json(&out);
// Payload is the bare checks array — still valid JSON on stdout.
assert!(v.is_array(), "doctor payload must be a JSON array: {v}");
let arr = v.as_array().unwrap();
assert!(!arr.is_empty());
// The config-readable check must have failed.
let cfg = arr
.iter()
.find(|c| c["check"] == "Config readable")
.unwrap();
assert_eq!(cfg["status"], "error");
// Must NOT have been replaced by a {"ok":false,...} error doc.
assert!(
v.get("ok").is_none(),
"gate should emit payload, not error env: {v}"
);
}
#[tokio::test]
async fn doctor_healthy_gate_passes_exit_zero() {
let server = MockServer::start().await;
// runner id the config will carry; doctor GETs /runners/{id}.
Mock::given(method("GET"))
.and(path("/api/v1/runners/r-doc"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": "r-doc"})))
.mount(&server)
.await;
// Write a runner config pointing at the mock server.
let dir = std::env::temp_dir().join(format!("adv-runner-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cfg_path = dir.join("config.json");
let cfg = json!({
"server_url": server.uri(),
"runner_id": "r-doc",
"runner_token": "rt",
"name": "n",
"labels": ["self-hosted"],
"work_dir": "/tmp/wd",
"parallel": 1
});
std::fs::write(&cfg_path, serde_json::to_string(&cfg).unwrap()).unwrap();
let out = run_json(
&server.uri(),
&[
"runner",
"doctor",
"--config",
cfg_path.to_str().unwrap(),
"--pid-file",
"/nonexistent/adv/runner.pid",
"--service-name",
"adv-test-nope",
],
);
let v = stdout_json(&out);
assert!(v.is_array(), "payload must be an array: {v}");
let arr = v.as_array().unwrap();
// All checks OK -> exit 0.
assert!(
out.status.success(),
"healthy doctor should exit 0; checks={v}"
);
assert!(arr.iter().all(|c| c["status"] == "ok"), "checks: {v}");
let _ = std::fs::remove_dir_all(&dir);
}
// ============================================================
// stop -> LOCAL. No PID file -> {"ok":true,"stopped":{"running":false,..}}
// ============================================================
#[tokio::test]
async fn stop_no_pidfile_is_ok_envelope() {
let server = MockServer::start().await;
let out = run_json(
&server.uri(),
&[
"runner",
"stop",
"--pid-file",
"/nonexistent/adv/runner.pid",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["stopped"]["running"], false);
let raw = String::from_utf8_lossy(&out.stdout);
assert!(
!raw.contains("No runner is running"),
"human line leaked: {raw}"
);
}
▸
tests/auth_rotate.rs
+5
−2
@@ -191,9 +191,12 @@
let out = home.run(&["--json", "auth", "rotate"]);
assert!(!out.status.success(), "expected a non-zero exit");
// Under --json the error is a JSON envelope on stdout, not human stderr.
let body: Value = serde_json::from_slice(&out.stdout).expect("stdout is JSON on error");
assert_eq!(body["ok"], false);
assert!(
body["error"].as_str().unwrap_or("").contains("--yes"),
String::from_utf8_lossy(&out.stderr).contains("--yes"),
"error should point at --yes"
"error should point at --yes; got {body}"
);
assert_eq!(home.stored()["token"], "anvil_oldtoken");
}
▸
tests/json_contract.rs
+207
−0
@@ -1,0 +1,207 @@
//! Structural enforcement of the `--json` output contract.
//!
//! The contract (see `src/output.rs`) is correct-by-construction: every leaf
//! command returns an `output::Response`, `commands::run` is the single point
//! that writes it to stdout under `--json`, and the human helpers self-suppress
//! — so a leaf can neither forget to emit JSON (the compiler requires a
//! `Response`) nor leak human text (raw `println!` is a clippy error, see
//! `clippy.toml`). This test is the regression tripwire on top of that: it walks
//! the real clap tree and asserts the full set of leaf commands, so adding a new
//! subcommand fails here until its author has consciously wired it through the
//! same contract (and, ideally, added a `--json` test for its shape below).
use clap::CommandFactory;
/// Every leaf (executable) command path in the CLI, as space-joined names,
/// e.g. `"requirement status"`. A command is a leaf when it has no subcommands.
fn leaf_paths() -> Vec<String> {
let cmd = anvil::commands::Cli::command();
let mut leaves = Vec::new();
collect(&cmd, String::new(), &mut leaves);
leaves.sort();
leaves
}
fn collect(cmd: &clap::Command, prefix: String, out: &mut Vec<String>) {
let subs: Vec<&clap::Command> = cmd.get_subcommands().collect();
if subs.is_empty() {
if !prefix.is_empty() {
out.push(prefix);
}
return;
}
for sub in subs {
let name = sub.get_name();
let path = if prefix.is_empty() {
name.to_string()
} else {
format!("{prefix} {name}")
};
collect(sub, path, out);
}
}
/// The complete set of leaf commands, as of the JSON-contract migration. Every
/// one of these returns an `output::Response` that `commands::run` emits under
/// `--json`. When you add, remove, or rename a subcommand this test fails: add
/// the leaf here, and make sure its `run` returns a `Response` with the right
/// envelope (read → `Response::read`/`items`, mutation → `Response::ok`, delete
/// → `Response::deleted`, gate → `Response::gate`) — never a bare `Ok(())`.
const COVERED_LEAVES: &[&str] = &[
"agent approve",
"agent list",
"agent reject",
"agent session",
"agent sessions",
"agent trigger",
"agent view",
"auth login",
"auth logout",
"auth rotate",
"auth status",
"board create-column",
"board delete-column",
"board edit-column",
"board init",
"board list",
"branch list",
"ci cancel",
"ci delete-secret",
"ci job-view",
"ci list",
"ci run",
"ci secrets",
"ci set-secret",
"ci view",
"commit list",
"commit view",
"deploy create",
"deploy env create",
"deploy env list",
"deploy list",
"deploy status",
"epic add-child",
"epic auto-close",
"epic children",
"epic list",
"epic mark",
"epic remove-child",
"epic view",
"issue assign",
"issue close",
"issue comment",
"issue comments",
"issue create",
"issue create-milestone",
"issue edit",
"issue link",
"issue link-req",
"issue links",
"issue list",
"issue milestones",
"issue move",
"issue reopen",
"issue unassign",
"issue unlink",
"issue unlink-req",
"issue view",
"label add",
"label create",
"label delete",
"label edit",
"label list",
"label remove",
"milestone close",
"milestone create",
"milestone delete",
"milestone edit",
"milestone list",
"milestone reopen",
"milestone view",
"pr checkout",
"pr close",
"pr comment",
"pr create",
"pr diff",
"pr edit",
"pr list",
"pr merge",
"pr reopen",
"pr review",
"pr reviews",
"pr view",
"registry token create",
"registry token delete",
"registry token list",
"release assets",
"release changelog",
"release create",
"release delete",
"release delete-asset",
"release download",
"release list",
"release publish",
"release update",
"release upload",
"release view",
"repo clone",
"repo create",
"repo list",
"repo set-default",
"repo view",
"requirement applicability add",
"requirement applicability list",
"requirement applicability remove",
"requirement create",
"requirement delete",
"requirement export",
"requirement import",
"requirement link",
"requirement list",
"requirement matrix",
"requirement seed",
"requirement status",
"requirement unlink",
"requirement update",
"requirement view",
"runner configure",
"runner doctor",
"runner list",
"runner logs",
"runner remove",
"runner restart",
"runner service install",
"runner service list",
"runner service svc-restart",
"runner service svc-start",
"runner service svc-status",
"runner service svc-stop",
"runner service uninstall",
"runner start",
"runner status",
"runner stop",
"runner token",
"runner unconfigure",
"runner update",
"runner view",
"ssh-key add",
"ssh-key list",
"ssh-key remove",
"update",
];
#[test]
fn every_leaf_is_covered() {
let actual = leaf_paths();
let expected: Vec<String> = COVERED_LEAVES.iter().map(|s| s.to_string()).collect();
let missing: Vec<&String> = actual.iter().filter(|p| !expected.contains(p)).collect();
let stale: Vec<&String> = expected.iter().filter(|p| !actual.contains(p)).collect();
assert!(
missing.is_empty() && stale.is_empty(),
"leaf-command inventory drifted from the JSON contract.\n\
NEW leaves not yet covered (make run() return an output::Response, then add here): {missing:?}\n\
STALE entries no longer in the CLI (remove from COVERED_LEAVES): {stale:?}",
);
}
▸
tests/json_fuzz.rs
+258
−0
@@ -1,0 +1,258 @@
//! Adversarial contract fuzzer: the ONE invariant, checked against EVERY leaf.
//!
//! Under `--json`, stdout must be exactly one valid JSON value — the success
//! payload, or a `{"ok":false,"error":…}` envelope on failure — for every leaf
//! command, whatever the server does. This test walks the real clap tree,
//! synthesizes just-enough arguments for each leaf, and runs the built binary
//! with `--json` against a catch-all mock that answers every request with an
//! adversarial body (an object where several commands expect a bare array, and
//! vice-versa). It then asserts stdout parses as JSON.
//!
//! What this catches that the happy-path tests can't:
//! * a raw `println!`/`print!` that leaked human text onto stdout,
//! * a command that returns without emitting (empty stdout),
//! * a panic on an unexpected server shape (backtrace, partial stdout),
//! * a gate/exit path that forgets to emit its JSON first.
//!
//! Commands that hang (long-poll / exec), mutate the working tree (git), or
//! replace the binary are on an explicit denylist, logged so coverage is honest.
use clap::CommandFactory;
use serde_json::json;
use std::io::Write;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use wiremock::matchers::any;
use wiremock::{Mock, MockServer, ResponseTemplate};
/// Leaves we can't safely spawn in a loop: they block forever, mutate the
/// checkout, touch the real system, or replace the running binary. Each is
/// covered (or excluded by nature) elsewhere; listed here so the skip is loud.
const DENY: &[&str] = &[
// Long-poll / never-returning / re-exec:
"runner start",
"runner restart",
"auth login",
// Writes local runner config / deregisters / system services:
"runner configure",
"runner unconfigure",
"runner logs",
"runner service install",
"runner service uninstall",
"runner service svc-start",
"runner service svc-stop",
"runner service svc-restart",
"runner service svc-status",
"runner service list",
// Mutates the git working tree / clones:
"pr checkout",
"pr diff",
"repo clone",
// Writes files into the cwd / replaces the binary:
"requirement export",
"requirement seed",
"release download",
"update",
];
/// A catch-all body deliberately shaped to trip lazy assumptions: it is an
/// object, but also carries arrays under the common envelope keys, a plaintext
/// `token`, and an `id` — so a command that blindly indexes, unwraps, or expects
/// the opposite container still has to produce *valid JSON* (payload or error).
fn adversarial_body() -> serde_json::Value {
json!({
"id": "11111111-1111-1111-1111-111111111111",
"token": "anvil_secret_plaintext",
"name": "x",
"status": "running",
"data": [],
"items": [],
"requirements": [],
"standards": [],
"columns": [],
"deployments": [],
"environments": [],
"releases": [],
"runner": { "id": "x", "name": "x", "status": "online" },
"label": { "id": "x", "name": "x" },
"milestone": { "id": "x", "title": "x" },
"column": { "id": "x", "name": "x" },
"release": { "id": "x", "tag_name": "x" }
})
}
/// Collect every leaf as `(space-joined path, argv-after-the-path)`, where argv
/// is the minimal set of required args clap needs to parse the leaf.
fn leaves_with_args() -> Vec<(String, Vec<String>)> {
let root = anvil::commands::Cli::command();
let mut out = Vec::new();
walk(&root, Vec::new(), &mut out);
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
fn walk(cmd: &clap::Command, path: Vec<String>, out: &mut Vec<(String, Vec<String>)>) {
let subs: Vec<&clap::Command> = cmd.get_subcommands().collect();
if subs.is_empty() {
if !path.is_empty() {
out.push((path.join(" "), synth_args(cmd)));
}
return;
}
for sub in subs {
let mut p = path.clone();
p.push(sub.get_name().to_string());
walk(sub, p, out);
}
}
/// Synthesize the required arguments for a leaf: a value per required option and
/// positional. Enum args get their first legal variant; everything else a
/// generic token (wrong values are fine — an error still yields a JSON envelope).
fn synth_args(leaf: &clap::Command) -> Vec<String> {
let mut opts: Vec<String> = Vec::new();
let mut positionals: Vec<String> = Vec::new();
for arg in leaf.get_arguments() {
if !arg.is_required_set() {
continue;
}
// "1" parses as both an integer arg (<NUMBER>, <EPIC>) and a string; a
// wrong-but-parseable value is fine, since a runtime error still yields a
// JSON envelope. Enum args get their first legal variant instead.
let value = arg
.get_possible_values()
.first()
.map(|p| p.get_name().to_string())
.unwrap_or_else(|| "1".to_string());
if arg.is_positional() {
positionals.push(value);
} else if let Some(long) = arg.get_long() {
opts.push(format!("--{long}"));
if takes_value(arg) {
opts.push(value);
}
}
}
opts.extend(positionals);
opts
}
fn takes_value(arg: &clap::Arg) -> bool {
matches!(
arg.get_action(),
clap::ArgAction::Set | clap::ArgAction::Append
)
}
/// Run one leaf with `--json` against the mock, killing it after a deadline so a
/// missed hang surfaces as a failure instead of wedging the suite.
fn run_leaf(server_uri: &str, config_path: &str, path: &str, args: &[String]) -> LeafOutcome {
let mut full: Vec<String> = path.split(' ').map(String::from).collect();
full.push("--json".into());
full.extend(args.iter().cloned());
let mut child = Command::new(env!("CARGO_BIN_EXE_anvil"))
.args(&full)
.env("ANVIL_SERVER_URL", server_uri)
.env("ANVIL_TOKEN", "test-token")
.env("ANVIL_CONFIG", config_path)
.env_remove("ANVIL_RUNNER_TOKEN")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn anvil");
let deadline = Instant::now() + Duration::from_secs(20);
loop {
if let Some(_status) = child.try_wait().expect("try_wait") {
let out = child.wait_with_output().expect("wait_with_output");
return LeafOutcome::Exited {
stdout: out.stdout,
stderr: out.stderr,
};
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return LeafOutcome::TimedOut;
}
std::thread::sleep(Duration::from_millis(25));
}
}
enum LeafOutcome {
Exited { stdout: Vec<u8>, stderr: Vec<u8> },
TimedOut,
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn every_leaf_emits_valid_json_under_json() {
let server = MockServer::start().await;
Mock::given(any())
.respond_with(ResponseTemplate::new(200).set_body_json(adversarial_body()))
.mount(&server)
.await;
let uri = server.uri();
// Scratch config so repo resolution finds a default and never the dev's real
// config or the checkout's git remote.
let dir = std::env::temp_dir().join(format!("anvil-fuzz-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let config_path = dir.join("config.json");
let mut f = std::fs::File::create(&config_path).unwrap();
f.write_all(
serde_json::to_string_pretty(&json!({
"server_url": uri,
"token": "test-token",
"default_repo": "test-org/test-repo"
}))
.unwrap()
.as_bytes(),
)
.unwrap();
let config_path = config_path.to_str().unwrap().to_string();
let mut failures: Vec<String> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
let mut checked = 0usize;
for (path, args) in leaves_with_args() {
if DENY.contains(&path.as_str()) {
skipped.push(path);
continue;
}
checked += 1;
match run_leaf(&uri, &config_path, &path, &args) {
LeafOutcome::TimedOut => {
failures.push(format!(
"`{path}` [{}]: TIMED OUT (hang — fix or denylist)",
args.join(" ")
));
}
LeafOutcome::Exited { stdout, stderr } => {
if serde_json::from_slice::<serde_json::Value>(&stdout).is_err() {
failures.push(format!(
"`{path}` [{}]: stdout is not valid JSON\n stdout: {}\n stderr: {}",
args.join(" "),
String::from_utf8_lossy(&stdout).trim(),
String::from_utf8_lossy(&stderr).trim(),
));
}
}
}
}
let _ = std::fs::remove_dir_all(&dir);
eprintln!(
"fuzzer: checked {checked} leaves, skipped {} (denylist): {skipped:?}",
skipped.len()
);
assert!(
failures.is_empty(),
"{} leaf command(s) broke the --json contract:\n\n{}",
failures.len(),
failures.join("\n\n"),
);
}
▸
tests/json_output.rs
+139
−0
@@ -852,3 +852,142 @@
assert_eq!(stdout_json(&out)["deleted"], "area/ci");
}
// ── Regression tests for the JSON-contract migration (fangorn/anvil#32,37,38) ──
// Each pins a bug the audit found: the command must emit exactly one JSON value
// on stdout under --json, with the right envelope.
/// `deploy status` used to print a human header AND a raw pretty-JSON dump —
/// invalid JSON under --json and a mess in human mode. Now it echoes one object.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn deploy_status_emits_a_single_json_object() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/deployments/status"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"environment": "production",
"status": "running",
"ref": "abc123"
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["deploy", "status", "test-org/test-repo"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
// Parsing at all proves no header/raw-dump leaked onto stdout.
let v = stdout_json(&out);
assert_eq!(v["status"], "running");
assert_eq!(v["environment"], "production");
}
/// `requirement list --kind all` used to print TWO JSON documents (requirements,
/// then standards) plus a blank line — unparseable. Now it merges into one.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn requirement_list_kind_all_emits_one_document() {
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-1", "title": "R"}]
})))
.mount(&server)
.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-1", "title": "S"}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"list",
"--kind",
"all",
"--repo",
"test-org/test-repo",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out); // one document, or this panics
assert_eq!(v["requirements"][0]["requirement_id"], "REQ-1");
assert_eq!(v["standards"][0]["requirement_id"], "STD-1");
}
/// `registry token create` used to print the one-time plaintext secret via a raw
/// println!, corrupting --json. The secret must now be a field of one JSON value.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn registry_token_create_returns_secret_as_a_json_field() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/registry/tokens"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "11111111-1111-1111-1111-111111111111",
"token": "anvil_reg_plaintext_secret",
"scopes": ["pull:test-org/app"]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"registry",
"token",
"create",
"--name",
"ci",
"--read",
"--repo",
"test-org/app",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out); // parses → no raw secret line leaked before it
assert_eq!(v["ok"], true);
assert_eq!(v["token"]["token"], "anvil_reg_plaintext_secret");
}
/// `board list` on a feature-disabled repo used to print a human error and exit
/// 0 — masking failure from scripts. It must now exit nonzero with an error
/// envelope on stdout.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn board_list_feature_disabled_exits_nonzero_with_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/board/columns"))
.respond_with(
ResponseTemplate::new(404).set_body_string(r#"{"error":"Feature not enabled"}"#),
)
.mount(&server)
.await;
let out = run_json(&server.uri(), &["board", "list", "test-org/test-repo"]);
assert!(
!out.status.success(),
"feature-disabled must be a nonzero exit, not a masked success"
);
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
assert!(
v["error"].as_str().unwrap_or("").contains("Board feature"),
"got: {v}"
);
}