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),
)
})
}
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";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pr_create_single_nest() {
let server = MockServer::start().await;
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));
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());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pr_edit_single_nest() {
let server = MockServer::start().await;
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));
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() {
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));
}
#[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));
assert_eq!(v["merge"]["merge_sha"].as_str(), Some("abc123"));
assert!(v["merge"]["merge"].is_null());
}
#[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);
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));
assert!(!String::from_utf8_lossy(&out.stdout).contains("(none)"));
}
#[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"));
}
#[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");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn issue_create_single_nest() {
let server = MockServer::start().await;
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));
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());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn issue_close_single_nest() {
let server = MockServer::start().await;
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));
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));
}
#[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());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn issue_link_req_unwraps_wrapped_body() {
let server = MockServer::start().await;
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"));
}
#[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));
}
#[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());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn epic_view_includes_children_from_grouped_links() {
let server = MockServer::start().await;
mock_json(
&server,
"GET",
"/api/v1/o/r/issues/9",
200,
json!({ "number": 9, "title": "E", "state": "open", "kind": "epic" }),
)
.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", "view", "9", "--repo", "o/r"]);
let v = stdout_json(&out);
#5
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);
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;
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"));
}
#[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;
let out = run_json(&server.uri(), &["epic", "list", REPO]);
let v = stdout_json(&out);
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;
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));
assert_eq!(v["issue"]["number"].as_u64(), Some(9));
assert!(v["issue"]["issue"].is_null());
}