use std::fs;
use std::path::Path;
use std::thread;
use std::time::Duration;
use assert_cmd::Command;
use tempfile::tempdir;
use tiny_http::{Header, Response, Server, StatusCode};
fn write_file(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("mkdir");
}
fs::write(path, content).expect("write");
}
fn create_single_crate_workspace(root: &Path) {
write_file(
&root.join("Cargo.toml"),
r#"
[workspace]
members = ["demo"]
resolver = "2"
"#,
);
write_file(
&root.join("demo/Cargo.toml"),
r#"
[package]
name = "demo"
version = "0.1.0"
edition = "2021"
"#,
);
write_file(&root.join("demo/src/lib.rs"), "pub fn demo() {}\n");
}
fn create_workspace(root: &Path) {
write_file(
&root.join("Cargo.toml"),
r#"
[workspace]
members = ["core", "utils", "app"]
resolver = "2"
"#,
);
write_file(
&root.join("core/Cargo.toml"),
r#"
[package]
name = "core"
version = "0.1.0"
edition = "2021"
"#,
);
write_file(&root.join("core/src/lib.rs"), "pub fn core() {}\n");
write_file(
&root.join("utils/Cargo.toml"),
r#"
[package]
name = "utils"
version = "0.1.0"
edition = "2021"
[dependencies]
core = { path = "../core" }
"#,
);
write_file(&root.join("utils/src/lib.rs"), "pub fn utils() {}\n");
write_file(
&root.join("app/Cargo.toml"),
r#"
[package]
name = "app"
version = "0.1.0"
edition = "2021"
[dependencies]
core = { path = "../core" }
utils = { path = "../utils" }
"#,
);
write_file(&root.join("app/src/lib.rs"), "pub fn app() {}\n");
}
fn create_fake_cargo_proxy(bin_dir: &Path) {
#[cfg(windows)]
{
fs::write(
bin_dir.join("cargo.cmd"),
"@echo off\r\nif \"%1\"==\"publish\" (\r\n if not \"%SHIPPER_FAKE_PUBLISH_LOG%\"==\"\" echo %*>>\"%SHIPPER_FAKE_PUBLISH_LOG%\"\r\n if not \"%SHIPPER_FAKE_PUBLISH_STDERR%\"==\"\" echo %SHIPPER_FAKE_PUBLISH_STDERR% 1>&2\r\n if \"%SHIPPER_FAKE_PUBLISH_EXIT%\"==\"\" (exit /b 0) else (exit /b %SHIPPER_FAKE_PUBLISH_EXIT%)\r\n)\r\n\"%REAL_CARGO%\" %*\r\nexit /b %ERRORLEVEL%\r\n",
)
.expect("write fake cargo");
}
#[cfg(not(windows))]
{
use std::os::unix::fs::PermissionsExt;
let path = bin_dir.join("cargo");
fs::write(
&path,
"#!/usr/bin/env sh\nif [ \"$1\" = \"publish\" ]; then\n if [ -n \"$SHIPPER_FAKE_PUBLISH_LOG\" ]; then\n printf '%s\\n' \"$*\" >> \"$SHIPPER_FAKE_PUBLISH_LOG\"\n fi\n if [ -n \"$SHIPPER_FAKE_PUBLISH_STDERR\" ]; then\n printf '%s\\n' \"$SHIPPER_FAKE_PUBLISH_STDERR\" >&2\n fi\n exit \"${SHIPPER_FAKE_PUBLISH_EXIT:-0}\"\nfi\n\"$REAL_CARGO\" \"$@\"\n",
)
.expect("write fake cargo");
let mut perms = fs::metadata(&path).expect("meta").permissions();
perms.set_mode(0o755);
fs::set_permissions(&path, perms).expect("chmod");
}
}
fn path_sep() -> &'static str {
if cfg!(windows) { ";" } else { ":" }
}
fn fake_cargo_bin_path(bin_dir: &Path) -> String {
#[cfg(windows)]
{
bin_dir.join("cargo.cmd").display().to_string()
}
#[cfg(not(windows))]
{
bin_dir.join("cargo").display().to_string()
}
}
struct TestRegistry {
base_url: String,
handle: thread::JoinHandle<()>,
}
impl TestRegistry {
fn join(self) {
self.handle.join().expect("join server");
}
}
fn spawn_registry(statuses: Vec<u16>, expected_requests: usize) -> TestRegistry {
let server = Server::http("127.0.0.1:0").expect("server");
let base_url = format!("http://{}", server.server_addr());
let handle = thread::spawn(move || {
for idx in 0..expected_requests {
let req = match server.recv_timeout(Duration::from_secs(30)) {
Ok(Some(r)) => r,
_ => break,
};
let status = statuses
.get(idx)
.copied()
.or_else(|| statuses.last().copied())
.unwrap_or(404);
let resp = Response::from_string("{}")
.with_status_code(StatusCode(status))
.with_header(
Header::from_bytes("Content-Type", "application/json").expect("header"),
);
req.respond(resp).expect("respond");
}
});
TestRegistry { base_url, handle }
}
fn shipper_cmd() -> Command {
Command::new(assert_cmd::cargo::cargo_bin!("shipper-cli"))
}
fn fake_cargo_env(bin_dir: &Path) -> (String, String, String) {
let old_path = std::env::var("PATH").unwrap_or_default();
let mut new_path = bin_dir.display().to_string();
if !old_path.is_empty() {
new_path.push_str(path_sep());
new_path.push_str(&old_path);
}
let real_cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
let fake_cargo = fake_cargo_bin_path(bin_dir);
(new_path, real_cargo, fake_cargo)
}
fn setup_fake_cargo(td: &Path) -> (String, String, String) {
let bin_dir = td.join("fake-bin");
fs::create_dir_all(&bin_dir).expect("mkdir");
create_fake_cargo_proxy(&bin_dir);
fake_cargo_env(&bin_dir)
}
fn read_publish_log(path: &Path) -> Vec<String> {
if !path.exists() {
return Vec::new();
}
fs::read_to_string(path)
.expect("read publish log")
.lines()
.map(str::to_owned)
.collect()
}
fn command_package_state<'a>(packages: &'a [serde_json::Value], name: &str) -> &'a str {
packages
.iter()
.find(|package| package["name"].as_str() == Some(name))
.unwrap_or_else(|| panic!("expected command package report for {name}"))["state"]
.as_str()
.unwrap_or_else(|| panic!("expected command package state for {name}"))
}
fn receipt_package_state<'a>(packages: &'a [serde_json::Value], name: &str) -> &'a str {
packages
.iter()
.find(|package| package["name"].as_str() == Some(name))
.unwrap_or_else(|| panic!("expected receipt package for {name}"))["state"]["state"]
.as_str()
.unwrap_or_else(|| panic!("expected receipt package state for {name}"))
}
#[test]
fn single_crate_publish_creates_state_and_receipt() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let (new_path, real_cargo, fake_cargo) = setup_fake_cargo(td.path());
let registry = spawn_registry(vec![404, 200], 2);
let state_dir = td.path().join(".shipper");
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("--allow-dirty")
.arg("--verify-timeout")
.arg("0ms")
.arg("--verify-poll")
.arg("0ms")
.arg("--max-attempts")
.arg("1")
.arg("--state-dir")
.arg(&state_dir)
.arg("publish")
.env("PATH", &new_path)
.env("REAL_CARGO", &real_cargo)
.env("SHIPPER_CARGO_BIN", &fake_cargo)
.env("SHIPPER_FAKE_PUBLISH_EXIT", "0")
.assert()
.success();
let state_path = state_dir.join("state.json");
assert!(state_path.exists(), "state.json should exist");
let state_json: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&state_path).expect("read state"))
.expect("parse state");
assert!(
state_json.get("plan_id").is_some(),
"state should have plan_id"
);
let receipt_path = state_dir.join("receipt.json");
assert!(receipt_path.exists(), "receipt.json should exist");
let receipt: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&receipt_path).expect("read receipt"))
.expect("parse receipt");
assert!(
receipt.get("plan_id").is_some(),
"receipt should have plan_id"
);
let packages = receipt["packages"].as_array().expect("packages array");
assert_eq!(packages.len(), 1);
assert_eq!(packages[0]["name"].as_str(), Some("demo"));
assert_eq!(
packages[0]["state"]["state"].as_str(),
Some("published"),
"package should be marked published"
);
assert!(
receipt.get("started_at").is_some(),
"receipt should have started_at"
);
assert!(
receipt.get("finished_at").is_some(),
"receipt should have finished_at"
);
registry.join();
}
#[test]
fn publish_json_format_writes_command_envelope_to_stdout() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let (new_path, real_cargo, fake_cargo) = setup_fake_cargo(td.path());
let registry = spawn_registry(vec![404, 200], 2);
let state_dir = td.path().join(".shipper");
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("--allow-dirty")
.arg("--verify-timeout")
.arg("0ms")
.arg("--verify-poll")
.arg("0ms")
.arg("--max-attempts")
.arg("1")
.arg("--state-dir")
.arg(&state_dir)
.arg("--format")
.arg("json")
.arg("publish")
.env("PATH", &new_path)
.env("REAL_CARGO", &real_cargo)
.env("SHIPPER_CARGO_BIN", &fake_cargo)
.env("SHIPPER_FAKE_PUBLISH_EXIT", "0")
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).expect("utf8");
let report: serde_json::Value =
serde_json::from_str(&stdout).expect("publish stdout should be command JSON");
assert_eq!(
report["schema_version"].as_str(),
Some("shipper.publish.v1"),
"publish JSON should carry a command-owned schema version"
);
assert_eq!(report["command"].as_str(), Some("publish"));
assert_eq!(report["registry"].as_str(), Some("crates-io"));
assert!(report["plan_id"].is_string(), "plan_id should be present");
assert_eq!(report["published"].as_u64(), Some(1));
assert_eq!(report["pending"].as_u64(), Some(0));
assert_eq!(report["failed"].as_u64(), Some(0));
assert_eq!(report["ambiguous"].as_u64(), Some(0));
assert_eq!(report["uploaded"].as_u64(), Some(0));
assert_eq!(report["skipped"].as_u64(), Some(0));
assert_eq!(
report["packages"][0]["name"].as_str(),
Some("demo"),
"command envelope should contain the published package"
);
assert_eq!(report["packages"][0]["state"].as_str(), Some("published"));
assert_eq!(report["packages"][0]["attempts"].as_u64(), Some(1));
assert_eq!(report["packages"][0]["reconciled"].as_bool(), Some(false));
assert_eq!(
report["artifacts"]["state"]["exists"].as_bool(),
Some(true),
"state artifact should exist"
);
assert_eq!(
report["artifacts"]["events"]["exists"].as_bool(),
Some(true),
"events artifact should exist"
);
assert_eq!(
report["artifacts"]["receipt"]["exists"].as_bool(),
Some(true),
"receipt artifact should exist"
);
assert_eq!(
report["artifacts"]["reconciliation"]["exists"].as_bool(),
Some(false),
"reconciliation artifact should be absent when no ambiguity occurred"
);
assert_eq!(
report["receipt"]["receipt_version"].as_str(),
Some("shipper.receipt.v2"),
"receipt remains nested as its own evidence contract"
);
assert_eq!(
report["receipt"]["packages"][0]["state"]["state"].as_str(),
Some("published")
);
assert!(
state_dir.join("receipt.json").exists(),
"receipt artifact should still be written"
);
registry.join();
}
#[test]
fn multi_crate_publish_respects_dependency_order() {
let td = tempdir().expect("tempdir");
create_workspace(td.path());
let (new_path, real_cargo, fake_cargo) = setup_fake_cargo(td.path());
let registry = spawn_registry(vec![404, 200, 404, 200, 404, 200], 6);
let state_dir = td.path().join(".shipper");
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("--allow-dirty")
.arg("--verify-timeout")
.arg("0ms")
.arg("--verify-poll")
.arg("0ms")
.arg("--max-attempts")
.arg("1")
.arg("--state-dir")
.arg(&state_dir)
.arg("publish")
.env("PATH", &new_path)
.env("REAL_CARGO", &real_cargo)
.env("SHIPPER_CARGO_BIN", &fake_cargo)
.env("SHIPPER_FAKE_PUBLISH_EXIT", "0")
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).expect("utf8");
let receipt: serde_json::Value = serde_json::from_str(
&fs::read_to_string(state_dir.join("receipt.json")).expect("read receipt"),
)
.expect("parse");
let packages = receipt["packages"].as_array().expect("packages");
assert_eq!(packages.len(), 3, "all 3 packages should be in receipt");
let published_count = packages
.iter()
.filter(|p| p["state"]["state"].as_str() == Some("published"))
.count();
assert_eq!(published_count, 3, "all 3 packages should be published");
let core_pos = stdout.find("core@0.1.0").expect("core in output");
let utils_pos = stdout.find("utils@0.1.0").expect("utils in output");
let app_pos = stdout.find("app@0.1.0").expect("app in output");
assert!(
core_pos < utils_pos,
"core should be published before utils"
);
assert!(utils_pos < app_pos, "utils should be published before app");
registry.join();
}
#[test]
fn plan_does_not_write_state() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("plan")
.assert()
.success();
assert!(
!td.path().join(".shipper").exists(),
"plan should not create .shipper state directory"
);
}
#[test]
fn publish_with_package_flag_limits_scope() {
let td = tempdir().expect("tempdir");
create_workspace(td.path());
let (new_path, real_cargo, fake_cargo) = setup_fake_cargo(td.path());
let registry = spawn_registry(vec![404, 200], 2);
let state_dir = td.path().join(".shipper");
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("--allow-dirty")
.arg("--verify-timeout")
.arg("0ms")
.arg("--verify-poll")
.arg("0ms")
.arg("--max-attempts")
.arg("1")
.arg("--package")
.arg("core")
.arg("--state-dir")
.arg(&state_dir)
.arg("publish")
.env("PATH", &new_path)
.env("REAL_CARGO", &real_cargo)
.env("SHIPPER_CARGO_BIN", &fake_cargo)
.env("SHIPPER_FAKE_PUBLISH_EXIT", "0")
.assert()
.success();
let receipt: serde_json::Value = serde_json::from_str(
&fs::read_to_string(state_dir.join("receipt.json")).expect("read receipt"),
)
.expect("parse");
let packages = receipt["packages"].as_array().expect("packages");
assert_eq!(packages.len(), 1, "only one package should be published");
assert_eq!(packages[0]["name"].as_str(), Some("core"));
assert_eq!(packages[0]["state"]["state"].as_str(), Some("published"));
registry.join();
}
#[test]
fn publish_creates_events_jsonl_with_lifecycle_events() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let (new_path, real_cargo, fake_cargo) = setup_fake_cargo(td.path());
let registry = spawn_registry(vec![404, 200], 2);
let state_dir = td.path().join(".shipper");
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("--allow-dirty")
.arg("--verify-timeout")
.arg("0ms")
.arg("--verify-poll")
.arg("0ms")
.arg("--max-attempts")
.arg("1")
.arg("--state-dir")
.arg(&state_dir)
.arg("publish")
.env("PATH", &new_path)
.env("REAL_CARGO", &real_cargo)
.env("SHIPPER_CARGO_BIN", &fake_cargo)
.env("SHIPPER_FAKE_PUBLISH_EXIT", "0")
.assert()
.success();
let events_path = state_dir.join("events.jsonl");
assert!(events_path.exists(), "events.jsonl should be created");
let events_content = fs::read_to_string(&events_path).expect("read events");
assert!(
!events_content.is_empty(),
"events.jsonl should not be empty"
);
assert!(
events_content.contains(r#""type":"plan_created"#),
"should contain plan_created event"
);
assert!(
events_content.contains(r#""type":"execution_started"#),
"should contain execution_started event"
);
assert!(
events_content.contains(r#""type":"package_started"#),
"should contain package_started event"
);
assert!(
events_content.contains(r#""type":"package_published"#),
"should contain package_published event"
);
assert!(
events_content.contains(r#""type":"execution_finished"#),
"should contain execution_finished event"
);
for line in events_content.lines() {
let _: serde_json::Value =
serde_json::from_str(line).expect("each events.jsonl line should be valid JSON");
}
registry.join();
}
#[test]
fn publish_with_custom_state_dir_writes_to_correct_location() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let (new_path, real_cargo, fake_cargo) = setup_fake_cargo(td.path());
let registry = spawn_registry(vec![404, 200], 2);
let custom_dir = td.path().join("my-artifacts").join("nested");
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("--allow-dirty")
.arg("--verify-timeout")
.arg("0ms")
.arg("--verify-poll")
.arg("0ms")
.arg("--max-attempts")
.arg("1")
.arg("--state-dir")
.arg(&custom_dir)
.arg("publish")
.env("PATH", &new_path)
.env("REAL_CARGO", &real_cargo)
.env("SHIPPER_CARGO_BIN", &fake_cargo)
.env("SHIPPER_FAKE_PUBLISH_EXIT", "0")
.assert()
.success();
assert!(custom_dir.exists(), "custom state dir should be created");
assert!(
custom_dir.join("state.json").exists(),
"state.json should be in custom dir"
);
assert!(
custom_dir.join("receipt.json").exists(),
"receipt.json should be in custom dir"
);
assert!(
custom_dir.join("events.jsonl").exists(),
"events.jsonl should be in custom dir"
);
assert!(
!td.path().join(".shipper").exists(),
"default .shipper dir should not be created when custom --state-dir is used"
);
let receipt: serde_json::Value = serde_json::from_str(
&fs::read_to_string(custom_dir.join("receipt.json")).expect("read receipt"),
)
.expect("parse receipt");
assert!(receipt.get("plan_id").is_some());
registry.join();
}
#[test]
fn failed_publish_creates_state_for_resume() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let (new_path, real_cargo, fake_cargo) = setup_fake_cargo(td.path());
let registry = spawn_registry(vec![404, 404], 2);
let state_dir = td.path().join(".shipper");
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("--allow-dirty")
.arg("--verify-timeout")
.arg("0ms")
.arg("--verify-poll")
.arg("0ms")
.arg("--max-attempts")
.arg("1")
.arg("--base-delay")
.arg("0ms")
.arg("--state-dir")
.arg(&state_dir)
.arg("publish")
.env("PATH", &new_path)
.env("REAL_CARGO", &real_cargo)
.env("SHIPPER_CARGO_BIN", &fake_cargo)
.env("SHIPPER_FAKE_PUBLISH_EXIT", "1")
.env(
"SHIPPER_FAKE_PUBLISH_STDERR",
"error: not authorized to publish this crate",
)
.assert()
.failure();
let state_path = state_dir.join("state.json");
assert!(
state_path.exists(),
"state.json should exist after failed publish (for resume)"
);
let state: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&state_path).expect("read state"))
.expect("parse state");
assert!(state.get("plan_id").is_some(), "state should have plan_id");
assert!(
state.get("packages").is_some(),
"state should have packages"
);
let packages = state["packages"].as_object().expect("packages map");
let demo = packages.get("demo@0.1.0").expect("demo in packages");
let demo_state = demo["state"]["state"].as_str().expect("state string");
assert_ne!(
demo_state, "published",
"failed package should NOT be marked published"
);
registry.join();
}
#[test]
fn publish_when_already_published_skips_all() {
let td = tempdir().expect("tempdir");
create_workspace(td.path());
let (new_path, real_cargo, fake_cargo) = setup_fake_cargo(td.path());
let registry = spawn_registry(vec![200, 200, 200], 3);
let state_dir = td.path().join(".shipper");
let publish_log = td.path().join("publish.log");
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("--allow-dirty")
.arg("--verify-timeout")
.arg("0ms")
.arg("--verify-poll")
.arg("0ms")
.arg("--max-attempts")
.arg("1")
.arg("--state-dir")
.arg(&state_dir)
.arg("--format")
.arg("json")
.arg("publish")
.env("PATH", &new_path)
.env("REAL_CARGO", &real_cargo)
.env("SHIPPER_CARGO_BIN", &fake_cargo)
.env("SHIPPER_FAKE_PUBLISH_EXIT", "0")
.env("SHIPPER_FAKE_PUBLISH_LOG", &publish_log)
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).expect("utf8");
let report: serde_json::Value =
serde_json::from_str(&stdout).expect("publish stdout should be command JSON");
let report_packages = report["packages"].as_array().expect("report packages");
assert_eq!(report_packages.len(), 3, "all packages should be reported");
assert_eq!(report["published"].as_u64(), Some(0));
assert_eq!(report["pending"].as_u64(), Some(0));
assert_eq!(report["failed"].as_u64(), Some(0));
assert_eq!(report["ambiguous"].as_u64(), Some(0));
assert_eq!(report["uploaded"].as_u64(), Some(0));
assert_eq!(report["skipped"].as_u64(), Some(3));
for package in ["core", "utils", "app"] {
assert_eq!(
command_package_state(report_packages, package),
"skipped",
"{package} should be skipped in publish JSON"
);
}
assert!(
read_publish_log(&publish_log).is_empty(),
"cargo publish must not run when every version already exists"
);
let receipt: serde_json::Value = serde_json::from_str(
&fs::read_to_string(state_dir.join("receipt.json")).expect("read receipt"),
)
.expect("parse");
let packages = receipt["packages"].as_array().expect("packages");
assert_eq!(packages.len(), 3, "all packages should be in receipt");
for package in ["core", "utils", "app"] {
assert_eq!(
receipt_package_state(packages, package),
"skipped",
"{package} should be skipped in receipt"
);
}
registry.join();
}
#[test]
fn publish_mixed_existing_and_missing_publishes_missing_only() {
let td = tempdir().expect("tempdir");
create_workspace(td.path());
let (new_path, real_cargo, fake_cargo) = setup_fake_cargo(td.path());
let registry = spawn_registry(vec![200, 404, 200, 404, 200], 5);
let state_dir = td.path().join(".shipper");
let publish_log = td.path().join("publish.log");
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("--allow-dirty")
.arg("--verify-timeout")
.arg("0ms")
.arg("--verify-poll")
.arg("0ms")
.arg("--max-attempts")
.arg("1")
.arg("--state-dir")
.arg(&state_dir)
.arg("--format")
.arg("json")
.arg("publish")
.env("PATH", &new_path)
.env("REAL_CARGO", &real_cargo)
.env("SHIPPER_CARGO_BIN", &fake_cargo)
.env("SHIPPER_FAKE_PUBLISH_EXIT", "0")
.env("SHIPPER_FAKE_PUBLISH_LOG", &publish_log)
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).expect("utf8");
let report: serde_json::Value =
serde_json::from_str(&stdout).expect("publish stdout should be command JSON");
let report_packages = report["packages"].as_array().expect("report packages");
assert_eq!(command_package_state(report_packages, "core"), "skipped");
assert_eq!(command_package_state(report_packages, "utils"), "published");
assert_eq!(command_package_state(report_packages, "app"), "published");
assert_eq!(report["published"].as_u64(), Some(2));
assert_eq!(report["pending"].as_u64(), Some(0));
assert_eq!(report["failed"].as_u64(), Some(0));
assert_eq!(report["ambiguous"].as_u64(), Some(0));
assert_eq!(report["uploaded"].as_u64(), Some(0));
assert_eq!(report["skipped"].as_u64(), Some(1));
let publish_log = read_publish_log(&publish_log);
assert_eq!(
publish_log.len(),
2,
"only missing package versions should invoke cargo publish"
);
assert!(
publish_log[0].contains("-p utils"),
"utils should publish after skipped core, log: {publish_log:?}"
);
assert!(
publish_log[1].contains("-p app"),
"app should publish after utils, log: {publish_log:?}"
);
assert!(
publish_log.iter().all(|line| !line.contains("-p core")),
"already-published core must not invoke cargo publish, log: {publish_log:?}"
);
let receipt: serde_json::Value = serde_json::from_str(
&fs::read_to_string(state_dir.join("receipt.json")).expect("read receipt"),
)
.expect("parse receipt");
let packages = receipt["packages"].as_array().expect("receipt packages");
assert_eq!(receipt_package_state(packages, "core"), "skipped");
assert_eq!(receipt_package_state(packages, "utils"), "published");
assert_eq!(receipt_package_state(packages, "app"), "published");
registry.join();
}
#[test]
fn publish_mixed_existing_and_missing_failure_records_failed_package() {
let td = tempdir().expect("tempdir");
create_workspace(td.path());
let (new_path, real_cargo, fake_cargo) = setup_fake_cargo(td.path());
let registry = spawn_registry(vec![200, 404, 404], 3);
let state_dir = td.path().join(".shipper");
let publish_log = td.path().join("publish.log");
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("--allow-dirty")
.arg("--verify-timeout")
.arg("0ms")
.arg("--verify-poll")
.arg("0ms")
.arg("--max-attempts")
.arg("1")
.arg("--base-delay")
.arg("0ms")
.arg("--state-dir")
.arg(&state_dir)
.arg("publish")
.env("PATH", &new_path)
.env("REAL_CARGO", &real_cargo)
.env("SHIPPER_CARGO_BIN", &fake_cargo)
.env("SHIPPER_FAKE_PUBLISH_EXIT", "1")
.env(
"SHIPPER_FAKE_PUBLISH_STDERR",
"error: not authorized to publish this crate",
)
.env("SHIPPER_FAKE_PUBLISH_LOG", &publish_log)
.assert()
.failure();
let publish_log = read_publish_log(&publish_log);
assert_eq!(
publish_log.len(),
1,
"failure should stop after first missing package publish attempt"
);
assert!(
publish_log[0].contains("-p utils"),
"utils should be the failed publish attempt, log: {publish_log:?}"
);
assert!(
!publish_log[0].contains("-p core") && !publish_log[0].contains("-p app"),
"already-published and downstream packages must not be published, log: {publish_log:?}"
);
let state: serde_json::Value = serde_json::from_str(
&fs::read_to_string(state_dir.join("state.json")).expect("read state"),
)
.expect("parse state");
assert_eq!(
state["packages"]["core@0.1.0"]["state"]["state"].as_str(),
Some("skipped"),
"already-published core should be recorded as skipped"
);
assert_eq!(
state["packages"]["utils@0.1.0"]["state"]["state"].as_str(),
Some("failed"),
"failed missing package should be recorded as failed"
);
assert_eq!(
state["packages"]["app@0.1.0"]["state"]["state"].as_str(),
Some("pending"),
"downstream package should remain pending after upstream failure"
);
registry.join();
}