use std::collections::BTreeMap;
use std::path::PathBuf;
use chrono::Utc;
use tempfile::tempdir;
use super::*;
use shipper_types::{
ExecutionState, PackageProgress, PackageReceipt, PackageState, Receipt, Registry,
};
fn sample_state() -> ExecutionState {
let mut packages = BTreeMap::new();
packages.insert(
"demo@0.1.0".to_string(),
PackageProgress {
name: "demo".to_string(),
version: "0.1.0".to_string(),
attempts: 1,
state: PackageState::Pending,
last_updated_at: Utc::now(),
},
);
ExecutionState {
state_version: CURRENT_STATE_VERSION.to_string(),
plan_id: "p1".to_string(),
registry: Registry::crates_io(),
created_at: Utc::now(),
updated_at: Utc::now(),
packages,
}
}
fn sample_receipt() -> Receipt {
Receipt {
receipt_version: "shipper.receipt.v2".to_string(),
plan_id: "p1".to_string(),
registry: Registry::crates_io(),
started_at: Utc::now(),
finished_at: Utc::now(),
packages: vec![PackageReceipt {
name: "demo".to_string(),
version: "0.1.0".to_string(),
attempts: 1,
state: PackageState::Published,
started_at: Utc::now(),
finished_at: Utc::now(),
duration_ms: 10,
evidence: shipper_types::PackageEvidence {
attempts: vec![],
readiness_checks: vec![],
},
compromised_at: None,
compromised_by: None,
superseded_by: None,
}],
event_log_path: PathBuf::from(".shipper/events.jsonl"),
git_context: None,
environment: shipper_types::EnvironmentFingerprint {
shipper_version: "0.1.0".to_string(),
cargo_version: Some("1.75.0".to_string()),
rust_version: Some("1.75.0".to_string()),
os: "linux".to_string(),
arch: "x86_64".to_string(),
},
}
}
#[test]
fn path_helpers_append_expected_files() {
let base = PathBuf::from("x");
assert_eq!(state_path(&base), PathBuf::from("x").join(STATE_FILE));
assert_eq!(receipt_path(&base), PathBuf::from("x").join(RECEIPT_FILE));
}
#[test]
fn load_state_returns_none_when_file_missing() {
let td = tempdir().expect("tempdir");
let loaded = load_state(td.path()).expect("load");
assert!(loaded.is_none());
}
#[test]
fn save_and_load_state_roundtrip() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("nested").join("state");
let st = sample_state();
save_state(&dir, &st).expect("save state");
let loaded = load_state(&dir).expect("load state").expect("exists");
assert_eq!(loaded.plan_id, st.plan_id);
assert_eq!(loaded.registry.name, st.registry.name);
assert_eq!(loaded.packages.len(), 1);
}
#[test]
fn write_receipt_creates_file() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("out");
let receipt = sample_receipt();
write_receipt(&dir, &receipt).expect("write receipt");
let path = receipt_path(&dir);
let content = fs::read_to_string(path).expect("read receipt");
assert!(content.contains("\"receipt_version\""));
assert!(content.contains("\"shipper.receipt.v2\""));
}
#[test]
fn validate_receipt_version_accepts_current_version() {
validate_receipt_version(CURRENT_RECEIPT_VERSION).expect("current version should be valid");
}
#[test]
fn validate_receipt_version_accepts_minimum_version() {
validate_receipt_version(MINIMUM_SUPPORTED_VERSION).expect("minimum version should be valid");
}
#[test]
fn validate_receipt_version_rejects_old_version() {
let result = validate_receipt_version("shipper.receipt.v0");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("too old"));
}
#[test]
fn validate_receipt_version_rejects_invalid_format() {
let result = validate_receipt_version("invalid.version");
assert!(result.is_err());
}
#[test]
fn parse_schema_version_extracts_number() {
let result =
shipper_types::schema::parse_schema_version("shipper.receipt.v2").expect("should parse");
assert_eq!(result, 2);
}
#[test]
fn parse_schema_version_handles_single_digit() {
let result =
shipper_types::schema::parse_schema_version("shipper.receipt.v1").expect("should parse");
assert_eq!(result, 1);
}
#[test]
fn parse_schema_version_handles_large_version() {
let result =
shipper_types::schema::parse_schema_version("shipper.receipt.v100").expect("should parse");
assert_eq!(result, 100);
}
#[test]
fn parse_schema_version_rejects_invalid_format() {
let result = shipper_types::schema::parse_schema_version("invalid");
assert!(result.is_err());
}
#[test]
fn migrate_v1_to_v2_adds_missing_fields() {
let td = tempdir().expect("tempdir");
let path = td.path().join("receipt.json");
let v1_json = r#"{
"receipt_version": "shipper.receipt.v1",
"plan_id": "test-plan",
"registry": {
"name": "crates-io",
"api_base": "https://crates.io",
"index_base": "https://index.crates.io"
},
"started_at": "2024-01-01T00:00:00Z",
"finished_at": "2024-01-01T01:00:00Z",
"packages": [],
"event_log_path": ".shipper/events.jsonl"
}"#;
fs::write(&path, v1_json).expect("write v1 receipt");
let receipt = migrate_receipt(&path).expect("migrate receipt");
assert_eq!(receipt.receipt_version, CURRENT_RECEIPT_VERSION);
assert!(receipt.git_context.is_none());
assert!(!receipt.environment.shipper_version.is_empty());
}
#[test]
fn load_receipt_migrates_v1_to_v2() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("out");
fs::create_dir_all(&dir).expect("mkdir");
let path = receipt_path(&dir);
let v1_json = r#"{
"receipt_version": "shipper.receipt.v1",
"plan_id": "test-plan",
"registry": {
"name": "crates-io",
"api_base": "https://crates.io",
"index_base": "https://index.crates.io"
},
"started_at": "2024-01-01T00:00:00Z",
"finished_at": "2024-01-01T01:00:00Z",
"packages": [],
"event_log_path": ".shipper/events.jsonl"
}"#;
fs::write(&path, v1_json).expect("write v1 receipt");
let receipt = load_receipt(&dir)
.expect("load receipt")
.expect("receipt exists");
assert_eq!(receipt.receipt_version, CURRENT_RECEIPT_VERSION);
assert!(receipt.git_context.is_none());
assert!(!receipt.environment.shipper_version.is_empty());
}
#[test]
fn load_receipt_loads_v2_directly() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("out");
let receipt = sample_receipt();
write_receipt(&dir, &receipt).expect("write receipt");
let loaded = load_receipt(&dir)
.expect("load receipt")
.expect("receipt exists");
assert_eq!(loaded.receipt_version, receipt.receipt_version);
assert_eq!(loaded.plan_id, receipt.plan_id);
}
#[test]
fn load_state_fails_on_invalid_json() {
let td = tempdir().expect("tempdir");
let path = state_path(td.path());
fs::create_dir_all(td.path()).expect("mkdir");
fs::write(&path, "{not-json").expect("write");
let err = load_state(td.path()).expect_err("must fail");
let msg = format!("{err:#}");
assert!(msg.contains("failed to parse state JSON"));
}
#[test]
fn save_state_surfaces_rename_error() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("state-dir");
fs::create_dir_all(&dir).expect("mkdir");
fs::create_dir_all(state_path(&dir)).expect("mkdir conflicting state path");
let err = save_state(&dir, &sample_state()).expect_err("must fail");
assert!(format!("{err:#}").contains("failed to rename tmp file"));
}
#[test]
fn validate_receipt_version_rejects_non_shipper_version() {
let result = validate_receipt_version("other.receipt.v2");
assert!(result.is_err());
}
#[test]
fn validate_receipt_version_rejects_missing_version_number() {
let result = validate_receipt_version("shipper.receipt.v");
assert!(result.is_err());
}
#[test]
fn parse_schema_version_rejects_invalid_format_no_prefix() {
let result = shipper_types::schema::parse_schema_version("receipt.v2");
assert!(result.is_err());
}
#[test]
fn parse_schema_version_rejects_invalid_format_no_version() {
let result = shipper_types::schema::parse_schema_version("shipper.receipt");
assert!(result.is_err());
}
#[test]
fn parse_schema_version_rejects_invalid_format_missing_v() {
let result = shipper_types::schema::parse_schema_version("shipper.receipt.2");
assert!(result.is_err());
}
#[test]
fn migrate_v1_to_v2_adds_git_context_as_none() {
let v1_json = serde_json::json!({
"receipt_version": "shipper.receipt.v1",
"plan_id": "test-plan",
"registry": {
"name": "crates-io",
"api_base": "https://crates.io",
"index_base": "https://index.crates.io"
},
"started_at": "2024-01-01T00:00:00Z",
"finished_at": "2024-01-01T01:00:00Z",
"packages": [],
"event_log_path": ".shipper/events.jsonl"
});
let receipt = migrate_v1_to_v2(v1_json).expect("migrate receipt");
assert_eq!(receipt.receipt_version, CURRENT_RECEIPT_VERSION);
assert!(receipt.git_context.is_none());
assert!(!receipt.environment.shipper_version.is_empty());
}
#[test]
fn migrate_v1_to_v2_preserves_existing_git_context() {
let v1_json = serde_json::json!({
"receipt_version": "shipper.receipt.v1",
"plan_id": "test-plan",
"registry": {
"name": "crates-io",
"api_base": "https://crates.io",
"index_base": "https://index.crates.io"
},
"started_at": "2024-01-01T00:00:00Z",
"finished_at": "2024-01-01T01:00:00Z",
"packages": [],
"event_log_path": ".shipper/events.jsonl",
"git_context": {
"commit": "abc123",
"branch": "main",
"tag": null,
"dirty": false
}
});
let receipt = migrate_v1_to_v2(v1_json).expect("migrate receipt");
assert_eq!(receipt.receipt_version, CURRENT_RECEIPT_VERSION);
assert!(receipt.git_context.is_some());
let ctx = receipt.git_context.unwrap();
assert_eq!(ctx.commit, Some("abc123".to_string()));
assert_eq!(ctx.branch, Some("main".to_string()));
}
#[test]
fn migrate_v1_to_v2_preserves_existing_environment() {
let v1_json = serde_json::json!({
"receipt_version": "shipper.receipt.v1",
"plan_id": "test-plan",
"registry": {
"name": "crates-io",
"api_base": "https://crates.io",
"index_base": "https://index.crates.io"
},
"started_at": "2024-01-01T00:00:00Z",
"finished_at": "2024-01-01T01:00:00Z",
"packages": [],
"event_log_path": ".shipper/events.jsonl",
"environment": {
"shipper_version": "0.1.0",
"cargo_version": "1.75.0",
"rust_version": "1.75.0",
"os": "linux",
"arch": "x86_64"
}
});
let receipt = migrate_v1_to_v2(v1_json).expect("migrate receipt");
assert_eq!(receipt.receipt_version, CURRENT_RECEIPT_VERSION);
assert_eq!(receipt.environment.shipper_version, "0.1.0");
assert_eq!(
receipt.environment.cargo_version,
Some("1.75.0".to_string())
);
}
#[test]
fn load_receipt_handles_missing_receipt_version_field() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("out");
fs::create_dir_all(&dir).expect("mkdir");
let path = receipt_path(&dir);
let receipt_json = r#"{
"plan_id": "test-plan",
"registry": {
"name": "crates-io",
"api_base": "https://crates.io",
"index_base": "https://index.crates.io"
},
"started_at": "2024-01-01T00:00:00Z",
"finished_at": "2024-01-01T01:00:00Z",
"packages": [],
"event_log_path": ".shipper/events.jsonl"
}"#;
fs::write(&path, receipt_json).expect("write receipt");
let receipt = load_receipt(&dir)
.expect("load receipt")
.expect("receipt exists");
assert_eq!(receipt.receipt_version, CURRENT_RECEIPT_VERSION);
}
#[test]
fn load_receipt_handles_future_version() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("out");
fs::create_dir_all(&dir).expect("mkdir");
let path = receipt_path(&dir);
let receipt_json = r#"{
"receipt_version": "shipper.receipt.v99",
"plan_id": "test-plan",
"registry": {
"name": "crates-io",
"api_base": "https://crates.io",
"index_base": "https://index.crates.io"
},
"started_at": "2024-01-01T00:00:00Z",
"finished_at": "2024-01-01T01:00:00Z",
"packages": [],
"event_log_path": ".shipper/events.jsonl",
"git_context": null,
"environment": {
"shipper_version": "0.1.0",
"cargo_version": null,
"rust_version": null,
"os": "linux",
"arch": "x86_64"
}
}"#;
fs::write(&path, receipt_json).expect("write receipt");
let receipt = load_receipt(&dir)
.expect("load receipt")
.expect("receipt exists");
assert_eq!(receipt.receipt_version, "shipper.receipt.v99");
}
#[test]
fn has_incomplete_state_returns_true_when_state_exists_without_receipt() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("out");
fs::create_dir_all(&dir).expect("mkdir");
let st = sample_state();
save_state(&dir, &st).expect("save state");
assert!(has_incomplete_state(&dir));
}
#[test]
fn has_incomplete_state_returns_false_when_receipt_exists() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("out");
fs::create_dir_all(&dir).expect("mkdir");
let st = sample_state();
save_state(&dir, &st).expect("save state");
write_receipt(&dir, &sample_receipt()).expect("write receipt");
assert!(!has_incomplete_state(&dir));
}
#[test]
fn has_incomplete_state_returns_false_when_no_state_exists() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("out");
fs::create_dir_all(&dir).expect("mkdir");
assert!(!has_incomplete_state(&dir));
}
#[test]
fn clear_state_removes_state_file() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("out");
fs::create_dir_all(&dir).expect("mkdir");
let st = sample_state();
save_state(&dir, &st).expect("save state");
assert!(state_path(&dir).exists());
clear_state(&dir).expect("clear state");
assert!(!state_path(&dir).exists());
}
#[test]
fn clear_state_does_not_remove_receipt_file() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("out");
fs::create_dir_all(&dir).expect("mkdir");
let st = sample_state();
save_state(&dir, &st).expect("save state");
write_receipt(&dir, &sample_receipt()).expect("write receipt");
clear_state(&dir).expect("clear state");
assert!(!state_path(&dir).exists());
assert!(receipt_path(&dir).exists());
}
#[test]
fn state_double_save_produces_identical_json() {
let td = tempdir().expect("tempdir");
let dir1 = td.path().join("first");
let dir2 = td.path().join("second");
let st = sample_state();
save_state(&dir1, &st).expect("first save");
let loaded = load_state(&dir1).expect("load").expect("exists");
save_state(&dir2, &loaded).expect("second save");
let json1 = fs::read_to_string(state_path(&dir1)).expect("read first");
let json2 = fs::read_to_string(state_path(&dir2)).expect("read second");
assert_eq!(json1, json2, "save→load→save must produce identical JSON");
}
#[test]
fn receipt_double_save_produces_identical_json() {
let td = tempdir().expect("tempdir");
let dir1 = td.path().join("first");
let dir2 = td.path().join("second");
let receipt = sample_receipt();
write_receipt(&dir1, &receipt).expect("first write");
let loaded = load_receipt(&dir1).expect("load").expect("exists");
write_receipt(&dir2, &loaded).expect("second write");
let json1 = fs::read_to_string(receipt_path(&dir1)).expect("read first");
let json2 = fs::read_to_string(receipt_path(&dir2)).expect("read second");
assert_eq!(json1, json2, "write→load→write must produce identical JSON");
}
#[test]
fn state_lifecycle_pending_uploaded_published() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("lifecycle");
let mut packages = BTreeMap::new();
packages.insert(
"crate-a@1.0.0".to_string(),
PackageProgress {
name: "crate-a".to_string(),
version: "1.0.0".to_string(),
attempts: 0,
state: PackageState::Pending,
last_updated_at: Utc::now(),
},
);
let mut state = ExecutionState {
state_version: CURRENT_STATE_VERSION.to_string(),
plan_id: "lifecycle-plan".to_string(),
registry: Registry::crates_io(),
created_at: Utc::now(),
updated_at: Utc::now(),
packages,
};
save_state(&dir, &state).expect("save pending");
let loaded = load_state(&dir).expect("load").expect("exists");
assert!(matches!(
loaded.packages["crate-a@1.0.0"].state,
PackageState::Pending
));
state.packages.get_mut("crate-a@1.0.0").unwrap().state = PackageState::Uploaded;
state.packages.get_mut("crate-a@1.0.0").unwrap().attempts = 1;
save_state(&dir, &state).expect("save uploaded");
let loaded = load_state(&dir).expect("load").expect("exists");
assert!(matches!(
loaded.packages["crate-a@1.0.0"].state,
PackageState::Uploaded
));
state.packages.get_mut("crate-a@1.0.0").unwrap().state = PackageState::Published;
save_state(&dir, &state).expect("save published");
let loaded = load_state(&dir).expect("load").expect("exists");
assert!(matches!(
loaded.packages["crate-a@1.0.0"].state,
PackageState::Published
));
assert_eq!(loaded.packages["crate-a@1.0.0"].attempts, 1);
}
#[test]
fn state_all_error_classes_persist() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("errors");
let mut packages = BTreeMap::new();
for (key, class, msg) in [
("a@1.0.0", shipper_types::ErrorClass::Retryable, "timeout"),
("b@1.0.0", shipper_types::ErrorClass::Permanent, "denied"),
("c@1.0.0", shipper_types::ErrorClass::Ambiguous, "unclear"),
] {
let name = key.split('@').next().unwrap();
packages.insert(
key.to_string(),
PackageProgress {
name: name.to_string(),
version: "1.0.0".to_string(),
attempts: 1,
state: PackageState::Failed {
class,
message: msg.to_string(),
},
last_updated_at: Utc::now(),
},
);
}
let state = ExecutionState {
state_version: CURRENT_STATE_VERSION.to_string(),
plan_id: "error-plan".to_string(),
registry: Registry::crates_io(),
created_at: Utc::now(),
updated_at: Utc::now(),
packages,
};
save_state(&dir, &state).expect("save");
let loaded = load_state(&dir).expect("load").expect("exists");
assert_eq!(loaded.packages.len(), 3);
match &loaded.packages["a@1.0.0"].state {
PackageState::Failed { class, .. } => {
assert!(matches!(class, shipper_types::ErrorClass::Retryable));
}
other => panic!("expected Failed, got {other:?}"),
}
match &loaded.packages["b@1.0.0"].state {
PackageState::Failed { class, .. } => {
assert!(matches!(class, shipper_types::ErrorClass::Permanent));
}
other => panic!("expected Failed, got {other:?}"),
}
match &loaded.packages["c@1.0.0"].state {
PackageState::Failed { class, .. } => {
assert!(matches!(class, shipper_types::ErrorClass::Ambiguous));
}
other => panic!("expected Failed, got {other:?}"),
}
}
#[test]
fn state_empty_packages_roundtrip() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("empty");
let state = ExecutionState {
state_version: CURRENT_STATE_VERSION.to_string(),
plan_id: "empty-plan".to_string(),
registry: Registry::crates_io(),
created_at: Utc::now(),
updated_at: Utc::now(),
packages: BTreeMap::new(),
};
save_state(&dir, &state).expect("save");
let loaded = load_state(&dir).expect("load").expect("exists");
assert!(loaded.packages.is_empty());
assert_eq!(loaded.plan_id, "empty-plan");
}
#[test]
fn receipt_empty_packages_roundtrip() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("empty");
let empty_receipt = Receipt {
packages: vec![],
..sample_receipt()
};
write_receipt(&dir, &empty_receipt).expect("write");
let loaded = load_receipt(&dir).expect("load").expect("exists");
assert!(loaded.packages.is_empty());
}
#[test]
fn clear_state_noop_when_no_state_exists() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("noop");
fs::create_dir_all(&dir).expect("mkdir");
clear_state(&dir).expect("clear on empty dir");
assert!(!state_path(&dir).exists());
}
#[test]
fn load_receipt_returns_none_when_missing() {
let td = tempdir().expect("tempdir");
let loaded = load_receipt(td.path()).expect("load");
assert!(loaded.is_none());
}
#[test]
fn state_overwrite_replaces_all_data() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("overwrite");
let st1 = ExecutionState {
state_version: CURRENT_STATE_VERSION.to_string(),
plan_id: "plan-v1".to_string(),
registry: Registry::crates_io(),
created_at: Utc::now(),
updated_at: Utc::now(),
packages: BTreeMap::new(),
};
save_state(&dir, &st1).expect("save v1");
let mut packages = BTreeMap::new();
packages.insert(
"new@2.0.0".to_string(),
PackageProgress {
name: "new".to_string(),
version: "2.0.0".to_string(),
attempts: 3,
state: PackageState::Published,
last_updated_at: Utc::now(),
},
);
let st2 = ExecutionState {
state_version: CURRENT_STATE_VERSION.to_string(),
plan_id: "plan-v2".to_string(),
registry: Registry::crates_io(),
created_at: Utc::now(),
updated_at: Utc::now(),
packages,
};
save_state(&dir, &st2).expect("save v2");
let loaded = load_state(&dir).expect("load").expect("exists");
assert_eq!(loaded.plan_id, "plan-v2");
assert_eq!(loaded.packages.len(), 1);
assert!(loaded.packages.contains_key("new@2.0.0"));
}
#[test]
fn state_version_constant_preserved_on_roundtrip() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("version");
save_state(&dir, &sample_state()).expect("save");
let loaded = load_state(&dir).expect("load").expect("exists");
assert_eq!(loaded.state_version, CURRENT_STATE_VERSION);
}
#[test]
fn receipt_version_constant_preserved_on_roundtrip() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("version");
write_receipt(&dir, &sample_receipt()).expect("write");
let loaded = load_receipt(&dir).expect("load").expect("exists");
assert_eq!(loaded.receipt_version, CURRENT_RECEIPT_VERSION);
}
#[test]
fn state_with_special_chars_in_plan_id() {
let td = tempdir().expect("tempdir");
let special_ids = [
"plan with spaces",
"plan/with/slashes",
"plan\"with\"quotes",
"план-юникод",
"\u{1f680}release-v1",
];
for (i, plan_id) in special_ids.iter().enumerate() {
let dir = td.path().join(format!("special-{i}"));
let state = ExecutionState {
state_version: CURRENT_STATE_VERSION.to_string(),
plan_id: plan_id.to_string(),
registry: Registry::crates_io(),
created_at: Utc::now(),
updated_at: Utc::now(),
packages: BTreeMap::new(),
};
save_state(&dir, &state).expect("save");
let loaded = load_state(&dir).expect("load").expect("exists");
assert_eq!(loaded.plan_id, *plan_id);
}
}
#[test]
fn state_plan_id_mismatch_detection() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("mismatch");
let state = ExecutionState {
state_version: CURRENT_STATE_VERSION.to_string(),
plan_id: "original-plan-abc".to_string(),
registry: Registry::crates_io(),
created_at: Utc::now(),
updated_at: Utc::now(),
packages: BTreeMap::new(),
};
save_state(&dir, &state).expect("save");
let loaded = load_state(&dir).expect("load").expect("exists");
assert_ne!(loaded.plan_id, "different-plan-xyz");
assert_eq!(loaded.plan_id, "original-plan-abc");
}
#[test]
fn receipt_with_high_attempt_count() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("high-attempts");
let receipt = Receipt {
packages: vec![PackageReceipt {
name: "flaky".to_string(),
version: "1.0.0".to_string(),
attempts: 99,
state: PackageState::Published,
started_at: Utc::now(),
finished_at: Utc::now(),
duration_ms: 999_999,
evidence: shipper_types::PackageEvidence {
attempts: vec![],
readiness_checks: vec![],
},
compromised_at: None,
compromised_by: None,
superseded_by: None,
}],
..sample_receipt()
};
write_receipt(&dir, &receipt).expect("write");
let loaded = load_receipt(&dir).expect("load").expect("exists");
assert_eq!(loaded.packages[0].attempts, 99);
assert_eq!(loaded.packages[0].duration_ms, 999_999);
}
use chrono::TimeZone;
fn deterministic_state() -> ExecutionState {
let fixed = Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap();
let mut packages = BTreeMap::new();
packages.insert(
"alpha@0.1.0".to_string(),
PackageProgress {
name: "alpha".to_string(),
version: "0.1.0".to_string(),
attempts: 0,
state: PackageState::Pending,
last_updated_at: fixed,
},
);
packages.insert(
"beta@0.2.0".to_string(),
PackageProgress {
name: "beta".to_string(),
version: "0.2.0".to_string(),
attempts: 1,
state: PackageState::Published,
last_updated_at: fixed,
},
);
ExecutionState {
state_version: CURRENT_STATE_VERSION.to_string(),
plan_id: "plan-abc123".to_string(),
registry: Registry::crates_io(),
created_at: fixed,
updated_at: fixed,
packages,
}
}
fn deterministic_receipt() -> Receipt {
let fixed = Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap();
let finished = Utc.with_ymd_and_hms(2025, 1, 15, 12, 5, 0).unwrap();
Receipt {
receipt_version: CURRENT_RECEIPT_VERSION.to_string(),
plan_id: "plan-abc123".to_string(),
registry: Registry::crates_io(),
started_at: fixed,
finished_at: finished,
packages: vec![
PackageReceipt {
name: "alpha".to_string(),
version: "0.1.0".to_string(),
attempts: 1,
state: PackageState::Published,
started_at: fixed,
finished_at: finished,
duration_ms: 300_000,
evidence: shipper_types::PackageEvidence {
attempts: vec![],
readiness_checks: vec![],
},
compromised_at: None,
compromised_by: None,
superseded_by: None,
},
PackageReceipt {
name: "beta".to_string(),
version: "0.2.0".to_string(),
attempts: 2,
state: PackageState::Failed {
class: shipper_types::ErrorClass::Retryable,
message: "registry timeout".to_string(),
},
started_at: fixed,
finished_at: finished,
duration_ms: 120_000,
evidence: shipper_types::PackageEvidence {
attempts: vec![],
readiness_checks: vec![],
},
compromised_at: None,
compromised_by: None,
superseded_by: None,
},
],
event_log_path: PathBuf::from(".shipper/events.jsonl"),
git_context: None,
environment: shipper_types::EnvironmentFingerprint {
shipper_version: "0.3.0".to_string(),
cargo_version: Some("1.82.0".to_string()),
rust_version: Some("1.82.0".to_string()),
os: "linux".to_string(),
arch: "x86_64".to_string(),
},
}
}
#[test]
fn snapshot_state_json_format() {
let state = deterministic_state();
let json = serde_json::to_string_pretty(&state).expect("serialize state");
insta::assert_snapshot!("state_json_format", json);
}
#[test]
fn snapshot_receipt_json_format() {
let receipt = deterministic_receipt();
let json = serde_json::to_string_pretty(&receipt).expect("serialize receipt");
insta::assert_snapshot!("receipt_json_format", json);
}
#[test]
fn snapshot_state_transition_pending_to_published() {
let fixed = Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap();
let mut state = deterministic_state();
if let Some(pkg) = state.packages.get_mut("alpha@0.1.0") {
pkg.state = PackageState::Published;
pkg.attempts = 1;
pkg.last_updated_at = fixed;
}
state.updated_at = fixed;
let json = serde_json::to_string_pretty(&state).expect("serialize");
insta::assert_snapshot!("state_transition_pending_to_published", json);
}
#[test]
fn snapshot_state_transition_pending_to_failed() {
let fixed = Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap();
let mut state = deterministic_state();
if let Some(pkg) = state.packages.get_mut("alpha@0.1.0") {
pkg.state = PackageState::Failed {
class: shipper_types::ErrorClass::Permanent,
message: "crate name is reserved".to_string(),
};
pkg.attempts = 1;
pkg.last_updated_at = fixed;
}
state.updated_at = fixed;
let json = serde_json::to_string_pretty(&state).expect("serialize");
insta::assert_snapshot!("state_transition_pending_to_failed", json);
}
#[test]
fn snapshot_state_transition_pending_to_skipped() {
let fixed = Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap();
let mut state = deterministic_state();
if let Some(pkg) = state.packages.get_mut("alpha@0.1.0") {
pkg.state = PackageState::Skipped {
reason: "already published".to_string(),
};
pkg.attempts = 0;
pkg.last_updated_at = fixed;
}
state.updated_at = fixed;
let json = serde_json::to_string_pretty(&state).expect("serialize");
insta::assert_snapshot!("state_transition_pending_to_skipped", json);
}
#[test]
fn snapshot_state_all_lifecycle_variants() {
let fixed = Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap();
let mut packages = BTreeMap::new();
packages.insert(
"crate-a@1.0.0".to_string(),
PackageProgress {
name: "crate-a".to_string(),
version: "1.0.0".to_string(),
attempts: 0,
state: PackageState::Pending,
last_updated_at: fixed,
},
);
packages.insert(
"crate-b@1.0.0".to_string(),
PackageProgress {
name: "crate-b".to_string(),
version: "1.0.0".to_string(),
attempts: 1,
state: PackageState::Uploaded,
last_updated_at: fixed,
},
);
packages.insert(
"crate-c@1.0.0".to_string(),
PackageProgress {
name: "crate-c".to_string(),
version: "1.0.0".to_string(),
attempts: 1,
state: PackageState::Published,
last_updated_at: fixed,
},
);
packages.insert(
"crate-d@1.0.0".to_string(),
PackageProgress {
name: "crate-d".to_string(),
version: "1.0.0".to_string(),
attempts: 0,
state: PackageState::Skipped {
reason: "already published".to_string(),
},
last_updated_at: fixed,
},
);
packages.insert(
"crate-e@1.0.0".to_string(),
PackageProgress {
name: "crate-e".to_string(),
version: "1.0.0".to_string(),
attempts: 3,
state: PackageState::Failed {
class: shipper_types::ErrorClass::Retryable,
message: "network timeout".to_string(),
},
last_updated_at: fixed,
},
);
packages.insert(
"crate-f@1.0.0".to_string(),
PackageProgress {
name: "crate-f".to_string(),
version: "1.0.0".to_string(),
attempts: 1,
state: PackageState::Ambiguous {
message: "upload status unknown".to_string(),
},
last_updated_at: fixed,
},
);
let state = ExecutionState {
state_version: CURRENT_STATE_VERSION.to_string(),
plan_id: "all-variants-plan".to_string(),
registry: Registry::crates_io(),
created_at: fixed,
updated_at: fixed,
packages,
};
let json = serde_json::to_string_pretty(&state).expect("serialize");
insta::assert_snapshot!("state_all_lifecycle_variants", json);
}
#[test]
fn snapshot_receipt_all_failed() {
let fixed = Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap();
let finished = Utc.with_ymd_and_hms(2025, 1, 15, 12, 10, 0).unwrap();
let receipt = Receipt {
receipt_version: CURRENT_RECEIPT_VERSION.to_string(),
plan_id: "all-failed-plan".to_string(),
registry: Registry::crates_io(),
started_at: fixed,
finished_at: finished,
packages: vec![
PackageReceipt {
name: "core".to_string(),
version: "1.0.0".to_string(),
attempts: 3,
state: PackageState::Failed {
class: shipper_types::ErrorClass::Retryable,
message: "registry timeout after 3 attempts".to_string(),
},
started_at: fixed,
finished_at: finished,
duration_ms: 180_000,
evidence: shipper_types::PackageEvidence {
attempts: vec![],
readiness_checks: vec![],
},
compromised_at: None,
compromised_by: None,
superseded_by: None,
},
PackageReceipt {
name: "utils".to_string(),
version: "0.5.0".to_string(),
attempts: 1,
state: PackageState::Failed {
class: shipper_types::ErrorClass::Permanent,
message: "crate name is reserved".to_string(),
},
started_at: fixed,
finished_at: finished,
duration_ms: 5_000,
evidence: shipper_types::PackageEvidence {
attempts: vec![],
readiness_checks: vec![],
},
compromised_at: None,
compromised_by: None,
superseded_by: None,
},
],
event_log_path: PathBuf::from(".shipper/events.jsonl"),
git_context: None,
environment: shipper_types::EnvironmentFingerprint {
shipper_version: "0.3.0".to_string(),
cargo_version: Some("1.82.0".to_string()),
rust_version: Some("1.82.0".to_string()),
os: "linux".to_string(),
arch: "x86_64".to_string(),
},
};
let json = serde_json::to_string_pretty(&receipt).expect("serialize");
insta::assert_snapshot!("receipt_all_failed", json);
}
mod proptests {
use super::*;
use proptest::prelude::*;
fn arb_error_class() -> impl Strategy<Value = shipper_types::ErrorClass> {
prop_oneof![
Just(shipper_types::ErrorClass::Retryable),
Just(shipper_types::ErrorClass::Permanent),
Just(shipper_types::ErrorClass::Ambiguous),
]
}
fn arb_package_state() -> impl Strategy<Value = PackageState> {
prop_oneof![
Just(PackageState::Pending),
Just(PackageState::Uploaded),
Just(PackageState::Published),
"\\PC{1,50}".prop_map(|reason| PackageState::Skipped { reason }),
(arb_error_class(), "\\PC{1,50}")
.prop_map(|(class, message)| PackageState::Failed { class, message }),
"\\PC{1,50}".prop_map(|message| PackageState::Ambiguous { message }),
]
}
fn arb_registry() -> impl Strategy<Value = Registry> {
(
"[a-z][a-z0-9-]{0,19}",
"https?://[a-z]{1,10}\\.[a-z]{2,4}",
proptest::option::of("https?://[a-z]{1,10}\\.[a-z]{2,4}"),
)
.prop_map(|(name, api_base, index_base)| Registry {
name,
api_base,
index_base,
})
}
fn arb_datetime() -> impl Strategy<Value = chrono::DateTime<Utc>> {
(0i64..=4_000_000_000i64)
.prop_map(|secs| chrono::DateTime::from_timestamp(secs, 0).unwrap_or_default())
}
fn arb_package_progress() -> impl Strategy<Value = PackageProgress> {
(
"[a-z][a-z0-9_-]{0,19}",
"[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}",
0u32..100,
arb_package_state(),
arb_datetime(),
)
.prop_map(|(name, version, attempts, state, ts)| PackageProgress {
name,
version,
attempts,
state,
last_updated_at: ts,
})
}
fn arb_execution_state() -> impl Strategy<Value = ExecutionState> {
(
arb_registry(),
arb_datetime(),
arb_datetime(),
proptest::collection::btree_map(
"[a-z]{1,8}@[0-9]{1,2}\\.[0-9]{1,2}\\.[0-9]{1,2}",
arb_package_progress(),
0..5,
),
"\\PC{1,30}",
)
.prop_map(
|(registry, created, updated, packages, plan_id)| ExecutionState {
state_version: CURRENT_STATE_VERSION.to_string(),
plan_id,
registry,
created_at: created,
updated_at: updated,
packages,
},
)
}
fn arb_receipt() -> impl Strategy<Value = Receipt> {
(
arb_registry(),
arb_datetime(),
arb_datetime(),
proptest::collection::vec(arb_package_receipt(), 0..5),
"\\PC{1,30}",
)
.prop_map(|(registry, started, finished, packages, plan_id)| Receipt {
receipt_version: CURRENT_RECEIPT_VERSION.to_string(),
plan_id,
registry,
started_at: started,
finished_at: finished,
packages,
event_log_path: PathBuf::from(".shipper/events.jsonl"),
git_context: None,
environment: shipper_types::EnvironmentFingerprint {
shipper_version: "0.1.0".to_string(),
cargo_version: Some("1.80.0".to_string()),
rust_version: Some("1.80.0".to_string()),
os: "linux".to_string(),
arch: "x86_64".to_string(),
},
})
}
fn arb_package_receipt() -> impl Strategy<Value = PackageReceipt> {
(
"[a-z][a-z0-9_-]{0,19}",
"[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}",
0u32..100,
arb_package_state(),
arb_datetime(),
arb_datetime(),
0u128..1_000_000,
)
.prop_map(|(name, version, attempts, state, started, finished, dur)| {
PackageReceipt {
name,
version,
attempts,
state,
started_at: started,
finished_at: finished,
duration_ms: dur,
evidence: shipper_types::PackageEvidence {
attempts: vec![],
readiness_checks: vec![],
},
compromised_at: None,
compromised_by: None,
superseded_by: None,
}
})
}
proptest! {
#[test]
fn execution_state_json_roundtrip(state in arb_execution_state()) {
let json = serde_json::to_string(&state).expect("serialize");
let deser: ExecutionState = serde_json::from_str(&json).expect("deserialize");
prop_assert_eq!(state.plan_id, deser.plan_id);
prop_assert_eq!(state.state_version, deser.state_version);
prop_assert_eq!(state.registry.name, deser.registry.name);
prop_assert_eq!(state.packages.len(), deser.packages.len());
for (k, v) in &state.packages {
let d = deser.packages.get(k).expect("key exists");
prop_assert_eq!(&v.name, &d.name);
prop_assert_eq!(&v.version, &d.version);
prop_assert_eq!(v.attempts, d.attempts);
}
}
#[test]
fn receipt_json_roundtrip(receipt in arb_receipt()) {
let json = serde_json::to_string(&receipt).expect("serialize");
let deser: Receipt = serde_json::from_str(&json).expect("deserialize");
prop_assert_eq!(receipt.plan_id, deser.plan_id);
prop_assert_eq!(receipt.receipt_version, deser.receipt_version);
prop_assert_eq!(receipt.registry.name, deser.registry.name);
prop_assert_eq!(receipt.packages.len(), deser.packages.len());
}
}
proptest! {
#[test]
fn plan_id_survives_roundtrip(plan_id in "\\PC{1,64}") {
let fixed = chrono::DateTime::from_timestamp(1_700_000_000, 0)
.unwrap_or_default();
let state = ExecutionState {
state_version: CURRENT_STATE_VERSION.to_string(),
plan_id: plan_id.clone(),
registry: Registry::crates_io(),
created_at: fixed,
updated_at: fixed,
packages: BTreeMap::new(),
};
let json = serde_json::to_string(&state).expect("serialize");
let deser: ExecutionState = serde_json::from_str(&json).expect("deserialize");
prop_assert_eq!(plan_id, deser.plan_id);
}
}
proptest! {
#[test]
fn package_state_roundtrips_through_json(pkg_state in arb_package_state()) {
let json = serde_json::to_string(&pkg_state).expect("serialize");
let deser: PackageState = serde_json::from_str(&json).expect("deserialize");
prop_assert_eq!(pkg_state, deser);
}
#[test]
fn package_progress_state_update_persists(
initial in arb_package_progress(),
new_state in arb_package_state(),
) {
let fixed = chrono::DateTime::from_timestamp(1_700_000_000, 0)
.unwrap_or_default();
let mut packages = BTreeMap::new();
let key = format!("{}@{}", initial.name, initial.version);
packages.insert(key.clone(), initial);
let mut state = ExecutionState {
state_version: CURRENT_STATE_VERSION.to_string(),
plan_id: "test".to_string(),
registry: Registry::crates_io(),
created_at: fixed,
updated_at: fixed,
packages,
};
if let Some(pkg) = state.packages.get_mut(&key) {
pkg.state = new_state.clone();
pkg.last_updated_at = fixed;
}
let json = serde_json::to_string(&state).expect("serialize");
let deser: ExecutionState = serde_json::from_str(&json).expect("deserialize");
let pkg = deser.packages.get(&key).expect("package exists");
prop_assert_eq!(&new_state, &pkg.state);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(20))]
#[test]
fn save_load_state_is_consistent(state in arb_execution_state()) {
let td = tempfile::tempdir().expect("tempdir");
let dir = td.path().join("proptest-state");
save_state(&dir, &state).expect("save");
let loaded = load_state(&dir).expect("load").expect("exists");
prop_assert_eq!(state.plan_id, loaded.plan_id);
prop_assert_eq!(state.state_version, loaded.state_version);
prop_assert_eq!(state.registry.name, loaded.registry.name);
prop_assert_eq!(state.registry.api_base, loaded.registry.api_base);
prop_assert_eq!(state.packages.len(), loaded.packages.len());
for (k, v) in &state.packages {
let d = loaded.packages.get(k).expect("key exists");
prop_assert_eq!(&v.name, &d.name);
prop_assert_eq!(&v.version, &d.version);
prop_assert_eq!(v.attempts, d.attempts);
}
}
#[test]
fn save_load_receipt_is_consistent(receipt in arb_receipt()) {
let td = tempfile::tempdir().expect("tempdir");
let dir = td.path().join("proptest-receipt");
write_receipt(&dir, &receipt).expect("write");
let loaded = load_receipt(&dir).expect("load").expect("exists");
prop_assert_eq!(receipt.plan_id, loaded.plan_id);
prop_assert_eq!(receipt.receipt_version, loaded.receipt_version);
prop_assert_eq!(receipt.registry.name, loaded.registry.name);
prop_assert_eq!(receipt.packages.len(), loaded.packages.len());
for (orig, ld) in receipt.packages.iter().zip(loaded.packages.iter()) {
prop_assert_eq!(&orig.name, &ld.name);
prop_assert_eq!(&orig.version, &ld.version);
prop_assert_eq!(orig.attempts, ld.attempts);
}
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(20))]
#[test]
fn state_save_load_save_byte_identical(state in arb_execution_state()) {
let td = tempfile::tempdir().expect("tempdir");
let dir1 = td.path().join("first");
let dir2 = td.path().join("second");
save_state(&dir1, &state).expect("first save");
let loaded = load_state(&dir1).expect("load").expect("exists");
save_state(&dir2, &loaded).expect("second save");
let json1 = fs::read_to_string(state_path(&dir1)).expect("read first");
let json2 = fs::read_to_string(state_path(&dir2)).expect("read second");
prop_assert_eq!(json1, json2);
}
#[test]
fn receipt_save_load_save_byte_identical(receipt in arb_receipt()) {
let td = tempfile::tempdir().expect("tempdir");
let dir1 = td.path().join("first");
let dir2 = td.path().join("second");
write_receipt(&dir1, &receipt).expect("first write");
let loaded = load_receipt(&dir1).expect("load").expect("exists");
write_receipt(&dir2, &loaded).expect("second write");
let json1 = fs::read_to_string(receipt_path(&dir1)).expect("read first");
let json2 = fs::read_to_string(receipt_path(&dir2)).expect("read second");
prop_assert_eq!(json1, json2);
}
}
}
#[test]
fn atomic_write_orphaned_tmp_does_not_affect_load() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("crash");
let state = sample_state();
save_state(&dir, &state).expect("save");
let tmp = state_path(&dir).with_extension("tmp");
fs::write(&tmp, "garbage-from-interrupted-write").expect("write orphaned tmp");
let loaded = load_state(&dir).expect("load").expect("exists");
assert_eq!(loaded.plan_id, state.plan_id);
}
#[test]
fn atomic_write_replaces_orphaned_tmp_on_next_save() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("crash");
let state = sample_state();
save_state(&dir, &state).expect("initial save");
let tmp = state_path(&dir).with_extension("tmp");
fs::write(&tmp, "leftover-garbage").expect("write orphaned tmp");
assert!(tmp.exists());
save_state(&dir, &state).expect("second save");
assert!(!tmp.exists(), ".tmp must be gone after successful save");
assert!(state_path(&dir).exists());
}
#[test]
fn atomic_write_no_partial_state_on_serialization_error_path() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("atomicity");
let st1 = ExecutionState {
state_version: CURRENT_STATE_VERSION.to_string(),
plan_id: "original".to_string(),
registry: Registry::crates_io(),
created_at: Utc::now(),
updated_at: Utc::now(),
packages: BTreeMap::new(),
};
save_state(&dir, &st1).expect("save original");
let original_bytes = fs::read(state_path(&dir)).expect("read original");
let st2 = ExecutionState {
plan_id: "updated".to_string(),
..st1.clone()
};
save_state(&dir, &st2).expect("save updated");
let updated_bytes = fs::read(state_path(&dir)).expect("read updated");
assert_ne!(original_bytes, updated_bytes);
let loaded = load_state(&dir).expect("load").expect("exists");
assert_eq!(loaded.plan_id, "updated");
}
#[test]
fn receipt_all_fields_individually_verified_after_roundtrip() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("fields");
let fixed = Utc.with_ymd_and_hms(2025, 6, 15, 10, 30, 0).unwrap();
let finished = Utc.with_ymd_and_hms(2025, 6, 15, 10, 35, 0).unwrap();
let receipt = Receipt {
receipt_version: CURRENT_RECEIPT_VERSION.to_string(),
plan_id: "field-check-plan".to_string(),
registry: Registry {
name: "custom-registry".to_string(),
api_base: "https://custom.registry.io".to_string(),
index_base: Some("https://index.custom.registry.io".to_string()),
},
started_at: fixed,
finished_at: finished,
packages: vec![PackageReceipt {
name: "my-crate".to_string(),
version: "3.2.1".to_string(),
attempts: 2,
state: PackageState::Published,
started_at: fixed,
finished_at: finished,
duration_ms: 42_000,
evidence: shipper_types::PackageEvidence {
attempts: vec![],
readiness_checks: vec![],
},
compromised_at: None,
compromised_by: None,
superseded_by: None,
}],
event_log_path: PathBuf::from("custom/events.jsonl"),
git_context: Some(shipper_types::GitContext {
commit: Some("abc123def456".to_string()),
branch: Some("release/v3.2.1".to_string()),
tag: Some("v3.2.1".to_string()),
dirty: Some(false),
}),
environment: shipper_types::EnvironmentFingerprint {
shipper_version: "0.3.0-rc.1".to_string(),
cargo_version: Some("1.82.0".to_string()),
rust_version: Some("1.82.0".to_string()),
os: "windows".to_string(),
arch: "x86_64".to_string(),
},
};
write_receipt(&dir, &receipt).expect("write");
let loaded = load_receipt(&dir).expect("load").expect("exists");
assert_eq!(loaded.receipt_version, CURRENT_RECEIPT_VERSION);
assert_eq!(loaded.plan_id, "field-check-plan");
assert_eq!(loaded.registry.name, "custom-registry");
assert_eq!(loaded.registry.api_base, "https://custom.registry.io");
assert_eq!(
loaded.registry.index_base,
Some("https://index.custom.registry.io".to_string())
);
assert_eq!(loaded.started_at, fixed);
assert_eq!(loaded.finished_at, finished);
assert_eq!(loaded.event_log_path, PathBuf::from("custom/events.jsonl"));
assert_eq!(loaded.packages.len(), 1);
let pkg = &loaded.packages[0];
assert_eq!(pkg.name, "my-crate");
assert_eq!(pkg.version, "3.2.1");
assert_eq!(pkg.attempts, 2);
assert!(matches!(pkg.state, PackageState::Published));
assert_eq!(pkg.started_at, fixed);
assert_eq!(pkg.finished_at, finished);
assert_eq!(pkg.duration_ms, 42_000);
let ctx = loaded.git_context.expect("git_context must be Some");
assert_eq!(ctx.commit, Some("abc123def456".to_string()));
assert_eq!(ctx.branch, Some("release/v3.2.1".to_string()));
assert_eq!(ctx.tag, Some("v3.2.1".to_string()));
assert_eq!(ctx.dirty, Some(false));
assert_eq!(loaded.environment.shipper_version, "0.3.0-rc.1");
assert_eq!(loaded.environment.cargo_version, Some("1.82.0".to_string()));
assert_eq!(loaded.environment.rust_version, Some("1.82.0".to_string()));
assert_eq!(loaded.environment.os, "windows");
assert_eq!(loaded.environment.arch, "x86_64");
}
#[test]
fn receipt_with_git_context_roundtrip() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("git");
let receipt = Receipt {
git_context: Some(shipper_types::GitContext {
commit: Some("deadbeef".to_string()),
branch: Some("main".to_string()),
tag: None,
dirty: Some(true),
}),
..sample_receipt()
};
write_receipt(&dir, &receipt).expect("write");
let loaded = load_receipt(&dir).expect("load").expect("exists");
let ctx = loaded.git_context.expect("git_context must be Some");
assert_eq!(ctx.commit, Some("deadbeef".to_string()));
assert_eq!(ctx.branch, Some("main".to_string()));
assert!(ctx.tag.is_none());
assert_eq!(ctx.dirty, Some(true));
}
#[test]
fn receipt_with_evidence_data_roundtrip() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("evidence");
let fixed = Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap();
let receipt = Receipt {
packages: vec![PackageReceipt {
name: "evi-crate".to_string(),
version: "1.0.0".to_string(),
attempts: 2,
state: PackageState::Published,
started_at: fixed,
finished_at: fixed,
duration_ms: 5000,
evidence: shipper_types::PackageEvidence {
attempts: vec![
shipper_types::AttemptEvidence {
attempt_number: 1,
command: "cargo publish -p evi-crate".to_string(),
exit_code: 1,
stdout_tail: "".to_string(),
stderr_tail: "error: network timeout".to_string(),
timestamp: fixed,
duration: std::time::Duration::from_secs(3),
},
shipper_types::AttemptEvidence {
attempt_number: 2,
command: "cargo publish -p evi-crate".to_string(),
exit_code: 0,
stdout_tail: "Uploading evi-crate v1.0.0".to_string(),
stderr_tail: "".to_string(),
timestamp: fixed,
duration: std::time::Duration::from_secs(2),
},
],
readiness_checks: vec![shipper_types::ReadinessEvidence {
attempt: 1,
visible: true,
timestamp: fixed,
delay_before: std::time::Duration::from_millis(500),
}],
},
compromised_at: None,
compromised_by: None,
superseded_by: None,
}],
..sample_receipt()
};
write_receipt(&dir, &receipt).expect("write");
let loaded = load_receipt(&dir).expect("load").expect("exists");
let pkg = &loaded.packages[0];
assert_eq!(pkg.evidence.attempts.len(), 2);
assert_eq!(pkg.evidence.attempts[0].attempt_number, 1);
assert_eq!(pkg.evidence.attempts[0].exit_code, 1);
assert_eq!(
pkg.evidence.attempts[0].stderr_tail,
"error: network timeout"
);
assert_eq!(pkg.evidence.attempts[1].attempt_number, 2);
assert_eq!(pkg.evidence.attempts[1].exit_code, 0);
assert_eq!(
pkg.evidence.attempts[1].stdout_tail,
"Uploading evi-crate v1.0.0"
);
assert_eq!(pkg.evidence.readiness_checks.len(), 1);
assert!(pkg.evidence.readiness_checks[0].visible);
assert_eq!(pkg.evidence.readiness_checks[0].attempt, 1);
}
#[test]
fn migrate_v1_receipt_with_packages_populated() {
let td = tempdir().expect("tempdir");
let path = td.path().join("receipt.json");
let v1_json = r#"{
"receipt_version": "shipper.receipt.v1",
"plan_id": "migrate-with-pkgs",
"registry": {
"name": "crates-io",
"api_base": "https://crates.io",
"index_base": "https://index.crates.io"
},
"started_at": "2024-01-01T00:00:00Z",
"finished_at": "2024-01-01T01:00:00Z",
"packages": [
{
"name": "core",
"version": "1.0.0",
"attempts": 1,
"state": {"state": "published"},
"started_at": "2024-01-01T00:00:00Z",
"finished_at": "2024-01-01T00:30:00Z",
"duration_ms": 1800000,
"evidence": {"attempts": [], "readiness_checks": []}
},
{
"name": "utils",
"version": "0.5.0",
"attempts": 2,
"state": {"state": "failed", "class": "retryable", "message": "timeout"},
"started_at": "2024-01-01T00:30:00Z",
"finished_at": "2024-01-01T01:00:00Z",
"duration_ms": 1800000,
"evidence": {"attempts": [], "readiness_checks": []}
}
],
"event_log_path": ".shipper/events.jsonl"
}"#;
fs::write(&path, v1_json).expect("write v1");
let receipt = migrate_receipt(&path).expect("migrate");
assert_eq!(receipt.receipt_version, CURRENT_RECEIPT_VERSION);
assert_eq!(receipt.plan_id, "migrate-with-pkgs");
assert_eq!(receipt.packages.len(), 2);
assert_eq!(receipt.packages[0].name, "core");
assert!(matches!(receipt.packages[0].state, PackageState::Published));
assert_eq!(receipt.packages[1].name, "utils");
assert!(matches!(
receipt.packages[1].state,
PackageState::Failed { .. }
));
assert!(receipt.git_context.is_none());
assert!(!receipt.environment.shipper_version.is_empty());
}
#[test]
fn state_version_field_present_in_serialized_json() {
let state = sample_state();
let json = serde_json::to_string(&state).expect("serialize");
let value: serde_json::Value = serde_json::from_str(&json).expect("parse");
assert_eq!(
value.get("state_version").and_then(|v| v.as_str()),
Some(CURRENT_STATE_VERSION)
);
}
#[test]
fn receipt_version_field_present_in_serialized_json() {
let receipt = sample_receipt();
let json = serde_json::to_string(&receipt).expect("serialize");
let value: serde_json::Value = serde_json::from_str(&json).expect("parse");
assert_eq!(
value.get("receipt_version").and_then(|v| v.as_str()),
Some(CURRENT_RECEIPT_VERSION)
);
}
#[test]
fn load_receipt_returns_none_for_nonexistent_directory() {
let td = tempdir().expect("tempdir");
let missing = td.path().join("does-not-exist");
let loaded = load_receipt(&missing).expect("load");
assert!(loaded.is_none());
}
#[test]
fn receipt_v0_rejected_by_validation() {
let result = validate_receipt_version("shipper.receipt.v0");
assert!(result.is_err());
let msg = format!("{:#}", result.unwrap_err());
assert!(msg.contains("too old"), "unexpected error: {msg}");
}
#[test]
fn concurrent_readers_all_see_consistent_state() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("conc-read");
let mut pkgs = BTreeMap::new();
for i in 0..5 {
pkgs.insert(
format!("pkg-{i}@1.0.0"),
PackageProgress {
name: format!("pkg-{i}"),
version: "1.0.0".to_string(),
attempts: 1,
state: PackageState::Published,
last_updated_at: Utc::now(),
},
);
}
let state = ExecutionState {
state_version: CURRENT_STATE_VERSION.to_string(),
plan_id: "concurrent-plan".to_string(),
registry: Registry::crates_io(),
created_at: Utc::now(),
updated_at: Utc::now(),
packages: pkgs,
};
save_state(&dir, &state).expect("save");
let dir = std::sync::Arc::new(dir);
let handles: Vec<_> = (0..8)
.map(|_| {
let d = std::sync::Arc::clone(&dir);
std::thread::spawn(move || {
let loaded = load_state(&d).unwrap().unwrap();
assert_eq!(loaded.plan_id, "concurrent-plan");
assert_eq!(loaded.packages.len(), 5);
})
})
.collect();
for h in handles {
h.join().expect("thread must not panic");
}
}
#[test]
fn sequential_writer_reader_pattern() {
let td = tempdir().expect("tempdir");
let dir = td.path().join("seq-wr");
for i in 0..10 {
let mut pkgs = BTreeMap::new();
pkgs.insert(
"crate@1.0.0".to_string(),
PackageProgress {
name: "crate".to_string(),
version: "1.0.0".to_string(),
attempts: i,
state: if i < 5 {
PackageState::Pending
} else {
PackageState::Published
},
last_updated_at: Utc::now(),
},
);
let state = ExecutionState {
state_version: CURRENT_STATE_VERSION.to_string(),
plan_id: format!("plan-{i}"),
registry: Registry::crates_io(),
created_at: Utc::now(),
updated_at: Utc::now(),
packages: pkgs,
};
save_state(&dir, &state).expect("save");
let loaded = load_state(&dir).expect("load").expect("exists");
assert_eq!(loaded.plan_id, format!("plan-{i}"));
assert_eq!(loaded.packages["crate@1.0.0"].attempts, i);
}
}
#[test]
fn concurrent_writer_then_readers() {
let td = tempdir().expect("tempdir");
let dir = std::sync::Arc::new(td.path().join("conc-wr"));
let wd = std::sync::Arc::clone(&dir);
let writer = std::thread::spawn(move || {
for i in 0..5 {
let state = ExecutionState {
state_version: CURRENT_STATE_VERSION.to_string(),
plan_id: format!("w-plan-{i}"),
registry: Registry::crates_io(),
created_at: Utc::now(),
updated_at: Utc::now(),
packages: BTreeMap::new(),
};
save_state(&wd, &state).expect("save");
}
});
writer.join().expect("writer done");
let handles: Vec<_> = (0..4)
.map(|_| {
let d = std::sync::Arc::clone(&dir);
std::thread::spawn(move || {
let loaded = load_state(&d).unwrap().unwrap();
assert_eq!(loaded.plan_id, "w-plan-4");
})
})
.collect();
for h in handles {
h.join().expect("reader must not panic");
}
}
#[test]
fn each_package_state_variant_disk_roundtrip() {
let td = tempdir().expect("tempdir");
let variants: Vec<(&str, PackageState)> = vec![
("pending", PackageState::Pending),
("uploaded", PackageState::Uploaded),
("published", PackageState::Published),
(
"skipped",
PackageState::Skipped {
reason: "already on registry".to_string(),
},
),
(
"failed-retryable",
PackageState::Failed {
class: shipper_types::ErrorClass::Retryable,
message: "network error".to_string(),
},
),
(
"failed-permanent",
PackageState::Failed {
class: shipper_types::ErrorClass::Permanent,
message: "unauthorized".to_string(),
},
),
(
"failed-ambiguous",
PackageState::Failed {
class: shipper_types::ErrorClass::Ambiguous,
message: "502 bad gateway".to_string(),
},
),
(
"ambiguous",
PackageState::Ambiguous {
message: "registry did not respond".to_string(),
},
),
];
for (label, pkg_state) in &variants {
let dir = td.path().join(format!("variant-{label}"));
let mut pkgs = BTreeMap::new();
pkgs.insert(
format!("{label}@1.0.0"),
PackageProgress {
name: label.to_string(),
version: "1.0.0".to_string(),
attempts: 1,
state: pkg_state.clone(),
last_updated_at: Utc::now(),
},
);
let state = ExecutionState {
state_version: CURRENT_STATE_VERSION.to_string(),
plan_id: format!("{label}-plan"),
registry: Registry::crates_io(),
created_at: Utc::now(),
updated_at: Utc::now(),
packages: pkgs,
};
save_state(&dir, &state).expect("save");
let loaded = load_state(&dir).expect("load").expect("exists");
let key = format!("{label}@1.0.0");
assert_eq!(
&loaded.packages[&key].state, pkg_state,
"state mismatch for variant {label}"
);
}
}
#[test]
fn snapshot_receipt_with_git_context() {
let fixed = Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap();
let finished = Utc.with_ymd_and_hms(2025, 1, 15, 12, 5, 0).unwrap();
let receipt = Receipt {
receipt_version: CURRENT_RECEIPT_VERSION.to_string(),
plan_id: "git-ctx-plan".to_string(),
registry: Registry::crates_io(),
started_at: fixed,
finished_at: finished,
packages: vec![PackageReceipt {
name: "my-lib".to_string(),
version: "2.0.0".to_string(),
attempts: 1,
state: PackageState::Published,
started_at: fixed,
finished_at: finished,
duration_ms: 60_000,
evidence: shipper_types::PackageEvidence {
attempts: vec![],
readiness_checks: vec![],
},
compromised_at: None,
compromised_by: None,
superseded_by: None,
}],
event_log_path: PathBuf::from(".shipper/events.jsonl"),
git_context: Some(shipper_types::GitContext {
commit: Some("a1b2c3d4e5f6".to_string()),
branch: Some("release/v2.0.0".to_string()),
tag: Some("v2.0.0".to_string()),
dirty: Some(false),
}),
environment: shipper_types::EnvironmentFingerprint {
shipper_version: "0.3.0".to_string(),
cargo_version: Some("1.82.0".to_string()),
rust_version: Some("1.82.0".to_string()),
os: "linux".to_string(),
arch: "x86_64".to_string(),
},
};
let json = serde_json::to_string_pretty(&receipt).expect("serialize");
insta::assert_snapshot!("receipt_with_git_context", json);
}
#[test]
fn snapshot_receipt_with_evidence() {
let fixed = Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap();
let finished = Utc.with_ymd_and_hms(2025, 1, 15, 12, 5, 0).unwrap();
let receipt = Receipt {
receipt_version: CURRENT_RECEIPT_VERSION.to_string(),
plan_id: "evidence-plan".to_string(),
registry: Registry::crates_io(),
started_at: fixed,
finished_at: finished,
packages: vec![PackageReceipt {
name: "retried-crate".to_string(),
version: "1.0.0".to_string(),
attempts: 2,
state: PackageState::Published,
started_at: fixed,
finished_at: finished,
duration_ms: 8_000,
evidence: shipper_types::PackageEvidence {
attempts: vec![
shipper_types::AttemptEvidence {
attempt_number: 1,
command: "cargo publish -p retried-crate".to_string(),
exit_code: 1,
stdout_tail: "".to_string(),
stderr_tail: "error: network timeout".to_string(),
timestamp: fixed,
duration: std::time::Duration::from_secs(3),
},
shipper_types::AttemptEvidence {
attempt_number: 2,
command: "cargo publish -p retried-crate".to_string(),
exit_code: 0,
stdout_tail: "Uploading retried-crate v1.0.0".to_string(),
stderr_tail: "".to_string(),
timestamp: fixed,
duration: std::time::Duration::from_secs(2),
},
],
readiness_checks: vec![shipper_types::ReadinessEvidence {
attempt: 1,
visible: true,
timestamp: fixed,
delay_before: std::time::Duration::from_millis(500),
}],
},
compromised_at: None,
compromised_by: None,
superseded_by: None,
}],
event_log_path: PathBuf::from(".shipper/events.jsonl"),
git_context: None,
environment: shipper_types::EnvironmentFingerprint {
shipper_version: "0.3.0".to_string(),
cargo_version: Some("1.82.0".to_string()),
rust_version: Some("1.82.0".to_string()),
os: "linux".to_string(),
arch: "x86_64".to_string(),
},
};
let json = serde_json::to_string_pretty(&receipt).expect("serialize");
insta::assert_snapshot!("receipt_with_evidence", json);
}
#[test]
fn snapshot_state_empty_packages() {
let fixed = Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap();
let state = ExecutionState {
state_version: CURRENT_STATE_VERSION.to_string(),
plan_id: "empty-plan".to_string(),
registry: Registry::crates_io(),
created_at: fixed,
updated_at: fixed,
packages: BTreeMap::new(),
};
let json = serde_json::to_string_pretty(&state).expect("serialize");
insta::assert_snapshot!("state_empty_packages", json);
}
fn normalize_error(err: &str, state_dir: &std::path::Path) -> String {
err.replace(&state_dir.display().to_string(), "<STATE_DIR>")
.replace('\\', "/")
}
#[test]
fn snapshot_error_message_corrupted_state_json() {
let td = tempfile::tempdir().expect("tempdir");
let state_dir = td.path();
std::fs::create_dir_all(state_dir).unwrap();
std::fs::write(state_dir.join(STATE_FILE), "{ not valid json }").unwrap();
let err = load_state(state_dir).unwrap_err();
insta::assert_snapshot!(
"error_msg_corrupted_state_json",
normalize_error(&format!("{err:#}"), state_dir)
);
}
#[test]
fn snapshot_error_message_truncated_state_json() {
let td = tempfile::tempdir().expect("tempdir");
let state_dir = td.path();
std::fs::create_dir_all(state_dir).unwrap();
std::fs::write(state_dir.join(STATE_FILE), r#"{"plan_id": "abc""#).unwrap();
let err = load_state(state_dir).unwrap_err();
insta::assert_snapshot!(
"error_msg_truncated_state_json",
normalize_error(&format!("{err:#}"), state_dir)
);
}
#[test]
fn snapshot_error_message_empty_state_file() {
let td = tempfile::tempdir().expect("tempdir");
let state_dir = td.path();
std::fs::create_dir_all(state_dir).unwrap();
std::fs::write(state_dir.join(STATE_FILE), "").unwrap();
let err = load_state(state_dir).unwrap_err();
insta::assert_snapshot!(
"error_msg_empty_state_file",
normalize_error(&format!("{err:#}"), state_dir)
);
}
#[test]
fn snapshot_error_message_receipt_version_too_old() {
let err = validate_receipt_version("shipper.receipt.v0").unwrap_err();
insta::assert_snapshot!("error_msg_receipt_version_too_old", err.to_string());
}
#[test]
fn snapshot_error_message_receipt_version_invalid_format() {
let err = validate_receipt_version("not-a-version").unwrap_err();
insta::assert_snapshot!("error_msg_receipt_version_invalid_format", err.to_string());
}
#[test]
fn snapshot_error_message_corrupted_receipt_json() {
let td = tempfile::tempdir().expect("tempdir");
let state_dir = td.path();
std::fs::create_dir_all(state_dir).unwrap();
std::fs::write(state_dir.join(RECEIPT_FILE), "not json at all").unwrap();
let err = load_receipt(state_dir).unwrap_err();
insta::assert_snapshot!(
"error_msg_corrupted_receipt_json",
normalize_error(&format!("{err:#}"), state_dir)
);
}
mod proptests_extended {
use super::*;
use proptest::prelude::*;
fn arb_error_class() -> impl Strategy<Value = shipper_types::ErrorClass> {
prop_oneof![
Just(shipper_types::ErrorClass::Retryable),
Just(shipper_types::ErrorClass::Permanent),
Just(shipper_types::ErrorClass::Ambiguous),
]
}
fn arb_package_state() -> impl Strategy<Value = PackageState> {
prop_oneof![
Just(PackageState::Pending),
Just(PackageState::Uploaded),
Just(PackageState::Published),
"\\PC{1,50}".prop_map(|reason| PackageState::Skipped { reason }),
(arb_error_class(), "\\PC{1,50}")
.prop_map(|(class, message)| PackageState::Failed { class, message }),
"\\PC{1,50}".prop_map(|message| PackageState::Ambiguous { message }),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(30))]
#[test]
fn error_class_json_roundtrip(class in arb_error_class()) {
let json = serde_json::to_string(&class).expect("serialize");
let deser: shipper_types::ErrorClass =
serde_json::from_str(&json).expect("deserialize");
prop_assert_eq!(class, deser);
}
#[test]
fn package_state_disk_roundtrip(pkg_state in arb_package_state()) {
let td = tempfile::tempdir().expect("tempdir");
let dir = td.path().join("proptest-variant");
let mut pkgs = BTreeMap::new();
pkgs.insert(
"test@1.0.0".to_string(),
PackageProgress {
name: "test".to_string(),
version: "1.0.0".to_string(),
attempts: 1,
state: pkg_state.clone(),
last_updated_at: chrono::DateTime::from_timestamp(1_700_000_000, 0)
.unwrap_or_default(),
},
);
let state = ExecutionState {
state_version: CURRENT_STATE_VERSION.to_string(),
plan_id: "pt".to_string(),
registry: Registry::crates_io(),
created_at: chrono::DateTime::from_timestamp(1_700_000_000, 0)
.unwrap_or_default(),
updated_at: chrono::DateTime::from_timestamp(1_700_000_000, 0)
.unwrap_or_default(),
packages: pkgs,
};
save_state(&dir, &state).expect("save");
let loaded = load_state(&dir).expect("load").expect("exists");
prop_assert_eq!(&pkg_state, &loaded.packages["test@1.0.0"].state);
}
#[test]
fn receipt_with_arbitrary_packages_disk_roundtrip(
states in proptest::collection::vec(arb_package_state(), 1..8)
) {
let td = tempfile::tempdir().expect("tempdir");
let dir = td.path().join("proptest-receipt");
let fixed = chrono::DateTime::from_timestamp(1_700_000_000, 0)
.unwrap_or_default();
let packages: Vec<PackageReceipt> = states
.iter()
.enumerate()
.map(|(i, st)| PackageReceipt {
name: format!("crate-{i}"),
version: "1.0.0".to_string(),
attempts: 1,
state: st.clone(),
started_at: fixed,
finished_at: fixed,
duration_ms: 100,
evidence: shipper_types::PackageEvidence {
attempts: vec![],
readiness_checks: vec![],
},
compromised_at: None,
compromised_by: None,
superseded_by: None,
})
.collect();
let receipt = Receipt {
receipt_version: CURRENT_RECEIPT_VERSION.to_string(),
plan_id: "pt-receipt".to_string(),
registry: Registry::crates_io(),
started_at: fixed,
finished_at: fixed,
packages,
event_log_path: PathBuf::from(".shipper/events.jsonl"),
git_context: None,
environment: shipper_types::EnvironmentFingerprint {
shipper_version: "0.1.0".to_string(),
cargo_version: Some("1.80.0".to_string()),
rust_version: Some("1.80.0".to_string()),
os: "test".to_string(),
arch: "x86_64".to_string(),
},
};
write_receipt(&dir, &receipt).expect("write");
let loaded = load_receipt(&dir).expect("load").expect("exists");
prop_assert_eq!(receipt.packages.len(), loaded.packages.len());
for (orig, ld) in receipt.packages.iter().zip(loaded.packages.iter()) {
prop_assert_eq!(&orig.name, &ld.name);
prop_assert_eq!(&orig.state, &ld.state);
}
}
}
}