use std::fs;
use std::path::Path;
use assert_cmd::Command;
use predicates::str::contains;
use tempfile::tempdir;
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_multi_crate_workspace(root: &Path) {
write_file(
&root.join("Cargo.toml"),
r#"
[workspace]
members = ["core", "utils"]
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");
}
fn shipper_cmd() -> Command {
Command::new(assert_cmd::cargo::cargo_bin!("shipper-cli"))
}
mod failure_classification {
#[test]
fn given_retryable_stderr_when_classifying_then_retryable() {
let outcome =
shipper_core::cargo_failure::classify_publish_failure("HTTP 429 too many requests", "");
assert_eq!(
outcome.class,
shipper_core::cargo_failure::CargoFailureClass::Retryable
);
}
#[test]
fn given_permanent_stderr_when_classifying_then_permanent() {
let outcome =
shipper_core::cargo_failure::classify_publish_failure("permission denied", "");
assert_eq!(
outcome.class,
shipper_core::cargo_failure::CargoFailureClass::Permanent
);
}
#[test]
fn given_unknown_error_when_classifying_then_ambiguous() {
let outcome = shipper_core::cargo_failure::classify_publish_failure(
"something completely unexpected happened",
"",
);
assert_eq!(
outcome.class,
shipper_core::cargo_failure::CargoFailureClass::Ambiguous
);
}
#[test]
fn given_both_retryable_and_permanent_patterns_when_classifying_then_retryable_wins() {
let outcome = shipper_core::cargo_failure::classify_publish_failure(
"permission denied and 429 too many requests",
"",
);
assert_eq!(
outcome.class,
shipper_core::cargo_failure::CargoFailureClass::Retryable
);
}
#[test]
fn given_timeout_error_when_classifying_then_retryable() {
let outcome = shipper_core::cargo_failure::classify_publish_failure(
"operation timed out after 30s",
"",
);
assert_eq!(
outcome.class,
shipper_core::cargo_failure::CargoFailureClass::Retryable
);
}
#[test]
fn given_auth_failure_when_classifying_then_permanent() {
let outcome = shipper_core::cargo_failure::classify_publish_failure("401 unauthorized", "");
assert_eq!(
outcome.class,
shipper_core::cargo_failure::CargoFailureClass::Permanent
);
}
}
mod state_persistence_after_failure {
use super::*;
#[test]
fn given_corrupted_state_file_when_resume_then_reports_parse_error() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let state_dir = td.path().join("custom-state");
fs::create_dir_all(&state_dir).expect("mkdir state dir");
fs::write(state_dir.join("state.json"), "NOT VALID JSON {{{").expect("write corrupt state");
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--state-dir")
.arg(&state_dir)
.arg("resume")
.assert()
.failure()
.stderr(contains("failed to parse state JSON"));
}
#[test]
fn given_state_with_mismatched_plan_id_when_publish_then_rejected() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let state_dir = td.path().join("custom-state");
fs::create_dir_all(&state_dir).expect("mkdir state dir");
let state_json = serde_json::json!({
"state_version": "shipper.state.v1",
"plan_id": "wrong-plan-id-12345",
"registry": {
"name": "crates-io",
"api_base": "https://crates.io",
"index_base": "https://index.crates.io"
},
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"packages": {}
});
fs::write(
state_dir.join("state.json"),
serde_json::to_string_pretty(&state_json).expect("serialize"),
)
.expect("write state");
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--state-dir")
.arg(&state_dir)
.arg("--allow-dirty")
.arg("publish")
.assert()
.failure()
.stderr(contains("does not match"));
}
#[test]
fn given_empty_state_file_when_resume_then_reports_parse_error() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let state_dir = td.path().join("empty-state");
fs::create_dir_all(&state_dir).expect("mkdir state dir");
fs::write(state_dir.join("state.json"), "").expect("write empty state");
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--state-dir")
.arg(&state_dir)
.arg("resume")
.assert()
.failure()
.stderr(contains("failed to parse state JSON"));
}
}
mod resume_skips_completed {
use super::*;
#[test]
fn given_state_with_published_package_when_resume_then_skips_completed() {
let td = tempdir().expect("tempdir");
create_multi_crate_workspace(td.path());
let spec = shipper_core::types::ReleaseSpec {
manifest_path: td.path().join("Cargo.toml"),
registry: shipper_core::types::Registry::crates_io(),
selected_packages: None,
};
let ws = shipper_core::plan::build_plan(&spec).expect("build plan");
let plan_id = &ws.plan.plan_id;
let state_dir = td.path().join("custom-state");
fs::create_dir_all(&state_dir).expect("mkdir state dir");
let state_json = serde_json::json!({
"state_version": "shipper.state.v1",
"plan_id": plan_id,
"registry": {
"name": "crates-io",
"api_base": "https://crates.io",
"index_base": "https://index.crates.io"
},
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"packages": {
"core@0.1.0": {
"name": "core",
"version": "0.1.0",
"attempts": 1,
"state": { "state": "published" },
"last_updated_at": "2024-01-01T00:00:00Z"
},
"utils@0.1.0": {
"name": "utils",
"version": "0.1.0",
"attempts": 0,
"state": { "state": "pending" },
"last_updated_at": "2024-01-01T00:00:00Z"
}
}
});
fs::write(
state_dir.join("state.json"),
serde_json::to_string_pretty(&state_json).expect("serialize"),
)
.expect("write state");
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--state-dir")
.arg(&state_dir)
.arg("--allow-dirty")
.arg("resume")
.output()
.expect("resume");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("already complete") || stderr.contains("already published"),
"expected 'already complete' or 'already published' in stderr, got:\n{stderr}"
);
}
#[test]
fn given_no_state_file_when_resume_then_fails() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let state_dir = td.path().join("empty-state");
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("resume")
.assert()
.failure()
.stderr(contains("no existing state found"));
}
}
mod invalid_state_handling {
use super::*;
#[test]
fn given_state_with_wrong_schema_when_resume_then_fails_gracefully() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let state_dir = td.path().join("bad-schema-state");
fs::create_dir_all(&state_dir).expect("mkdir state dir");
fs::write(
state_dir.join("state.json"),
r#"{"unexpected": "schema", "not": "execution_state"}"#,
)
.expect("write bad schema state");
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--state-dir")
.arg(&state_dir)
.arg("resume")
.output()
.expect("resume");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("failed to parse state JSON") || stderr.contains("missing field"),
"expected parse/schema error in stderr, got:\n{stderr}"
);
}
#[test]
fn given_truncated_state_json_when_resume_then_fails_gracefully() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let state_dir = td.path().join("truncated-state");
fs::create_dir_all(&state_dir).expect("mkdir state dir");
fs::write(
state_dir.join("state.json"),
r#"{"state_version": "shipper.state.v1", "plan_id": "abc"#,
)
.expect("write truncated state");
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--state-dir")
.arg(&state_dir)
.arg("resume")
.assert()
.failure()
.stderr(contains("failed to parse state JSON"));
}
#[test]
fn given_state_as_json_array_when_resume_then_fails_gracefully() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let state_dir = td.path().join("array-state");
fs::create_dir_all(&state_dir).expect("mkdir state dir");
fs::write(state_dir.join("state.json"), r#"[1, 2, 3]"#).expect("write array state");
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--state-dir")
.arg(&state_dir)
.arg("resume")
.assert()
.failure()
.stderr(contains("failed to parse state JSON"));
}
}
mod corrupted_receipt_handling {
use super::*;
#[test]
fn given_corrupted_receipt_when_inspect_receipt_then_fails_gracefully() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let state_dir = td.path().join("custom-state");
fs::create_dir_all(&state_dir).expect("mkdir state dir");
fs::write(
state_dir.join("receipt.json"),
"CORRUPTED RECEIPT DATA {{{}}}",
)
.expect("write corrupt receipt");
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--state-dir")
.arg(&state_dir)
.arg("inspect-receipt")
.output()
.expect("inspect-receipt");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("failed to parse receipt") || stderr.contains("failed to read receipt"),
"expected receipt parse error in stderr, got:\n{stderr}"
);
}
#[test]
fn given_empty_receipt_when_inspect_receipt_then_fails_gracefully() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let state_dir = td.path().join("empty-receipt-state");
fs::create_dir_all(&state_dir).expect("mkdir state dir");
fs::write(state_dir.join("receipt.json"), "").expect("write empty receipt");
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--state-dir")
.arg(&state_dir)
.arg("inspect-receipt")
.output()
.expect("inspect-receipt");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("failed to parse receipt") || stderr.contains("failed to read receipt"),
"expected receipt error in stderr, got:\n{stderr}"
);
}
#[test]
fn given_receipt_with_wrong_schema_when_inspect_receipt_then_fails_gracefully() {
let td = tempdir().expect("tempdir");
create_single_crate_workspace(td.path());
let state_dir = td.path().join("wrong-schema-receipt");
fs::create_dir_all(&state_dir).expect("mkdir state dir");
fs::write(state_dir.join("receipt.json"), r#"{"not": "a receipt"}"#)
.expect("write bad receipt");
let output = shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("--state-dir")
.arg(&state_dir)
.arg("inspect-receipt")
.output()
.expect("inspect-receipt");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("failed to parse receipt") || stderr.contains("missing field"),
"expected receipt parse error in stderr, got:\n{stderr}"
);
}
#[test]
fn given_corrupted_receipt_from_prior_run_when_plan_then_still_works() {
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 state dir");
fs::write(state_dir.join("receipt.json"), "BROKEN LEFTOVER RECEIPT")
.expect("write corrupt receipt");
shipper_cmd()
.arg("--manifest-path")
.arg(td.path().join("Cargo.toml"))
.arg("plan")
.assert()
.success();
}
}