use std::fs;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::Duration;
use assert_cmd::Command;
use predicates::str::contains;
use serial_test::serial;
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 shipper_cmd() -> Command {
Command::new(assert_cmd::cargo::cargo_bin!("shipper-cli"))
}
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 spawn_doctor_registry(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 _ in 0..expected_requests {
let req = match server.recv_timeout(Duration::from_secs(30)) {
Ok(Some(r)) => r,
_ => break,
};
let resp = Response::from_string(r#"{"crate":{"id":"serde"}}"#)
.with_status_code(StatusCode(200))
.with_header(
Header::from_bytes("Content-Type", "application/json").expect("header"),
);
req.respond(resp).expect("respond");
}
});
TestRegistry { base_url, handle }
}
fn path_sep() -> &'static str {
if cfg!(windows) { ";" } else { ":" }
}
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 \"%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 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 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()
}
}
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);
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 find_executable_on_path(program: &str) -> Option<PathBuf> {
let path_var = std::env::var_os("PATH")?;
#[cfg(windows)]
let candidates = [
format!("{program}.exe"),
format!("{program}.cmd"),
format!("{program}.bat"),
program.to_string(),
];
#[cfg(not(windows))]
let candidates = [program.to_string()];
std::env::split_paths(&path_var)
.flat_map(|dir| candidates.iter().map(move |candidate| dir.join(candidate)))
.find(|candidate| candidate.is_file())
}
fn resolve_tool_path(env_var: &str, program: &str) -> PathBuf {
if let Some(configured) = std::env::var_os(env_var) {
let configured = PathBuf::from(configured);
if configured.is_file() {
return configured;
}
if let Some(resolved) = find_executable_on_path(&configured.to_string_lossy()) {
return resolved;
}
}
find_executable_on_path(program).unwrap_or_else(|| panic!("failed to resolve {program}"))
}
fn create_tool_proxy(bin_dir: &Path, tool: &str, env_var: &str) {
#[cfg(windows)]
{
fs::write(
bin_dir.join(format!("{tool}.cmd")),
format!("@echo off\r\n\"%{env_var}%\" %*\r\nexit /b %ERRORLEVEL%\r\n"),
)
.expect("write tool proxy");
}
#[cfg(not(windows))]
{
use std::os::unix::fs::PermissionsExt;
let path = bin_dir.join(tool);
fs::write(
&path,
format!("#!/usr/bin/env sh\n\"${{{env_var}}}\" \"$@\"\n"),
)
.expect("write tool proxy");
let mut perms = fs::metadata(&path).expect("meta").permissions();
perms.set_mode(0o755);
fs::set_permissions(&path, perms).expect("chmod");
}
}
fn create_failing_tool_proxy(bin_dir: &Path, tool: &str, message: &str) {
#[cfg(windows)]
{
fs::write(
bin_dir.join(format!("{tool}.cmd")),
format!("@echo off\r\necho {message} 1>&2\r\nexit /b 1\r\n"),
)
.expect("write failing tool proxy");
}
#[cfg(not(windows))]
{
use std::os::unix::fs::PermissionsExt;
let path = bin_dir.join(tool);
fs::write(
&path,
format!("#!/usr/bin/env sh\necho '{message}' >&2\nexit 1\n"),
)
.expect("write failing tool proxy");
let mut perms = fs::metadata(&path).expect("meta").permissions();
perms.set_mode(0o755);
fs::set_permissions(&path, perms).expect("chmod");
}
}
fn path_entry_has_cargo(path: &Path) -> bool {
#[cfg(windows)]
{
path.join("cargo.exe").exists()
|| path.join("cargo.cmd").exists()
|| path.join("cargo.bat").exists()
|| path.join("cargo.com").exists()
}
#[cfg(not(windows))]
{
path.join("cargo").exists()
}
}
fn setup_doctor_tool_path(td: &Path) -> (String, String, String, Option<String>) {
let bin_dir = td.join("doctor-bin");
fs::create_dir_all(&bin_dir).expect("mkdir");
create_failing_tool_proxy(&bin_dir, "cargo", "simulated missing cargo");
create_tool_proxy(&bin_dir, "rustc", "REAL_RUSTC");
let real_cargo = resolve_tool_path("CARGO", "cargo");
let real_rustc = resolve_tool_path("RUSTC", "rustc");
let real_git = find_executable_on_path("git");
if real_git.is_some() {
create_tool_proxy(&bin_dir, "git", "REAL_GIT");
}
let filtered_path = std::env::var_os("PATH")
.map(|path| {
std::env::split_paths(&path)
.filter(|entry| !path_entry_has_cargo(entry))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let mut tool_path = bin_dir.display().to_string();
if !filtered_path.is_empty() {
tool_path.push_str(path_sep());
tool_path.push_str(
&std::env::join_paths(filtered_path)
.expect("join PATH")
.to_string_lossy(),
);
}
(
tool_path,
real_cargo.display().to_string(),
real_rustc.display().to_string(),
real_git.map(|path| path.display().to_string()),
)
}
fn fast_args(cmd: &mut Command, manifest: &Path, api_base: &str, state_dir: &Path) {
cmd.arg("--manifest-path")
.arg(manifest)
.arg("--api-base")
.arg(api_base)
.arg("--allow-dirty")
.arg("--verify-timeout")
.arg("0ms")
.arg("--verify-poll")
.arg("0ms")
.arg("--no-readiness")
.arg("--max-attempts")
.arg("2")
.arg("--base-delay")
.arg("0ms")
.arg("--state-dir")
.arg(state_dir);
}
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_two_crate_workspace(root: &Path) {
write_file(
&root.join("Cargo.toml"),
r#"
[workspace]
members = ["core", "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("app/Cargo.toml"),
r#"
[package]
name = "app"
version = "0.1.0"
edition = "2021"
[dependencies]
core = { path = "../core" }
"#,
);
write_file(&root.join("app/src/lib.rs"), "pub fn app() {}\n");
}
fn create_independent_workspace(root: &Path) {
write_file(
&root.join("Cargo.toml"),
r#"
[workspace]
members = ["alpha", "beta", "gamma"]
resolver = "2"
"#,
);
for name in &["alpha", "beta", "gamma"] {
write_file(
&root.join(format!("{name}/Cargo.toml")),
&format!(
r#"
[package]
name = "{name}"
version = "0.1.0"
edition = "2021"
"#
),
);
write_file(
&root.join(format!("{name}/src/lib.rs")),
&format!("pub fn {name}() {{}}\n"),
);
}
}
fn create_parallel_workspace(root: &Path) {
write_file(
&root.join("Cargo.toml"),
r#"
[workspace]
members = ["core", "api", "cli", "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("api/Cargo.toml"),
r#"
[package]
name = "api"
version = "0.1.0"
edition = "2021"
[dependencies]
core = { path = "../core" }
"#,
);
write_file(&root.join("api/src/lib.rs"), "pub fn api() {}\n");
write_file(
&root.join("cli/Cargo.toml"),
r#"
[package]
name = "cli"
version = "0.1.0"
edition = "2021"
[dependencies]
core = { path = "../core" }
"#,
);
write_file(&root.join("cli/src/lib.rs"), "pub fn cli() {}\n");
write_file(
&root.join("app/Cargo.toml"),
r#"
[package]
name = "app"
version = "0.1.0"
edition = "2021"
[dependencies]
api = { path = "../api" }
cli = { path = "../cli" }
"#,
);
write_file(&root.join("app/src/lib.rs"), "pub fn app() {}\n");
}
fn create_multi_crate_workspace(root: &Path) {
write_file(
&root.join("Cargo.toml"),
r#"
[workspace]
members = ["core-lib", "utils-lib", "top-app"]
resolver = "2"
"#,
);
write_file(
&root.join("core-lib/Cargo.toml"),
r#"
[package]
name = "core-lib"
version = "0.1.0"
edition = "2021"
"#,
);
write_file(&root.join("core-lib/src/lib.rs"), "pub fn core() {}\n");
write_file(
&root.join("utils-lib/Cargo.toml"),
r#"
[package]
name = "utils-lib"
version = "0.1.0"
edition = "2021"
[dependencies]
core-lib = { path = "../core-lib" }
"#,
);
write_file(
&root.join("utils-lib/src/lib.rs"),
"pub fn utils() { core_lib::core(); }\n",
);
write_file(
&root.join("top-app/Cargo.toml"),
r#"
[package]
name = "top-app"
version = "0.1.0"
edition = "2021"
[dependencies]
core-lib = { path = "../core-lib" }
utils-lib = { path = "../utils-lib" }
"#,
);
write_file(
&root.join("top-app/src/lib.rs"),
"pub fn app() { utils_lib::utils(); }\n",
);
}
fn create_solo_workspace(root: &Path) {
write_file(
&root.join("Cargo.toml"),
r#"
[workspace]
members = ["solo"]
resolver = "2"
"#,
);
write_file(
&root.join("solo/Cargo.toml"),
r#"
[package]
name = "solo"
version = "0.3.0"
edition = "2021"
"#,
);
write_file(&root.join("solo/src/lib.rs"), "pub fn solo() {}\n");
}
mod resume_continues_after_interruption {
use super::*;
#[test]
#[serial]
fn given_interrupted_publish_when_resume_then_completes_remaining_crates() {
let td = tempdir().expect("tempdir");
create_two_crate_workspace(td.path());
let (new_path, real_cargo, fake_cargo) = setup_fake_cargo(td.path());
let state_dir = td.path().join(".shipper");
let registry = spawn_registry(vec![200, 404, 404, 404, 404, 200], 7);
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("--no-readiness")
.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")
.assert()
.failure();
let state: serde_json::Value = serde_json::from_str(
&fs::read_to_string(state_dir.join("state.json")).expect("read state"),
)
.expect("parse state");
let app_state = state["packages"]["app@0.1.0"]["state"]["state"]
.as_str()
.expect("app state");
assert_eq!(app_state, "failed", "app should be failed before resume");
let mut cmd = shipper_cmd();
fast_args(
&mut cmd,
&td.path().join("Cargo.toml"),
®istry.base_url,
&state_dir,
);
cmd.arg("resume")
.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 receipt");
let packages = receipt["packages"].as_array().expect("packages array");
let app_pkg = packages.iter().find(|p| p["name"].as_str() == Some("app"));
assert!(app_pkg.is_some(), "receipt should contain app");
assert_eq!(
app_pkg.unwrap()["state"]["state"].as_str(),
Some("published"),
"app should be published after resume"
);
registry.join();
}
}
mod resume_noop_when_complete {
use super::*;
#[test]
#[serial]
fn given_all_published_when_resume_then_noop() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let (new_path, real_cargo, fake_cargo) = setup_fake_cargo(td.path());
let state_dir = td.path().join(".shipper");
let registry = spawn_registry(vec![404, 200], 3);
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("--no-readiness")
.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: 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"]["demo@0.1.0"]["state"]["state"].as_str(),
Some("published")
);
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("--no-readiness")
.arg("--max-attempts")
.arg("1")
.arg("--state-dir")
.arg(&state_dir)
.arg("resume")
.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()
.stderr
.clone();
let stderr = String::from_utf8(output).expect("utf8");
assert!(
stderr.contains("already complete"),
"expected 'already complete' in stderr, got: {stderr}"
);
registry.join();
}
}
mod parallel_independent_skipped {
use super::*;
#[test]
#[serial]
fn given_independent_crates_when_parallel_publish_then_all_skipped() {
let td = tempdir().expect("tempdir");
create_independent_workspace(td.path());
let (new_path, real_cargo, fake_cargo) = setup_fake_cargo(td.path());
let state_dir = td.path().join(".shipper");
let registry = spawn_registry(vec![200, 200, 200], 3);
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("--allow-dirty")
.arg("--max-attempts")
.arg("1")
.arg("--state-dir")
.arg(&state_dir)
.arg("--max-concurrent")
.arg("2")
.arg("--parallel")
.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 receipt");
let packages = receipt["packages"].as_array().expect("packages array");
assert_eq!(packages.len(), 3, "receipt should have 3 packages");
for pkg in packages {
let pkg_state = pkg["state"]["state"].as_str().unwrap_or("unknown");
assert!(
pkg_state == "skipped" || pkg_state == "published",
"expected skipped or published, got: {pkg_state}"
);
}
registry.join();
}
}
mod parallel_respects_dependency_ordering {
use super::*;
#[test]
#[serial]
fn given_dependencies_when_parallel_publish_then_all_in_receipt() {
let td = tempdir().expect("tempdir");
create_parallel_workspace(td.path());
let (new_path, real_cargo, fake_cargo) = setup_fake_cargo(td.path());
let state_dir = td.path().join(".shipper");
let registry = spawn_registry(vec![200, 200, 200, 200], 4);
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("--allow-dirty")
.arg("--max-attempts")
.arg("1")
.arg("--state-dir")
.arg(&state_dir)
.arg("--max-concurrent")
.arg("1")
.arg("--parallel")
.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 receipt");
let packages = receipt["packages"].as_array().expect("packages array");
assert_eq!(packages.len(), 4, "receipt should have 4 packages");
let names: Vec<&str> = packages.iter().filter_map(|p| p["name"].as_str()).collect();
assert!(names.contains(&"core"), "receipt should contain core");
assert!(names.contains(&"api"), "receipt should contain api");
assert!(names.contains(&"cli"), "receipt should contain cli");
assert!(names.contains(&"app"), "receipt should contain app");
registry.join();
}
}
mod status_mixed_published_and_missing {
use super::*;
#[test]
fn given_mixed_versions_when_status_then_reports_each_correctly() {
let td = tempdir().expect("tempdir");
create_multi_crate_workspace(td.path());
let registry = spawn_registry(vec![200, 404, 404], 3);
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("status")
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).expect("utf8");
assert!(
stdout.contains("published"),
"expected at least one published crate in: {stdout}"
);
assert!(
stdout.contains("missing"),
"expected at least one missing crate in: {stdout}"
);
registry.join();
}
}
mod status_single_crate_shows_version {
use super::*;
#[test]
fn given_single_crate_when_status_then_shows_version() {
let td = tempdir().expect("tempdir");
create_solo_workspace(td.path());
let registry = spawn_registry(vec![404], 1);
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("status")
.assert()
.success()
.stdout(contains("solo@0.3.0"));
registry.join();
}
}
mod doctor_reports_header_and_workspace {
use super::*;
#[test]
fn given_valid_workspace_when_doctor_then_reports_header_and_root() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
fs::create_dir_all(td.path().join("cargo-home")).expect("mkdir");
let registry = spawn_doctor_registry(1);
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("doctor")
.env("CARGO_HOME", td.path().join("cargo-home"))
.env_remove("CARGO_REGISTRY_TOKEN")
.env_remove("CARGO_REGISTRIES_CRATES_IO_TOKEN")
.assert()
.success()
.stdout(contains("Shipper Doctor - Diagnostics Report"))
.stdout(contains("workspace_root:"));
registry.join();
}
}
mod doctor_warns_missing_token {
use super::*;
#[test]
fn given_no_token_when_doctor_then_warns_none_found() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let cargo_home = td.path().join("cargo-home");
fs::create_dir_all(&cargo_home).expect("mkdir");
let registry = spawn_doctor_registry(1);
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("doctor")
.env("CARGO_HOME", &cargo_home)
.env_remove("CARGO_REGISTRY_TOKEN")
.env_remove("CARGO_REGISTRIES_CRATES_IO_TOKEN")
.assert()
.success()
.stdout(contains("NONE FOUND"));
registry.join();
}
}
mod doctor_detects_cargo {
use super::*;
#[test]
fn given_cargo_installed_when_doctor_then_shows_version() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
fs::create_dir_all(td.path().join("cargo-home")).expect("mkdir");
let registry = spawn_doctor_registry(1);
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("doctor")
.env("CARGO_HOME", td.path().join("cargo-home"))
.env_remove("CARGO_REGISTRY_TOKEN")
.env_remove("CARGO_REGISTRIES_CRATES_IO_TOKEN")
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).expect("utf8");
assert!(
stdout.contains("cargo: cargo"),
"expected cargo version line, got: {stdout}"
);
registry.join();
}
}
mod doctor_reports_registry_reachability {
use super::*;
#[test]
fn given_reachable_registry_when_doctor_then_reports_reachable() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
fs::create_dir_all(td.path().join("cargo-home")).expect("mkdir");
let registry = spawn_doctor_registry(1);
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("doctor")
.env("CARGO_HOME", td.path().join("cargo-home"))
.env_remove("CARGO_REGISTRY_TOKEN")
.env_remove("CARGO_REGISTRIES_CRATES_IO_TOKEN")
.assert()
.success()
.stdout(contains("registry_reachable: true"));
registry.join();
}
}
mod config_validate_rejects_zero_max_attempts {
use super::*;
#[test]
fn given_zero_max_attempts_when_config_validate_then_error() {
let td = tempdir().expect("tempdir");
write_file(
&td.path().join(".shipper.toml"),
r#"
schema_version = "shipper.config.v1"
[retry]
max_attempts = 0
"#,
);
shipper_cmd()
.arg("config")
.arg("validate")
.arg("-p")
.arg(td.path().join(".shipper.toml"))
.assert()
.failure()
.stderr(contains("max_attempts"));
}
}
mod config_validate_rejects_invalid_jitter {
use super::*;
#[test]
fn given_invalid_jitter_when_config_validate_then_error() {
let td = tempdir().expect("tempdir");
write_file(
&td.path().join(".shipper.toml"),
r#"
schema_version = "shipper.config.v1"
[retry]
jitter = 1.5
"#,
);
shipper_cmd()
.arg("config")
.arg("validate")
.arg("-p")
.arg(td.path().join(".shipper.toml"))
.assert()
.failure()
.stderr(contains("jitter"));
}
}
mod doctor_reports_token_source_when_missing {
use super::*;
#[test]
fn given_no_token_no_credentials_when_doctor_then_reports_none_found() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let cargo_home = td.path().join("cargo-home");
fs::create_dir_all(&cargo_home).expect("mkdir");
let registry = spawn_doctor_registry(1);
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("doctor")
.env("CARGO_HOME", &cargo_home)
.env_remove("CARGO_REGISTRY_TOKEN")
.env_remove("CARGO_REGISTRIES_CRATES_IO_TOKEN")
.assert()
.success()
.stdout(contains("auth_type:"))
.stdout(contains("NONE FOUND"));
registry.join();
}
}
mod clean_removes_state_files {
use super::*;
#[test]
#[serial]
fn given_state_files_when_clean_then_removes_them() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let state_dir = td.path().join(".shipper");
fs::create_dir_all(&state_dir).expect("mkdir");
write_file(&state_dir.join("state.json"), r#"{"plan_id":"test"}"#);
write_file(&state_dir.join("events.jsonl"), "{}\n");
assert!(state_dir.join("state.json").exists());
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--state-dir")
.arg(&state_dir)
.arg("clean")
.assert()
.success()
.stdout(contains("Clean complete"));
assert!(
!state_dir.join("state.json").exists(),
"state.json should be removed after clean"
);
assert!(
!state_dir.join("events.jsonl").exists(),
"events.jsonl should be removed after clean"
);
}
}
mod clean_keep_receipt {
use super::*;
#[test]
#[serial]
fn given_receipt_when_clean_keep_receipt_then_preserves_it() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let state_dir = td.path().join(".shipper");
fs::create_dir_all(&state_dir).expect("mkdir");
write_file(&state_dir.join("state.json"), r#"{"plan_id":"test"}"#);
write_file(&state_dir.join("events.jsonl"), "{}\n");
write_file(&state_dir.join("receipt.json"), r#"{"packages":[]}"#);
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--state-dir")
.arg(&state_dir)
.arg("clean")
.arg("--keep-receipt")
.assert()
.success()
.stdout(contains("Clean complete"));
assert!(
state_dir.join("receipt.json").exists(),
"receipt.json should be preserved with --keep-receipt"
);
assert!(
!state_dir.join("state.json").exists(),
"state.json should be removed"
);
}
}
mod plan_with_package_filter {
use super::*;
#[test]
fn given_multi_crate_when_plan_with_package_then_shows_filtered() {
let td = tempdir().expect("tempdir");
create_multi_crate_workspace(td.path());
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--package")
.arg("top-app")
.arg("plan")
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).expect("utf8");
assert!(
stdout.contains("top-app@0.1.0"),
"expected top-app@0.1.0 in plan output, got: {stdout}"
);
assert!(
stdout.contains("Total packages to publish:"),
"expected total packages line in output, got: {stdout}"
);
}
}
mod preflight_checks_without_publishing {
use super::*;
#[test]
fn given_workspace_when_preflight_then_no_state_file() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let state_dir = td.path().join(".shipper");
let registry = spawn_registry(vec![200, 200, 200], 3);
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("--allow-dirty")
.arg("--state-dir")
.arg(&state_dir)
.arg("--skip-ownership-check")
.arg("--no-verify")
.arg("preflight")
.assert()
.success();
assert!(
!state_dir.join("state.json").exists(),
"preflight should not create state.json"
);
registry.join();
}
}
fn create_dev_dependency_workspace(root: &Path) {
write_file(
&root.join("Cargo.toml"),
r#"
[workspace]
members = ["lib-a", "lib-b"]
resolver = "2"
"#,
);
write_file(
&root.join("lib-a/Cargo.toml"),
r#"
[package]
name = "lib-a"
version = "0.1.0"
edition = "2021"
"#,
);
write_file(&root.join("lib-a/src/lib.rs"), "pub fn a() {}\n");
write_file(
&root.join("lib-b/Cargo.toml"),
r#"
[package]
name = "lib-b"
version = "0.1.0"
edition = "2021"
[dev-dependencies]
lib-a = { path = "../lib-a" }
"#,
);
write_file(
&root.join("lib-b/src/lib.rs"),
"pub fn b() {}\n#[cfg(test)] mod tests { use lib_a::a; #[test] fn it() { a(); } }\n",
);
}
mod publish_all_already_published_sequential {
use super::*;
#[test]
#[serial]
fn given_all_published_when_sequential_publish_then_all_skipped() {
let td = tempdir().expect("tempdir");
create_two_crate_workspace(td.path());
let (new_path, real_cargo, fake_cargo) = setup_fake_cargo(td.path());
let state_dir = td.path().join(".shipper");
let registry = spawn_registry(vec![200, 200], 2);
let mut cmd = shipper_cmd();
fast_args(
&mut cmd,
&td.path().join("Cargo.toml"),
®istry.base_url,
&state_dir,
);
cmd.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 receipt");
let packages = receipt["packages"].as_array().expect("packages array");
assert_eq!(packages.len(), 2, "receipt should have 2 packages");
for pkg in packages {
let pkg_state = pkg["state"]["state"].as_str().unwrap_or("unknown");
assert_eq!(
pkg_state, "skipped",
"expected skipped for {}, got: {pkg_state}",
pkg["name"]
);
}
registry.join();
}
}
mod clean_with_no_state_directory {
use super::*;
#[test]
fn given_no_state_dir_when_clean_then_reports_not_exist() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let state_dir = td.path().join(".shipper");
assert!(!state_dir.exists());
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--state-dir")
.arg(&state_dir)
.arg("clean")
.assert()
.success()
.stdout(contains("State directory does not exist"));
}
}
mod doctor_reports_unreachable_registry {
use super::*;
#[test]
fn given_unreachable_registry_when_doctor_then_reports_unreachable() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
fs::create_dir_all(td.path().join("cargo-home")).expect("mkdir");
let bad_url = "http://127.0.0.1:1";
let assert = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(bad_url)
.arg("doctor")
.env("CARGO_HOME", td.path().join("cargo-home"))
.env_remove("CARGO_REGISTRY_TOKEN")
.env_remove("CARGO_REGISTRIES_CRATES_IO_TOKEN")
.assert()
.success();
let stderr = String::from_utf8(assert.get_output().stderr.clone()).expect("utf8");
assert!(
stderr.contains("registry_reachable: false"),
"expected 'registry_reachable: false' in stderr, got: {stderr}"
);
}
}
mod plan_with_dev_dependencies_only {
use super::*;
#[test]
fn given_dev_deps_only_when_plan_then_both_crates_listed() {
let td = tempdir().expect("tempdir");
create_dev_dependency_workspace(td.path());
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("plan")
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).expect("utf8");
assert!(
stdout.contains("lib-a@0.1.0"),
"expected lib-a@0.1.0 in plan output, got: {stdout}"
);
assert!(
stdout.contains("lib-b@0.1.0"),
"expected lib-b@0.1.0 in plan output, got: {stdout}"
);
assert!(
stdout.contains("Total packages to publish:"),
"expected total packages line in output, got: {stdout}"
);
}
}
mod preflight_fails_on_non_git_directory {
use super::*;
#[test]
fn given_non_git_dir_when_preflight_without_allow_dirty_then_fails() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let registry = spawn_registry(vec![200, 200, 200], 3);
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("--skip-ownership-check")
.arg("--no-verify")
.arg("preflight")
.assert()
.failure();
registry.join();
}
}
mod resume_with_corrupted_state_file {
use super::*;
#[test]
#[serial]
fn given_corrupted_state_when_resume_then_fails_with_error() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let state_dir = td.path().join(".shipper");
fs::create_dir_all(&state_dir).expect("mkdir");
write_file(&state_dir.join("state.json"), "NOT VALID JSON {{{{");
let registry = spawn_registry(vec![200], 1);
let mut cmd = shipper_cmd();
fast_args(
&mut cmd,
&td.path().join("Cargo.toml"),
®istry.base_url,
&state_dir,
);
cmd.arg("resume").assert().failure();
registry.join();
}
}
mod status_all_published {
use super::*;
#[test]
fn given_all_published_when_status_then_no_missing() {
let td = tempdir().expect("tempdir");
create_two_crate_workspace(td.path());
let registry = spawn_registry(vec![200, 200], 2);
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("status")
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).expect("utf8");
assert!(
stdout.contains("published"),
"expected published in output, got: {stdout}"
);
assert!(
!stdout.contains("missing"),
"expected no missing in output, got: {stdout}"
);
registry.join();
}
}
mod bdd_preflight_dry_run_no_state {
use super::*;
#[test]
fn bdd_preflight_dry_run_writes_no_state_or_receipts() {
let td = tempdir().expect("tempdir");
create_multi_crate_workspace(td.path());
let state_dir = td.path().join(".shipper");
let registry = spawn_registry(vec![200, 200, 200, 200, 200, 200], 6);
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("--allow-dirty")
.arg("--skip-ownership-check")
.arg("--no-verify")
.arg("--state-dir")
.arg(&state_dir)
.arg("preflight")
.assert()
.success();
assert!(
!state_dir.join("state.json").exists(),
"preflight (dry-run) should not create state.json"
);
assert!(
!state_dir.join("receipt.json").exists(),
"preflight (dry-run) should not create receipt.json"
);
registry.join();
}
}
mod bdd_preflight_skip_ownership {
use super::*;
#[test]
fn bdd_preflight_with_skip_ownership_check_succeeds() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let registry = spawn_registry(vec![404, 404, 404], 3);
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("--allow-dirty")
.arg("--skip-ownership-check")
.arg("--no-verify")
.arg("preflight")
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).expect("utf8");
assert!(
stdout.contains("Preflight Report"),
"expected Preflight Report header, got: {stdout}"
);
assert!(
stdout.contains("Ownership verified: 0"),
"expected 'Ownership verified: 0' when ownership check is skipped, got: {stdout}"
);
registry.join();
}
}
mod bdd_resume_after_network_failure {
use super::*;
#[test]
#[serial]
fn bdd_resume_continues_from_last_published_after_failure() {
let td = tempdir().expect("tempdir");
create_multi_crate_workspace(td.path());
let (new_path, real_cargo, fake_cargo) = setup_fake_cargo(td.path());
let state_dir = td.path().join(".shipper");
let registry = spawn_registry(vec![200, 200, 404, 404, 404, 404, 200], 8);
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("--no-readiness")
.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")
.assert()
.failure();
assert!(
state_dir.join("state.json").exists(),
"state.json should exist after failed publish"
);
let mut cmd = shipper_cmd();
fast_args(
&mut cmd,
&td.path().join("Cargo.toml"),
®istry.base_url,
&state_dir,
);
cmd.arg("resume")
.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 receipt");
let packages = receipt["packages"].as_array().expect("packages array");
assert!(
!packages.is_empty(),
"receipt should have at least one package after resume"
);
for pkg in packages {
let state = pkg["state"]["state"].as_str().unwrap_or("unknown");
assert!(
state == "published" || state == "skipped",
"expected published or skipped for {}, got: {state}",
pkg["name"]
);
}
let top_app = packages
.iter()
.find(|p| p["name"].as_str() == Some("top-app"));
assert!(
top_app.is_some(),
"receipt should contain top-app after resume"
);
assert_eq!(
top_app.unwrap()["state"]["state"].as_str(),
Some("published"),
"top-app should be published after resume"
);
registry.join();
}
}
mod bdd_status_mixed_published_unpublished {
use super::*;
#[test]
fn bdd_status_shows_mixed_published_and_unpublished() {
let td = tempdir().expect("tempdir");
create_two_crate_workspace(td.path());
let registry = spawn_registry(vec![200, 404], 2);
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("status")
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).expect("utf8");
assert!(
stdout.contains("published"),
"expected at least one published crate, got: {stdout}"
);
assert!(
stdout.contains("missing"),
"expected at least one missing crate, got: {stdout}"
);
assert!(
stdout.contains("core"),
"expected 'core' in status output, got: {stdout}"
);
assert!(
stdout.contains("app"),
"expected 'app' in status output, got: {stdout}"
);
registry.join();
}
}
mod bdd_doctor_missing_cargo {
use super::*;
#[test]
fn bdd_doctor_warns_when_cargo_not_found() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let cargo_home = td.path().join("cargo-home");
fs::create_dir_all(&cargo_home).expect("mkdir");
let (tool_path, real_cargo, real_rustc, real_git) = setup_doctor_tool_path(td.path());
let registry = spawn_doctor_registry(1);
let mut cmd = shipper_cmd();
cmd.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("doctor")
.env("PATH", &tool_path)
.env("CARGO", &real_cargo)
.env("REAL_RUSTC", &real_rustc)
.env("CARGO_HOME", &cargo_home)
.env_remove("CARGO_REGISTRY_TOKEN")
.env_remove("CARGO_REGISTRIES_CRATES_IO_TOKEN");
if let Some(ref real_git) = real_git {
cmd.env("REAL_GIT", real_git);
}
let assert = cmd.assert().success();
let stderr = String::from_utf8(assert.get_output().stderr.clone()).expect("utf8");
assert!(
stderr.contains("unable to run cargo") || stderr.contains("cargo"),
"expected warning about cargo not found, got stderr: {stderr}"
);
registry.join();
}
}
mod bdd_publish_single_package {
use super::*;
#[test]
#[serial]
fn bdd_publish_single_package_filters_correctly() {
let td = tempdir().expect("tempdir");
create_multi_crate_workspace(td.path());
let (new_path, real_cargo, fake_cargo) = setup_fake_cargo(td.path());
let state_dir = td.path().join(".shipper");
let registry = spawn_registry(vec![200], 1);
let mut cmd = shipper_cmd();
fast_args(
&mut cmd,
&td.path().join("Cargo.toml"),
®istry.base_url,
&state_dir,
);
cmd.arg("--package")
.arg("core-lib")
.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 receipt");
let packages = receipt["packages"].as_array().expect("packages array");
assert_eq!(
packages.len(),
1,
"receipt should have exactly 1 package when --package filters"
);
assert_eq!(
packages[0]["name"].as_str(),
Some("core-lib"),
"the single package should be core-lib"
);
registry.join();
}
}
mod bdd_plan_manifest_path_subcrate {
use super::*;
#[test]
fn bdd_plan_with_manifest_path_scoped_correctly() {
let td = tempdir().expect("tempdir");
create_multi_crate_workspace(td.path());
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--package")
.arg("utils-lib")
.arg("plan")
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).expect("utf8");
assert!(
stdout.contains("utils-lib@0.1.0"),
"expected utils-lib@0.1.0 in plan output, got: {stdout}"
);
assert!(
!stdout.contains("top-app@0.1.0"),
"expected top-app to be excluded from filtered plan, got: {stdout}"
);
}
}
mod bdd_config_conflicting_settings {
use super::*;
#[test]
fn bdd_config_validation_catches_base_delay_exceeding_max_delay() {
let td = tempdir().expect("tempdir");
write_file(
&td.path().join(".shipper.toml"),
r#"
schema_version = "shipper.config.v1"
[retry]
base_delay = "30s"
max_delay = "5s"
"#,
);
shipper_cmd()
.arg("config")
.arg("validate")
.arg("-p")
.arg(td.path().join(".shipper.toml"))
.assert()
.failure()
.stderr(contains("max_delay"));
}
}
mod bdd_ci_github_actions_output {
use super::*;
#[test]
fn bdd_ci_github_actions_produces_valid_yaml_steps() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("ci")
.arg("github-actions")
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).expect("utf8");
assert!(
stdout.contains("- name:"),
"expected '- name:' YAML step marker, got: {stdout}"
);
assert!(
stdout.contains("uses:"),
"expected 'uses:' action reference, got: {stdout}"
);
assert!(
stdout.contains("shipper publish"),
"expected 'shipper publish' command reference, got: {stdout}"
);
assert!(
stdout.contains("CARGO_REGISTRY_TOKEN"),
"expected CARGO_REGISTRY_TOKEN env var reference, got: {stdout}"
);
assert!(
stdout.starts_with("# GitHub Actions"),
"expected output to start with '# GitHub Actions' comment, got: {stdout}"
);
}
}
mod bdd_clean_preserves_workspace {
use super::*;
#[test]
#[serial]
fn bdd_clean_removes_state_but_preserves_source_files() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let state_dir = td.path().join(".shipper");
fs::create_dir_all(&state_dir).expect("mkdir");
write_file(&state_dir.join("state.json"), r#"{"plan_id":"test"}"#);
write_file(&state_dir.join("events.jsonl"), "{}\n");
write_file(&state_dir.join("receipt.json"), r#"{"packages":[]}"#);
assert!(state_dir.join("state.json").exists());
assert!(state_dir.join("events.jsonl").exists());
assert!(state_dir.join("receipt.json").exists());
assert!(td.path().join("Cargo.toml").exists());
assert!(td.path().join("demo/Cargo.toml").exists());
assert!(td.path().join("demo/src/lib.rs").exists());
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--state-dir")
.arg(&state_dir)
.arg("clean")
.assert()
.success()
.stdout(contains("Clean complete"));
assert!(
!state_dir.join("state.json").exists(),
"state.json should be removed after clean"
);
assert!(
!state_dir.join("events.jsonl").exists(),
"events.jsonl should be removed after clean"
);
assert!(
!state_dir.join("receipt.json").exists(),
"receipt.json should be removed after clean"
);
assert!(
td.path().join("Cargo.toml").exists(),
"workspace Cargo.toml should be preserved after clean"
);
assert!(
td.path().join("demo/Cargo.toml").exists(),
"demo/Cargo.toml should be preserved after clean"
);
assert!(
td.path().join("demo/src/lib.rs").exists(),
"demo/src/lib.rs should be preserved after clean"
);
}
}
mod config_validate_rejects_missing_schema_version {
use super::*;
#[test]
fn given_garbage_content_when_config_validate_then_fails_with_parse_error() {
let td = tempdir().expect("tempdir");
write_file(
&td.path().join(".shipper.toml"),
"this is {{not}} valid TOML !!@#$",
);
shipper_cmd()
.arg("config")
.arg("validate")
.arg("-p")
.arg(td.path().join(".shipper.toml"))
.assert()
.failure();
}
}
mod config_validate_rejects_unknown_schema_version {
use super::*;
#[test]
fn given_unknown_schema_version_when_config_validate_then_fails() {
let td = tempdir().expect("tempdir");
write_file(
&td.path().join(".shipper.toml"),
r#"
schema_version = "unknown.version.v99"
"#,
);
shipper_cmd()
.arg("config")
.arg("validate")
.arg("-p")
.arg(td.path().join(".shipper.toml"))
.assert()
.failure();
}
}
mod config_validate_nonexistent_file {
use super::*;
#[test]
fn given_nonexistent_path_when_config_validate_then_fails_with_not_found() {
let td = tempdir().expect("tempdir");
let missing_path = td.path().join("does-not-exist.toml");
shipper_cmd()
.arg("config")
.arg("validate")
.arg("-p")
.arg(&missing_path)
.assert()
.failure()
.stderr(contains("not found"));
}
}
mod plan_multi_crate_correct_ordering {
use super::*;
#[test]
fn given_chain_deps_when_plan_then_core_before_utils_before_top() {
let td = tempdir().expect("tempdir");
create_multi_crate_workspace(td.path());
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("plan")
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).expect("utf8");
assert!(stdout.contains("core-lib@0.1.0"), "missing core-lib");
assert!(stdout.contains("utils-lib@0.1.0"), "missing utils-lib");
assert!(stdout.contains("top-app@0.1.0"), "missing top-app");
let pos_core = stdout.find("core-lib@0.1.0").expect("core-lib position");
let pos_utils = stdout.find("utils-lib@0.1.0").expect("utils-lib position");
let pos_top = stdout.find("top-app@0.1.0").expect("top-app position");
assert!(
pos_core < pos_utils,
"core-lib should appear before utils-lib in plan"
);
assert!(
pos_utils < pos_top,
"utils-lib should appear before top-app in plan"
);
}
}
mod plan_independent_crates_all_listed {
use super::*;
#[test]
fn given_independent_crates_when_plan_then_all_listed() {
let td = tempdir().expect("tempdir");
create_independent_workspace(td.path());
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("plan")
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).expect("utf8");
assert!(stdout.contains("alpha@0.1.0"), "missing alpha");
assert!(stdout.contains("beta@0.1.0"), "missing beta");
assert!(stdout.contains("gamma@0.1.0"), "missing gamma");
assert!(
stdout.contains("Total packages to publish: 3"),
"expected total 3 packages, got: {stdout}"
);
}
}
mod preflight_reports_git_check_failure {
use super::*;
#[test]
fn given_multi_crate_non_git_when_preflight_then_fails_with_git_error() {
let td = tempdir().expect("tempdir");
create_multi_crate_workspace(td.path());
let registry = spawn_registry(vec![200, 200, 200], 3);
let assert = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("--skip-ownership-check")
.arg("--no-verify")
.arg("preflight")
.assert()
.failure();
let stderr = String::from_utf8(assert.get_output().stderr.clone()).expect("utf8");
assert!(
stderr.to_lowercase().contains("git"),
"expected git-related error in stderr, got: {stderr}"
);
registry.join();
}
}
mod resume_with_no_state_file {
use super::*;
#[test]
#[serial]
fn given_empty_state_dir_when_resume_then_fails_with_missing_state() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let state_dir = td.path().join(".shipper");
fs::create_dir_all(&state_dir).expect("mkdir");
let registry = spawn_registry(vec![200], 1);
let mut cmd = shipper_cmd();
fast_args(
&mut cmd,
&td.path().join("Cargo.toml"),
®istry.base_url,
&state_dir,
);
cmd.arg("resume").assert().failure();
registry.join();
}
}
mod resume_with_nonexistent_state_dir {
use super::*;
#[test]
#[serial]
fn given_no_state_dir_when_resume_then_fails() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let state_dir = td.path().join("nonexistent-state");
let registry = spawn_registry(vec![200], 1);
let mut cmd = shipper_cmd();
fast_args(
&mut cmd,
&td.path().join("Cargo.toml"),
®istry.base_url,
&state_dir,
);
cmd.arg("resume").assert().failure();
registry.join();
}
}
mod doctor_reports_package_count {
use super::*;
#[test]
fn given_multi_crate_workspace_when_doctor_then_reports_workspace_info() {
let td = tempdir().expect("tempdir");
create_multi_crate_workspace(td.path());
fs::create_dir_all(td.path().join("cargo-home")).expect("mkdir");
let registry = spawn_doctor_registry(1);
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("doctor")
.env("CARGO_HOME", td.path().join("cargo-home"))
.env_remove("CARGO_REGISTRY_TOKEN")
.env_remove("CARGO_REGISTRIES_CRATES_IO_TOKEN")
.assert()
.success()
.stdout(contains("Shipper Doctor - Diagnostics Report"))
.stdout(contains("workspace_root:"));
registry.join();
}
}
mod doctor_with_token_env_var {
use super::*;
#[test]
fn given_token_set_when_doctor_then_reports_token_detected() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
fs::create_dir_all(td.path().join("cargo-home")).expect("mkdir");
let registry = spawn_doctor_registry(1);
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("doctor")
.env("CARGO_HOME", td.path().join("cargo-home"))
.env("CARGO_REGISTRY_TOKEN", "test-token-value")
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).expect("utf8");
assert!(
stdout.contains("token (detected)"),
"expected 'token (detected)' when token is set, got: {stdout}"
);
assert!(
!stdout.contains("NONE FOUND"),
"should not report NONE FOUND when token is set, got: {stdout}"
);
registry.join();
}
}
mod clean_only_state_files_not_lock {
use super::*;
#[test]
#[serial]
fn given_extra_files_in_state_dir_when_clean_then_only_state_files_removed() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let state_dir = td.path().join(".shipper");
fs::create_dir_all(&state_dir).expect("mkdir");
write_file(&state_dir.join("state.json"), r#"{"plan_id":"test"}"#);
write_file(&state_dir.join("events.jsonl"), "{}\n");
write_file(&state_dir.join("notes.txt"), "user notes\n");
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--state-dir")
.arg(&state_dir)
.arg("clean")
.assert()
.success()
.stdout(contains("Clean complete"));
assert!(
!state_dir.join("state.json").exists(),
"state.json should be removed"
);
assert!(
!state_dir.join("events.jsonl").exists(),
"events.jsonl should be removed"
);
assert!(
state_dir.join("notes.txt").exists(),
"notes.txt should be preserved — clean only removes known state files"
);
}
}
mod status_all_missing {
use super::*;
#[test]
fn given_all_unpublished_when_status_then_all_missing() {
let td = tempdir().expect("tempdir");
create_multi_crate_workspace(td.path());
let registry = spawn_registry(vec![404, 404, 404], 3);
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("status")
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).expect("utf8");
assert!(
stdout.contains("missing"),
"expected 'missing' in status output, got: {stdout}"
);
assert!(
!stdout.contains("published"),
"expected no 'published' when all crates are unpublished, got: {stdout}"
);
}
}
mod status_shows_plan_id {
use super::*;
#[test]
fn given_workspace_when_status_then_shows_plan_id() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let registry = spawn_registry(vec![404], 1);
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("status")
.assert()
.success()
.stdout(contains("plan_id:"));
registry.join();
}
}
mod parallel_plan_with_max_concurrent_flag {
use super::*;
#[test]
fn given_parallel_workspace_when_plan_with_max_concurrent_then_succeeds() {
let td = tempdir().expect("tempdir");
create_parallel_workspace(td.path());
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--parallel")
.arg("--max-concurrent")
.arg("3")
.arg("plan")
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).expect("utf8");
assert!(stdout.contains("core@0.1.0"), "missing core in plan");
assert!(stdout.contains("api@0.1.0"), "missing api in plan");
assert!(stdout.contains("cli@0.1.0"), "missing cli in plan");
assert!(stdout.contains("app@0.1.0"), "missing app in plan");
}
}
mod parallel_publish_with_config_file {
use super::*;
#[test]
#[serial]
fn given_parallel_config_when_publish_then_respects_settings() {
let td = tempdir().expect("tempdir");
create_independent_workspace(td.path());
write_file(
&td.path().join(".shipper.toml"),
r#"
schema_version = "shipper.config.v1"
[parallel]
max_concurrent = 1
"#,
);
let (new_path, real_cargo, fake_cargo) = setup_fake_cargo(td.path());
let state_dir = td.path().join(".shipper");
let registry = spawn_registry(vec![200, 200, 200], 3);
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--config")
.arg(td.path().join(".shipper.toml"))
.arg("--api-base")
.arg(®istry.base_url)
.arg("--allow-dirty")
.arg("--max-attempts")
.arg("1")
.arg("--state-dir")
.arg(&state_dir)
.arg("--parallel")
.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 receipt");
let packages = receipt["packages"].as_array().expect("packages array");
assert_eq!(packages.len(), 3, "receipt should have 3 packages");
registry.join();
}
}
mod inspect_events_without_events_file {
use super::*;
#[test]
fn given_no_events_file_when_inspect_events_then_shows_empty_log() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let state_dir = td.path().join(".shipper");
fs::create_dir_all(&state_dir).expect("mkdir");
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--state-dir")
.arg(&state_dir)
.arg("inspect-events")
.assert()
.success()
.stdout(contains("Event log:"));
}
}
mod inspect_receipt_without_receipt_file {
use super::*;
#[test]
fn given_no_receipt_file_when_inspect_receipt_then_fails() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let state_dir = td.path().join(".shipper");
fs::create_dir_all(&state_dir).expect("mkdir");
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--state-dir")
.arg(&state_dir)
.arg("inspect-receipt")
.assert()
.failure();
}
}
mod ci_gitlab_output {
use super::*;
#[test]
fn given_workspace_when_ci_gitlab_then_produces_valid_yaml() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("ci")
.arg("gitlab")
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).expect("utf8");
assert!(
stdout.contains("script:") || stdout.contains("stage:"),
"expected GitLab CI keywords, got: {stdout}"
);
assert!(
stdout.contains("shipper publish"),
"expected 'shipper publish' in GitLab CI template, got: {stdout}"
);
}
}
mod ci_circleci_output {
use super::*;
#[test]
fn given_workspace_when_ci_circleci_then_produces_valid_yaml() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("ci")
.arg("circleci")
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).expect("utf8");
assert!(
stdout.contains("shipper publish"),
"expected 'shipper publish' in CircleCI template, got: {stdout}"
);
}
}
mod config_init_creates_valid_file {
use super::*;
#[test]
fn given_empty_dir_when_config_init_then_file_validates() {
let td = tempdir().expect("tempdir");
let config_path = td.path().join("test-config.toml");
shipper_cmd()
.arg("config")
.arg("init")
.arg("-o")
.arg(&config_path)
.assert()
.success()
.stdout(contains("Created configuration file"));
assert!(config_path.exists(), "config file should be created");
shipper_cmd()
.arg("config")
.arg("validate")
.arg("-p")
.arg(&config_path)
.assert()
.success()
.stdout(contains("valid"));
let content = fs::read_to_string(&config_path).expect("read config");
assert!(
content.contains("schema_version"),
"generated config should contain schema_version, got: {content}"
);
}
}
mod plan_quiet_mode {
use super::*;
#[test]
fn given_workspace_when_plan_quiet_then_succeeds_with_minimal_output() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--quiet")
.arg("plan")
.assert()
.success()
.stdout(contains("demo@0.1.0"));
}
}
mod plan_json_format {
use super::*;
#[test]
fn given_multi_crate_when_plan_json_then_valid_json_output() {
let td = tempdir().expect("tempdir");
create_multi_crate_workspace(td.path());
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--format")
.arg("json")
.arg("plan")
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).expect("utf8");
assert!(
stdout.contains("core-lib@0.1.0"),
"plan output should contain core-lib, got: {stdout}"
);
assert!(
stdout.contains("Total packages to publish:"),
"plan output should contain package count, got: {stdout}"
);
}
}