1use std::collections::BTreeMap;
25use std::path::PathBuf;
26use std::time::Duration;
27
28use chrono::{DateTime, Utc};
29use serde::{Deserialize, Serialize};
30use serde_with::{DurationMilliSeconds, serde_as};
31
32pub use shipper_duration::{deserialize_duration, serialize_duration};
33use shipper_encrypt::EncryptionConfig as EncryptionSettings;
34use shipper_webhook::WebhookConfig;
35
36pub mod storage;
37
38pub mod schema;
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct Registry {
67 pub name: String,
69 pub api_base: String,
71 #[serde(skip_serializing_if = "Option::is_none")]
74 pub index_base: Option<String>,
75}
76
77impl Registry {
78 pub fn crates_io() -> Self {
100 Self {
101 name: "crates-io".to_string(),
102 api_base: "https://crates.io".to_string(),
103 index_base: Some("https://index.crates.io".to_string()),
104 }
105 }
106
107 pub fn get_index_base(&self) -> String {
110 if let Some(index_base) = &self.index_base {
111 index_base
112 .strip_prefix("sparse+")
113 .unwrap_or(index_base)
114 .to_string()
115 } else {
116 self.api_base
118 .replace("https://", "https://index.")
119 .replace("http://", "http://index.")
120 }
121 }
122}
123
124#[derive(Debug, Clone)]
155pub struct ReleaseSpec {
156 pub manifest_path: PathBuf,
158 pub registry: Registry,
160 pub selected_packages: Option<Vec<String>>,
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
192#[serde(rename_all = "snake_case")]
193pub enum PublishPolicy {
194 #[default]
201 Safe,
202 Balanced,
207 Fast,
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
234#[serde(rename_all = "snake_case")]
235pub enum VerifyMode {
236 #[default]
241 Workspace,
242 Package,
247 None,
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
280#[serde(rename_all = "snake_case")]
281pub enum ReadinessMethod {
282 #[default]
287 Api,
288 Index,
293 Both,
298}
299
300#[serde_as]
339#[derive(Debug, Clone, Serialize, Deserialize)]
340#[serde(default)]
341pub struct ReadinessConfig {
342 pub enabled: bool,
347 pub method: ReadinessMethod,
349 #[serde(
355 deserialize_with = "deserialize_duration",
356 serialize_with = "serialize_duration"
357 )]
358 pub initial_delay: Duration,
359 #[serde(
364 deserialize_with = "deserialize_duration",
365 serialize_with = "serialize_duration"
366 )]
367 pub max_delay: Duration,
368 #[serde(
373 deserialize_with = "deserialize_duration",
374 serialize_with = "serialize_duration"
375 )]
376 pub max_total_wait: Duration,
377 #[serde(
382 deserialize_with = "deserialize_duration",
383 serialize_with = "serialize_duration"
384 )]
385 pub poll_interval: Duration,
386 pub jitter_factor: f64,
392 #[serde(skip_serializing_if = "Option::is_none")]
397 pub index_path: Option<PathBuf>,
398 #[serde(default)]
403 pub prefer_index: bool,
404}
405
406impl Default for ReadinessConfig {
407 fn default() -> Self {
408 Self {
409 enabled: true,
410 method: ReadinessMethod::Api,
411 initial_delay: Duration::from_secs(1),
412 max_delay: Duration::from_secs(60),
413 max_total_wait: Duration::from_secs(300), poll_interval: Duration::from_secs(2),
415 jitter_factor: 0.5,
416 index_path: None,
417 prefer_index: false,
418 }
419 }
420}
421
422#[derive(Debug, Clone, Serialize, Deserialize)]
458#[serde(default)]
459pub struct ParallelConfig {
460 pub enabled: bool,
466 pub max_concurrent: usize,
471 #[serde(
477 deserialize_with = "deserialize_duration",
478 serialize_with = "serialize_duration"
479 )]
480 pub per_package_timeout: Duration,
481}
482
483impl Default for ParallelConfig {
484 fn default() -> Self {
485 Self {
486 enabled: false,
487 max_concurrent: 4,
488 per_package_timeout: Duration::from_secs(1800), }
490 }
491}
492
493#[derive(Debug, Clone)]
533pub struct RuntimeOptions {
534 pub allow_dirty: bool,
536 pub skip_ownership_check: bool,
538 pub strict_ownership: bool,
540 pub no_verify: bool,
542 pub max_attempts: u32,
544 pub base_delay: Duration,
546 pub max_delay: Duration,
548 pub retry_strategy: shipper_retry::RetryStrategyType,
550 pub retry_jitter: f64,
552 pub retry_per_error: shipper_retry::PerErrorConfig,
554 pub verify_timeout: Duration,
556 pub verify_poll_interval: Duration,
558 pub state_dir: PathBuf,
560 pub force_resume: bool,
562 pub policy: PublishPolicy,
564 pub verify_mode: VerifyMode,
566 pub readiness: ReadinessConfig,
568 pub output_lines: usize,
570 pub force: bool,
572 pub lock_timeout: Duration,
574 pub parallel: ParallelConfig,
576 pub webhook: WebhookConfig,
578 pub encryption: EncryptionSettings,
580 pub registries: Vec<Registry>,
582 pub resume_from: Option<String>,
584 pub rehearsal_registry: Option<String>,
591 pub rehearsal_skip: bool,
596 pub rehearsal_smoke_install: Option<String>,
603}
604
605#[derive(Debug, Clone, Serialize, Deserialize)]
624pub struct PlannedPackage {
625 pub name: String,
626 pub version: String,
627 pub manifest_path: PathBuf,
628}
629
630#[derive(Debug, Clone, Serialize, Deserialize)]
664pub struct PublishLevel {
665 pub level: usize,
667 pub packages: Vec<PlannedPackage>,
669}
670
671#[derive(Debug, Clone, Serialize, Deserialize)]
696pub struct ReleasePlan {
697 pub plan_version: String,
698 pub plan_id: String,
699 pub created_at: DateTime<Utc>,
700 pub registry: Registry,
701 pub packages: Vec<PlannedPackage>,
703 #[serde(default)]
706 pub dependencies: BTreeMap<String, Vec<String>>,
707}
708
709#[derive(Debug, Clone, Serialize, Deserialize)]
714pub struct SkippedPackage {
715 pub name: String,
717 pub version: String,
719 pub reason: String,
721}
722
723#[derive(Debug, Clone)]
728pub struct PlannedWorkspace {
729 pub workspace_root: PathBuf,
731 pub plan: ReleasePlan,
733 pub skipped: Vec<SkippedPackage>,
735}
736
737impl ReleasePlan {
738 pub fn group_by_levels(&self) -> Vec<PublishLevel> {
743 group_packages_by_levels(&self.packages, |pkg| pkg.name.as_str(), &self.dependencies)
744 .into_iter()
745 .map(|l| PublishLevel {
746 level: l.level,
747 packages: l.packages,
748 })
749 .collect()
750 }
751}
752
753#[derive(Debug, Clone, PartialEq, Eq)]
757pub struct GenericPublishLevel<T> {
758 pub level: usize,
760 pub packages: Vec<T>,
762}
763
764pub fn group_packages_by_levels<T, F>(
771 ordered_packages: &[T],
772 package_name: F,
773 dependencies: &BTreeMap<String, Vec<String>>,
774) -> Vec<GenericPublishLevel<T>>
775where
776 T: Clone,
777 F: Fn(&T) -> &str,
778{
779 use std::collections::BTreeSet;
780
781 let mut ordered_names: Vec<String> = Vec::new();
782 let mut package_lookup: BTreeMap<String, T> = BTreeMap::new();
783
784 for package in ordered_packages {
785 let name = package_name(package).to_string();
786 if package_lookup.contains_key(&name) {
787 continue;
788 }
789 ordered_names.push(name.clone());
790 package_lookup.insert(name, package.clone());
791 }
792
793 if ordered_names.is_empty() {
794 return Vec::new();
795 }
796
797 let package_set: BTreeSet<String> = ordered_names.iter().cloned().collect();
798 let mut indegree: BTreeMap<String, usize> = package_set
799 .iter()
800 .map(|name| (name.clone(), 0usize))
801 .collect();
802 let mut dependents: BTreeMap<String, Vec<String>> = BTreeMap::new();
803
804 for name in &ordered_names {
805 if let Some(deps) = dependencies.get(name) {
806 for dep in deps {
807 if !package_set.contains(dep) {
808 continue;
809 }
810 if let Some(degree) = indegree.get_mut(name) {
811 *degree += 1;
812 }
813 dependents
814 .entry(dep.clone())
815 .or_default()
816 .push(name.clone());
817 }
818 }
819 }
820
821 let mut remaining: BTreeSet<String> = package_set;
822 let mut levels: Vec<GenericPublishLevel<T>> = Vec::new();
823
824 while !remaining.is_empty() {
825 let mut current: Vec<String> = ordered_names
826 .iter()
827 .filter(|name| {
828 remaining.contains(*name) && indegree.get(*name).copied().unwrap_or(0) == 0
829 })
830 .cloned()
831 .collect();
832
833 if current.is_empty() {
834 if let Some(name) = ordered_names
835 .iter()
836 .find(|name| remaining.contains(*name))
837 .cloned()
838 {
839 current.push(name);
840 } else {
841 break;
842 }
843 }
844
845 let packages = current
846 .iter()
847 .filter_map(|name| package_lookup.get(name).cloned())
848 .collect();
849
850 levels.push(GenericPublishLevel {
851 level: levels.len(),
852 packages,
853 });
854
855 for name in current {
856 remaining.remove(&name);
857 if let Some(children) = dependents.get(&name) {
858 for child in children {
859 if !remaining.contains(child) {
860 continue;
861 }
862 if let Some(degree) = indegree.get_mut(child) {
863 *degree = degree.saturating_sub(1);
864 }
865 }
866 }
867 }
868 }
869
870 levels
871}
872
873#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
915#[serde(tag = "state", rename_all = "snake_case")]
916pub enum PackageState {
917 Pending,
918 Uploaded,
919 Published,
920 Skipped { reason: String },
921 Failed { class: ErrorClass, message: String },
922 Ambiguous { message: String },
923}
924
925#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
977#[serde(rename_all = "snake_case")]
978pub enum ErrorClass {
979 Retryable,
980 Permanent,
981 Ambiguous,
982}
983
984#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
999pub struct StateEventDrift {
1000 pub in_events_only: Vec<String>,
1006 pub in_state_only: Vec<String>,
1012}
1013
1014impl StateEventDrift {
1015 pub fn is_consistent(&self) -> bool {
1017 self.in_events_only.is_empty() && self.in_state_only.is_empty()
1018 }
1019}
1020
1021#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1030#[serde(tag = "outcome", rename_all = "snake_case")]
1031pub enum ReconciliationOutcome {
1032 Published { attempts: u32, elapsed_ms: u64 },
1035 NotPublished { attempts: u32, elapsed_ms: u64 },
1039 StillUnknown {
1044 attempts: u32,
1045 elapsed_ms: u64,
1046 reason: String,
1047 },
1048}
1049
1050#[derive(Debug, Clone, Serialize, Deserialize)]
1071pub struct PackageProgress {
1072 pub name: String,
1073 pub version: String,
1074 pub attempts: u32,
1075 pub state: PackageState,
1076 pub last_updated_at: DateTime<Utc>,
1077}
1078
1079#[derive(Debug, Clone, Serialize, Deserialize)]
1109pub struct ExecutionState {
1110 pub state_version: String,
1111 pub plan_id: String,
1112 pub registry: Registry,
1113 pub created_at: DateTime<Utc>,
1114 pub updated_at: DateTime<Utc>,
1115 pub packages: BTreeMap<String, PackageProgress>,
1116}
1117
1118#[derive(Debug, Clone, Serialize, Deserialize)]
1148pub struct PackageReceipt {
1149 pub name: String,
1150 pub version: String,
1151 pub attempts: u32,
1152 pub state: PackageState,
1153 pub started_at: DateTime<Utc>,
1154 pub finished_at: DateTime<Utc>,
1155 pub duration_ms: u128,
1156 pub evidence: PackageEvidence,
1157
1158 #[serde(default, skip_serializing_if = "Option::is_none")]
1167 pub compromised_at: Option<DateTime<Utc>>,
1168 #[serde(default, skip_serializing_if = "Option::is_none")]
1172 pub compromised_by: Option<String>,
1173 #[serde(default, skip_serializing_if = "Option::is_none")]
1177 pub superseded_by: Option<String>,
1178}
1179
1180#[derive(Debug, Clone, Serialize, Deserialize)]
1190pub struct PackageEvidence {
1191 pub attempts: Vec<AttemptEvidence>,
1192 pub readiness_checks: Vec<ReadinessEvidence>,
1193}
1194
1195#[serde_as]
1218#[derive(Debug, Clone, Serialize, Deserialize)]
1219pub struct AttemptEvidence {
1220 pub attempt_number: u32,
1221 pub command: String,
1222 pub exit_code: i32,
1223 pub stdout_tail: String,
1224 pub stderr_tail: String,
1225 pub timestamp: DateTime<Utc>,
1226 #[serde_as(as = "DurationMilliSeconds<u64>")]
1227 pub duration: Duration,
1228}
1229
1230#[serde_as]
1249#[derive(Debug, Clone, Serialize, Deserialize)]
1250pub struct ReadinessEvidence {
1251 pub attempt: u32,
1252 pub visible: bool,
1253 pub timestamp: DateTime<Utc>,
1254 #[serde_as(as = "DurationMilliSeconds<u64>")]
1255 pub delay_before: Duration,
1256}
1257
1258#[derive(Debug, Clone, Serialize, Deserialize)]
1277pub struct EnvironmentFingerprint {
1278 pub shipper_version: String,
1279 pub cargo_version: Option<String>,
1280 pub rust_version: Option<String>,
1281 pub os: String,
1282 pub arch: String,
1283}
1284
1285#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1303pub struct GitContext {
1304 pub commit: Option<String>,
1305 pub branch: Option<String>,
1306 pub tag: Option<String>,
1307 pub dirty: Option<bool>,
1308}
1309
1310impl GitContext {
1311 pub fn new() -> Self {
1313 Self::default()
1314 }
1315
1316 pub fn has_commit(&self) -> bool {
1318 self.commit.is_some()
1319 }
1320
1321 pub fn is_dirty(&self) -> bool {
1326 self.dirty.unwrap_or(true)
1327 }
1328
1329 pub fn short_commit(&self) -> Option<&str> {
1335 self.commit
1336 .as_ref()
1337 .map(|c| if c.len() > 7 { &c[..7] } else { c.as_str() })
1338 }
1339}
1340
1341#[derive(Debug, Clone, Serialize, Deserialize)]
1381pub struct Receipt {
1382 pub receipt_version: String,
1383 pub plan_id: String,
1384 pub registry: Registry,
1385 pub started_at: DateTime<Utc>,
1386 pub finished_at: DateTime<Utc>,
1387 pub packages: Vec<PackageReceipt>,
1388 pub event_log_path: PathBuf,
1389 #[serde(default)]
1390 pub git_context: Option<GitContext>,
1391 pub environment: EnvironmentFingerprint,
1392}
1393
1394#[derive(Debug, Clone, Serialize, Deserialize)]
1414pub struct PublishEvent {
1415 pub timestamp: DateTime<Utc>,
1416 pub event_type: EventType,
1417 pub package: String, }
1419
1420#[derive(Debug, Clone, Serialize, Deserialize)]
1468#[serde(tag = "type", rename_all = "snake_case")]
1469pub enum EventType {
1470 PlanCreated {
1472 plan_id: String,
1473 package_count: usize,
1474 },
1475 ExecutionStarted,
1476 ExecutionFinished {
1477 result: ExecutionResult,
1478 },
1479
1480 PackageStarted {
1482 name: String,
1483 version: String,
1484 },
1485 PackageAttempted {
1486 attempt: u32,
1487 command: String,
1488 },
1489 PackageOutput {
1490 stdout_tail: String,
1491 stderr_tail: String,
1492 },
1493 PackagePublished {
1494 duration_ms: u64,
1495 },
1496 PackageFailed {
1497 class: ErrorClass,
1498 message: String,
1499 },
1500 PackageSkipped {
1501 reason: String,
1502 },
1503
1504 PublishReconciling {
1506 method: ReadinessMethod,
1507 },
1508 PublishReconciled {
1509 outcome: ReconciliationOutcome,
1510 },
1511
1512 StateEventDriftDetected {
1514 drift: StateEventDrift,
1515 },
1516
1517 PackageYanked {
1522 crate_name: String,
1523 version: String,
1524 reason: String,
1525 exit_code: i32,
1526 },
1527
1528 RehearsalStarted {
1539 registry: String,
1540 plan_id: String,
1541 package_count: usize,
1542 },
1543 RehearsalPackagePublished {
1544 name: String,
1545 version: String,
1546 duration_ms: u128,
1547 },
1548 RehearsalPackageFailed {
1549 name: String,
1550 version: String,
1551 class: ErrorClass,
1552 message: String,
1553 },
1554 RehearsalComplete {
1555 passed: bool,
1556 registry: String,
1557 plan_id: String,
1562 summary: String,
1563 },
1564
1565 RehearsalSmokeCheckStarted {
1571 name: String,
1572 version: String,
1573 registry: String,
1574 },
1575 RehearsalSmokeCheckSucceeded {
1576 name: String,
1577 version: String,
1578 duration_ms: u128,
1579 },
1580 RehearsalSmokeCheckFailed {
1581 name: String,
1582 version: String,
1583 message: String,
1584 },
1585
1586 RetryBackoffStarted {
1592 attempt: u32,
1593 max_attempts: u32,
1594 delay_ms: u64,
1595 next_attempt_at: DateTime<Utc>,
1596 reason: ErrorClass,
1597 message: String,
1598 },
1599
1600 ReadinessStarted {
1602 method: ReadinessMethod,
1603 },
1604 ReadinessPoll {
1605 attempt: u32,
1606 visible: bool,
1607 },
1608 ReadinessComplete {
1609 duration_ms: u64,
1610 attempts: u32,
1611 },
1612 ReadinessTimeout {
1613 max_wait_ms: u64,
1614 },
1615 IndexReadinessStarted {
1617 crate_name: String,
1618 version: String,
1619 },
1620 IndexReadinessCheck {
1621 crate_name: String,
1622 version: String,
1623 found: bool,
1624 },
1625 IndexReadinessComplete {
1626 crate_name: String,
1627 version: String,
1628 visible: bool,
1629 },
1630
1631 PreflightStarted,
1633 PreflightWorkspaceVerify {
1634 passed: bool,
1635 output: String,
1636 },
1637 PreflightNewCrateDetected {
1638 crate_name: String,
1639 },
1640 PreflightOwnershipCheck {
1641 crate_name: String,
1642 verified: bool,
1643 },
1644 PreflightComplete {
1645 finishability: Finishability,
1646 },
1647}
1648
1649#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1670#[serde(rename_all = "snake_case")]
1671pub enum ExecutionResult {
1672 Success,
1673 PartialFailure,
1674 CompleteFailure,
1675}
1676
1677#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1698#[serde(rename_all = "snake_case")]
1699pub enum AuthType {
1700 Token,
1701 TrustedPublishing,
1702 Unknown,
1703}
1704
1705#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1726#[serde(rename_all = "snake_case")]
1727pub enum Finishability {
1728 Proven,
1729 NotProven,
1730 Failed,
1731}
1732
1733#[derive(Debug, Clone, Serialize, Deserialize)]
1771pub struct PreflightReport {
1772 pub plan_id: String,
1773 pub token_detected: bool,
1774 pub finishability: Finishability,
1775 pub packages: Vec<PreflightPackage>,
1776 pub timestamp: DateTime<Utc>,
1777 pub dry_run_output: Option<String>,
1779}
1780
1781#[derive(Debug, Clone, Serialize, Deserialize)]
1803pub struct PreflightPackage {
1804 pub name: String,
1805 pub version: String,
1806 pub already_published: bool,
1807 pub is_new_crate: bool,
1808 pub auth_type: Option<AuthType>,
1809 pub ownership_verified: bool,
1810 pub dry_run_passed: bool,
1811 pub dry_run_output: Option<String>,
1813}
1814
1815#[cfg(test)]
1816mod tests {
1817 use super::*;
1818
1819 #[test]
1820 fn crates_io_registry_defaults_are_expected() {
1821 let reg = Registry::crates_io();
1822 assert_eq!(reg.name, "crates-io");
1823 assert_eq!(reg.api_base, "https://crates.io");
1824 }
1825
1826 #[test]
1827 fn uploaded_state_serde_roundtrip() {
1828 let st = PackageState::Uploaded;
1829 let json = serde_json::to_string(&st).expect("serialize");
1830 assert_eq!(json, r#"{"state":"uploaded"}"#);
1831 let rt: PackageState = serde_json::from_str(&json).expect("deserialize");
1832 assert_eq!(rt, PackageState::Uploaded);
1833 }
1834
1835 #[test]
1836 fn package_state_serializes_with_tagged_representation() {
1837 let st = PackageState::Failed {
1838 class: ErrorClass::Permanent,
1839 message: "nope".to_string(),
1840 };
1841
1842 let json = serde_json::to_string(&st).expect("serialize");
1843 assert!(json.contains("\"state\":\"failed\""));
1844 assert!(json.contains("\"class\":\"permanent\""));
1845
1846 let rt: PackageState = serde_json::from_str(&json).expect("deserialize");
1847 assert_eq!(rt, st);
1848 }
1849
1850 #[test]
1851 fn execution_state_roundtrips_json() {
1852 let mut packages = BTreeMap::new();
1853 packages.insert(
1854 "demo@1.2.3".to_string(),
1855 PackageProgress {
1856 name: "demo".to_string(),
1857 version: "1.2.3".to_string(),
1858 attempts: 2,
1859 state: PackageState::Published,
1860 last_updated_at: Utc::now(),
1861 },
1862 );
1863
1864 let st = ExecutionState {
1865 state_version: "shipper.state.v1".to_string(),
1866 plan_id: "plan-1".to_string(),
1867 registry: Registry::crates_io(),
1868 created_at: Utc::now(),
1869 updated_at: Utc::now(),
1870 packages,
1871 };
1872
1873 let json = serde_json::to_string_pretty(&st).expect("serialize");
1874 let parsed: ExecutionState = serde_json::from_str(&json).expect("deserialize");
1875 assert_eq!(parsed.plan_id, "plan-1");
1876 assert!(parsed.packages.contains_key("demo@1.2.3"));
1877 }
1878
1879 #[test]
1880 fn registry_get_index_base_strips_sparse_prefix() {
1881 let registry = Registry {
1882 name: "crates-io".to_string(),
1883 api_base: "https://crates.io".to_string(),
1884 index_base: Some("sparse+https://index.crates.io".to_string()),
1885 };
1886
1887 assert_eq!(registry.get_index_base(), "https://index.crates.io");
1888 }
1889
1890 #[test]
1891 fn readiness_method_default_is_api() {
1892 let method = ReadinessMethod::default();
1893 assert_eq!(method, ReadinessMethod::Api);
1894 }
1895
1896 #[test]
1897 fn readiness_config_default_values() {
1898 let config = ReadinessConfig::default();
1899 assert!(config.enabled);
1900 assert_eq!(config.method, ReadinessMethod::Api);
1901 assert_eq!(config.initial_delay, Duration::from_secs(1));
1902 assert_eq!(config.max_delay, Duration::from_secs(60));
1903 assert_eq!(config.max_total_wait, Duration::from_secs(300));
1904 assert_eq!(config.poll_interval, Duration::from_secs(2));
1905 assert_eq!(config.jitter_factor, 0.5);
1906 }
1907
1908 #[test]
1909 fn readiness_config_can_be_customized() {
1910 let config = ReadinessConfig {
1911 enabled: false,
1912 method: ReadinessMethod::Both,
1913 initial_delay: Duration::from_millis(500),
1914 max_delay: Duration::from_secs(30),
1915 max_total_wait: Duration::from_secs(600),
1916 poll_interval: Duration::from_secs(5),
1917 jitter_factor: 0.25,
1918 index_path: None,
1919 prefer_index: false,
1920 };
1921 assert!(!config.enabled);
1922 assert_eq!(config.method, ReadinessMethod::Both);
1923 assert_eq!(config.initial_delay, Duration::from_millis(500));
1924 assert_eq!(config.max_delay, Duration::from_secs(30));
1925 assert_eq!(config.max_total_wait, Duration::from_secs(600));
1926 assert_eq!(config.poll_interval, Duration::from_secs(5));
1927 assert_eq!(config.jitter_factor, 0.25);
1928 }
1929
1930 #[test]
1933 fn package_state_pending_to_uploaded_is_valid() {
1934 let pending = PackageState::Pending;
1935 let uploaded = PackageState::Uploaded;
1936 assert_eq!(pending, PackageState::Pending);
1937 assert_eq!(uploaded, PackageState::Uploaded);
1938 assert_ne!(pending, uploaded);
1940 }
1941
1942 #[test]
1943 fn package_state_uploaded_to_published_is_valid() {
1944 let uploaded = PackageState::Uploaded;
1945 let published = PackageState::Published;
1946 assert_ne!(uploaded, published);
1947 }
1948
1949 #[test]
1950 fn package_state_pending_to_failed_is_valid() {
1951 let pending = PackageState::Pending;
1952 let failed = PackageState::Failed {
1953 class: ErrorClass::Retryable,
1954 message: "connection refused".to_string(),
1955 };
1956 assert_ne!(pending, failed);
1957 }
1958
1959 #[test]
1960 fn package_state_pending_to_skipped_is_valid() {
1961 let skipped = PackageState::Skipped {
1962 reason: "already published".to_string(),
1963 };
1964 assert!(matches!(skipped, PackageState::Skipped { .. }));
1965 }
1966
1967 #[test]
1968 fn package_state_uploaded_to_ambiguous_is_valid() {
1969 let ambiguous = PackageState::Ambiguous {
1970 message: "upload succeeded but timed out waiting for visibility".to_string(),
1971 };
1972 assert!(matches!(ambiguous, PackageState::Ambiguous { .. }));
1973 }
1974
1975 #[test]
1976 fn package_state_failed_equality_requires_matching_fields() {
1977 let f1 = PackageState::Failed {
1978 class: ErrorClass::Retryable,
1979 message: "timeout".to_string(),
1980 };
1981 let f2 = PackageState::Failed {
1982 class: ErrorClass::Retryable,
1983 message: "timeout".to_string(),
1984 };
1985 let f3 = PackageState::Failed {
1986 class: ErrorClass::Permanent,
1987 message: "timeout".to_string(),
1988 };
1989 let f4 = PackageState::Failed {
1990 class: ErrorClass::Retryable,
1991 message: "different".to_string(),
1992 };
1993 assert_eq!(f1, f2);
1994 assert_ne!(f1, f3);
1995 assert_ne!(f1, f4);
1996 }
1997
1998 #[test]
1999 fn package_state_skipped_equality_by_reason() {
2000 let s1 = PackageState::Skipped {
2001 reason: "exists".to_string(),
2002 };
2003 let s2 = PackageState::Skipped {
2004 reason: "exists".to_string(),
2005 };
2006 let s3 = PackageState::Skipped {
2007 reason: "other".to_string(),
2008 };
2009 assert_eq!(s1, s2);
2010 assert_ne!(s1, s3);
2011 }
2012
2013 #[test]
2014 fn package_state_all_unit_variants_are_distinct() {
2015 let states: Vec<PackageState> = vec![
2016 PackageState::Pending,
2017 PackageState::Uploaded,
2018 PackageState::Published,
2019 ];
2020 for (i, a) in states.iter().enumerate() {
2021 for (j, b) in states.iter().enumerate() {
2022 if i == j {
2023 assert_eq!(a, b);
2024 } else {
2025 assert_ne!(a, b);
2026 }
2027 }
2028 }
2029 }
2030
2031 #[test]
2034 fn release_plan_serde_roundtrip_preserves_all_fields() {
2035 let plan = ReleasePlan {
2036 plan_version: "shipper.plan.v1".to_string(),
2037 plan_id: "deadbeef01234567".to_string(),
2038 created_at: "2025-06-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap(),
2039 registry: Registry::crates_io(),
2040 packages: vec![
2041 PlannedPackage {
2042 name: "alpha".to_string(),
2043 version: "1.0.0".to_string(),
2044 manifest_path: PathBuf::from("crates/alpha/Cargo.toml"),
2045 },
2046 PlannedPackage {
2047 name: "beta".to_string(),
2048 version: "2.0.0".to_string(),
2049 manifest_path: PathBuf::from("crates/beta/Cargo.toml"),
2050 },
2051 ],
2052 dependencies: BTreeMap::from([("beta".to_string(), vec!["alpha".to_string()])]),
2053 };
2054 let json = serde_json::to_string(&plan).unwrap();
2055 let parsed: ReleasePlan = serde_json::from_str(&json).unwrap();
2056 assert_eq!(parsed.plan_version, plan.plan_version);
2057 assert_eq!(parsed.plan_id, plan.plan_id);
2058 assert_eq!(parsed.packages.len(), 2);
2059 assert_eq!(parsed.packages[0].name, "alpha");
2060 assert_eq!(parsed.packages[1].name, "beta");
2061 assert_eq!(parsed.dependencies.len(), 1);
2062 assert_eq!(parsed.dependencies["beta"], vec!["alpha".to_string()]);
2063 assert_eq!(parsed.registry.name, "crates-io");
2064 }
2065
2066 #[test]
2067 fn release_plan_empty_dependencies_roundtrip() {
2068 let plan = ReleasePlan {
2069 plan_version: "shipper.plan.v1".to_string(),
2070 plan_id: "nodeps".to_string(),
2071 created_at: Utc::now(),
2072 registry: Registry::crates_io(),
2073 packages: vec![PlannedPackage {
2074 name: "standalone".to_string(),
2075 version: "0.1.0".to_string(),
2076 manifest_path: PathBuf::from("Cargo.toml"),
2077 }],
2078 dependencies: BTreeMap::new(),
2079 };
2080 let json = serde_json::to_string(&plan).unwrap();
2081 let parsed: ReleasePlan = serde_json::from_str(&json).unwrap();
2082 assert!(parsed.dependencies.is_empty());
2083 }
2084
2085 #[test]
2086 fn release_plan_group_by_levels_single_crate() {
2087 let plan = ReleasePlan {
2088 plan_version: "shipper.plan.v1".to_string(),
2089 plan_id: "single".to_string(),
2090 created_at: Utc::now(),
2091 registry: Registry::crates_io(),
2092 packages: vec![PlannedPackage {
2093 name: "solo".to_string(),
2094 version: "1.0.0".to_string(),
2095 manifest_path: PathBuf::from("Cargo.toml"),
2096 }],
2097 dependencies: BTreeMap::new(),
2098 };
2099 let levels = plan.group_by_levels();
2100 assert_eq!(levels.len(), 1);
2101 assert_eq!(levels[0].level, 0);
2102 assert_eq!(levels[0].packages.len(), 1);
2103 assert_eq!(levels[0].packages[0].name, "solo");
2104 }
2105
2106 #[test]
2107 fn release_plan_group_by_levels_chain() {
2108 let plan = ReleasePlan {
2109 plan_version: "shipper.plan.v1".to_string(),
2110 plan_id: "chain".to_string(),
2111 created_at: Utc::now(),
2112 registry: Registry::crates_io(),
2113 packages: vec![
2114 PlannedPackage {
2115 name: "a".to_string(),
2116 version: "1.0.0".to_string(),
2117 manifest_path: PathBuf::from("a/Cargo.toml"),
2118 },
2119 PlannedPackage {
2120 name: "b".to_string(),
2121 version: "1.0.0".to_string(),
2122 manifest_path: PathBuf::from("b/Cargo.toml"),
2123 },
2124 PlannedPackage {
2125 name: "c".to_string(),
2126 version: "1.0.0".to_string(),
2127 manifest_path: PathBuf::from("c/Cargo.toml"),
2128 },
2129 ],
2130 dependencies: BTreeMap::from([
2131 ("b".to_string(), vec!["a".to_string()]),
2132 ("c".to_string(), vec!["b".to_string()]),
2133 ]),
2134 };
2135 let levels = plan.group_by_levels();
2136 assert_eq!(levels.len(), 3);
2137 assert_eq!(levels[0].level, 0);
2138 assert_eq!(levels[0].packages[0].name, "a");
2139 assert_eq!(levels[1].level, 1);
2140 assert_eq!(levels[1].packages[0].name, "b");
2141 assert_eq!(levels[2].level, 2);
2142 assert_eq!(levels[2].packages[0].name, "c");
2143 }
2144
2145 #[test]
2146 fn release_plan_group_by_levels_parallel_at_level_zero() {
2147 let plan = ReleasePlan {
2148 plan_version: "shipper.plan.v1".to_string(),
2149 plan_id: "parallel".to_string(),
2150 created_at: Utc::now(),
2151 registry: Registry::crates_io(),
2152 packages: vec![
2153 PlannedPackage {
2154 name: "x".to_string(),
2155 version: "1.0.0".to_string(),
2156 manifest_path: PathBuf::from("x/Cargo.toml"),
2157 },
2158 PlannedPackage {
2159 name: "y".to_string(),
2160 version: "1.0.0".to_string(),
2161 manifest_path: PathBuf::from("y/Cargo.toml"),
2162 },
2163 PlannedPackage {
2164 name: "z".to_string(),
2165 version: "1.0.0".to_string(),
2166 manifest_path: PathBuf::from("z/Cargo.toml"),
2167 },
2168 ],
2169 dependencies: BTreeMap::new(),
2170 };
2171 let levels = plan.group_by_levels();
2172 assert_eq!(levels.len(), 1);
2173 assert_eq!(levels[0].packages.len(), 3);
2174 }
2175
2176 #[test]
2179 fn receipt_with_ambiguous_state_roundtrip() {
2180 let t = "2025-01-15T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
2181 let receipt = Receipt {
2182 receipt_version: "shipper.receipt.v1".to_string(),
2183 plan_id: "ambig-test".to_string(),
2184 registry: Registry::crates_io(),
2185 started_at: t,
2186 finished_at: t,
2187 packages: vec![PackageReceipt {
2188 name: "ambig-crate".to_string(),
2189 version: "0.1.0".to_string(),
2190 attempts: 2,
2191 state: PackageState::Ambiguous {
2192 message: "upload ok but readiness timed out".to_string(),
2193 },
2194 started_at: t,
2195 finished_at: t,
2196 duration_ms: 60000,
2197 evidence: PackageEvidence {
2198 attempts: vec![],
2199 readiness_checks: vec![],
2200 },
2201 compromised_at: None,
2202 compromised_by: None,
2203 superseded_by: None,
2204 }],
2205 event_log_path: PathBuf::from(".shipper/events.jsonl"),
2206 git_context: None,
2207 environment: EnvironmentFingerprint {
2208 shipper_version: "0.3.0".to_string(),
2209 cargo_version: None,
2210 rust_version: None,
2211 os: "linux".to_string(),
2212 arch: "x86_64".to_string(),
2213 },
2214 };
2215 let json = serde_json::to_string(&receipt).unwrap();
2216 let parsed: Receipt = serde_json::from_str(&json).unwrap();
2217 assert!(matches!(
2218 &parsed.packages[0].state,
2219 PackageState::Ambiguous { message } if message.contains("readiness timed out")
2220 ));
2221 }
2222
2223 #[test]
2224 fn receipt_empty_packages_roundtrip() {
2225 let t = Utc::now();
2226 let receipt = Receipt {
2227 receipt_version: "shipper.receipt.v1".to_string(),
2228 plan_id: "empty".to_string(),
2229 registry: Registry::crates_io(),
2230 started_at: t,
2231 finished_at: t,
2232 packages: vec![],
2233 event_log_path: PathBuf::from(".shipper/events.jsonl"),
2234 git_context: None,
2235 environment: EnvironmentFingerprint {
2236 shipper_version: "0.3.0".to_string(),
2237 cargo_version: None,
2238 rust_version: None,
2239 os: "linux".to_string(),
2240 arch: "x86_64".to_string(),
2241 },
2242 };
2243 let json = serde_json::to_string(&receipt).unwrap();
2244 let parsed: Receipt = serde_json::from_str(&json).unwrap();
2245 assert!(parsed.packages.is_empty());
2246 }
2247
2248 #[test]
2249 fn receipt_all_state_variants_roundtrip() {
2250 let t = Utc::now();
2251 let states = vec![
2252 PackageState::Published,
2253 PackageState::Uploaded,
2254 PackageState::Pending,
2255 PackageState::Skipped {
2256 reason: "exists".to_string(),
2257 },
2258 PackageState::Failed {
2259 class: ErrorClass::Permanent,
2260 message: "auth".to_string(),
2261 },
2262 PackageState::Ambiguous {
2263 message: "unclear".to_string(),
2264 },
2265 ];
2266 let packages: Vec<PackageReceipt> = states
2267 .into_iter()
2268 .enumerate()
2269 .map(|(i, state)| PackageReceipt {
2270 name: format!("crate-{i}"),
2271 version: "1.0.0".to_string(),
2272 attempts: 1,
2273 state,
2274 started_at: t,
2275 finished_at: t,
2276 duration_ms: 100,
2277 evidence: PackageEvidence {
2278 attempts: vec![],
2279 readiness_checks: vec![],
2280 },
2281 compromised_at: None,
2282 compromised_by: None,
2283 superseded_by: None,
2284 })
2285 .collect();
2286 let receipt = Receipt {
2287 receipt_version: "shipper.receipt.v1".to_string(),
2288 plan_id: "all-variants".to_string(),
2289 registry: Registry::crates_io(),
2290 started_at: t,
2291 finished_at: t,
2292 packages,
2293 event_log_path: PathBuf::from(".shipper/events.jsonl"),
2294 git_context: None,
2295 environment: EnvironmentFingerprint {
2296 shipper_version: "0.3.0".to_string(),
2297 cargo_version: None,
2298 rust_version: None,
2299 os: "linux".to_string(),
2300 arch: "x86_64".to_string(),
2301 },
2302 };
2303 let json = serde_json::to_string(&receipt).unwrap();
2304 let parsed: Receipt = serde_json::from_str(&json).unwrap();
2305 assert_eq!(parsed.packages.len(), 6);
2306 assert!(matches!(parsed.packages[0].state, PackageState::Published));
2307 assert!(matches!(parsed.packages[1].state, PackageState::Uploaded));
2308 assert!(matches!(parsed.packages[2].state, PackageState::Pending));
2309 assert!(matches!(
2310 parsed.packages[3].state,
2311 PackageState::Skipped { .. }
2312 ));
2313 assert!(matches!(
2314 parsed.packages[4].state,
2315 PackageState::Failed { .. }
2316 ));
2317 assert!(matches!(
2318 parsed.packages[5].state,
2319 PackageState::Ambiguous { .. }
2320 ));
2321 }
2322
2323 #[test]
2326 fn publish_policy_default_is_safe() {
2327 assert_eq!(PublishPolicy::default(), PublishPolicy::Safe);
2328 }
2329
2330 #[test]
2331 fn publish_policy_exhaustive_serde() {
2332 let policies = [
2333 PublishPolicy::Safe,
2334 PublishPolicy::Balanced,
2335 PublishPolicy::Fast,
2336 ];
2337 let expected_json = [r#""safe""#, r#""balanced""#, r#""fast""#];
2338 for (policy, expected) in policies.iter().zip(expected_json.iter()) {
2339 let json = serde_json::to_string(policy).unwrap();
2340 assert_eq!(&json, expected);
2341 let parsed: PublishPolicy = serde_json::from_str(&json).unwrap();
2342 assert_eq!(&parsed, policy);
2343 }
2344 }
2345
2346 #[test]
2347 fn verify_mode_default_is_workspace() {
2348 assert_eq!(VerifyMode::default(), VerifyMode::Workspace);
2349 }
2350
2351 #[test]
2352 fn verify_mode_exhaustive_serde() {
2353 let modes = [VerifyMode::Workspace, VerifyMode::Package, VerifyMode::None];
2354 let expected_json = [r#""workspace""#, r#""package""#, r#""none""#];
2355 for (mode, expected) in modes.iter().zip(expected_json.iter()) {
2356 let json = serde_json::to_string(mode).unwrap();
2357 assert_eq!(&json, expected);
2358 let parsed: VerifyMode = serde_json::from_str(&json).unwrap();
2359 assert_eq!(&parsed, mode);
2360 }
2361 }
2362
2363 #[test]
2364 fn readiness_method_exhaustive_serde() {
2365 let methods = [
2366 ReadinessMethod::Api,
2367 ReadinessMethod::Index,
2368 ReadinessMethod::Both,
2369 ];
2370 let expected_json = [r#""api""#, r#""index""#, r#""both""#];
2371 for (method, expected) in methods.iter().zip(expected_json.iter()) {
2372 let json = serde_json::to_string(method).unwrap();
2373 assert_eq!(&json, expected);
2374 let parsed: ReadinessMethod = serde_json::from_str(&json).unwrap();
2375 assert_eq!(&parsed, method);
2376 }
2377 }
2378
2379 #[test]
2382 fn package_progress_epoch_timestamp_roundtrip() {
2383 let epoch = DateTime::from_timestamp(0, 0).unwrap();
2384 let progress = PackageProgress {
2385 name: "epoch-crate".to_string(),
2386 version: "0.0.1".to_string(),
2387 attempts: 0,
2388 state: PackageState::Pending,
2389 last_updated_at: epoch,
2390 };
2391 let json = serde_json::to_string(&progress).unwrap();
2392 let parsed: PackageProgress = serde_json::from_str(&json).unwrap();
2393 assert_eq!(parsed.last_updated_at.timestamp(), 0);
2394 }
2395
2396 #[test]
2397 fn package_progress_far_future_timestamp_roundtrip() {
2398 let far_future = DateTime::from_timestamp(4102444800, 0).unwrap(); let progress = PackageProgress {
2400 name: "future-crate".to_string(),
2401 version: "99.0.0".to_string(),
2402 attempts: 0,
2403 state: PackageState::Pending,
2404 last_updated_at: far_future,
2405 };
2406 let json = serde_json::to_string(&progress).unwrap();
2407 let parsed: PackageProgress = serde_json::from_str(&json).unwrap();
2408 assert_eq!(parsed.last_updated_at.timestamp(), 4102444800);
2409 }
2410
2411 #[test]
2412 fn package_progress_all_states_roundtrip() {
2413 let states = vec![
2414 PackageState::Pending,
2415 PackageState::Uploaded,
2416 PackageState::Published,
2417 PackageState::Skipped {
2418 reason: "r".to_string(),
2419 },
2420 PackageState::Failed {
2421 class: ErrorClass::Ambiguous,
2422 message: "m".to_string(),
2423 },
2424 PackageState::Ambiguous {
2425 message: "a".to_string(),
2426 },
2427 ];
2428 for state in states {
2429 let progress = PackageProgress {
2430 name: "test".to_string(),
2431 version: "1.0.0".to_string(),
2432 attempts: 1,
2433 state: state.clone(),
2434 last_updated_at: Utc::now(),
2435 };
2436 let json = serde_json::to_string(&progress).unwrap();
2437 let parsed: PackageProgress = serde_json::from_str(&json).unwrap();
2438 assert_eq!(parsed.state, state);
2439 }
2440 }
2441
2442 fn make_default_runtime_options() -> RuntimeOptions {
2445 RuntimeOptions {
2446 allow_dirty: false,
2447 skip_ownership_check: false,
2448 strict_ownership: false,
2449 no_verify: false,
2450 max_attempts: 3,
2451 base_delay: Duration::from_secs(1),
2452 max_delay: Duration::from_secs(60),
2453 retry_strategy: shipper_retry::RetryStrategyType::Exponential,
2454 retry_jitter: 0.5,
2455 retry_per_error: shipper_retry::PerErrorConfig::default(),
2456 verify_timeout: Duration::from_secs(600),
2457 verify_poll_interval: Duration::from_secs(10),
2458 state_dir: PathBuf::from(".shipper"),
2459 force_resume: false,
2460 policy: PublishPolicy::Safe,
2461 verify_mode: VerifyMode::Workspace,
2462 readiness: ReadinessConfig::default(),
2463 output_lines: 1000,
2464 force: false,
2465 lock_timeout: Duration::from_secs(3600),
2466 parallel: ParallelConfig::default(),
2467 webhook: WebhookConfig::default(),
2468 encryption: EncryptionSettings::default(),
2469 registries: vec![],
2470 resume_from: None,
2471 rehearsal_registry: None,
2472 rehearsal_skip: false,
2473 rehearsal_smoke_install: None,
2474 }
2475 }
2476
2477 #[test]
2478 fn runtime_options_default_values() {
2479 let opts = make_default_runtime_options();
2480 assert!(!opts.allow_dirty);
2481 assert!(!opts.skip_ownership_check);
2482 assert!(!opts.strict_ownership);
2483 assert!(!opts.no_verify);
2484 assert_eq!(opts.max_attempts, 3);
2485 assert_eq!(opts.base_delay, Duration::from_secs(1));
2486 assert_eq!(opts.max_delay, Duration::from_secs(60));
2487 assert_eq!(opts.policy, PublishPolicy::Safe);
2488 assert_eq!(opts.verify_mode, VerifyMode::Workspace);
2489 assert_eq!(opts.output_lines, 1000);
2490 assert!(!opts.force);
2491 assert!(!opts.force_resume);
2492 assert!(opts.registries.is_empty());
2493 assert!(opts.resume_from.is_none());
2494 }
2495
2496 #[test]
2497 fn runtime_options_all_booleans_toggled() {
2498 let opts = RuntimeOptions {
2499 allow_dirty: true,
2500 skip_ownership_check: true,
2501 strict_ownership: true,
2502 no_verify: true,
2503 force_resume: true,
2504 force: true,
2505 ..make_default_runtime_options()
2506 };
2507 assert!(opts.allow_dirty);
2508 assert!(opts.skip_ownership_check);
2509 assert!(opts.strict_ownership);
2510 assert!(opts.no_verify);
2511 assert!(opts.force_resume);
2512 assert!(opts.force);
2513 }
2514
2515 #[test]
2516 fn runtime_options_with_multiple_registries() {
2517 let opts = RuntimeOptions {
2518 registries: vec![
2519 Registry::crates_io(),
2520 Registry {
2521 name: "private".to_string(),
2522 api_base: "https://registry.example.com".to_string(),
2523 index_base: None,
2524 },
2525 ],
2526 ..make_default_runtime_options()
2527 };
2528 assert_eq!(opts.registries.len(), 2);
2529 assert_eq!(opts.registries[0].name, "crates-io");
2530 assert_eq!(opts.registries[1].name, "private");
2531 }
2532
2533 #[test]
2534 fn runtime_options_with_resume_from() {
2535 let opts = RuntimeOptions {
2536 resume_from: Some("my-crate".to_string()),
2537 ..make_default_runtime_options()
2538 };
2539 assert_eq!(opts.resume_from.as_deref(), Some("my-crate"));
2540 }
2541
2542 #[test]
2545 fn registry_get_index_base_derives_from_api_https() {
2546 let reg = Registry {
2547 name: "custom".to_string(),
2548 api_base: "https://registry.example.com".to_string(),
2549 index_base: None,
2550 };
2551 assert_eq!(reg.get_index_base(), "https://index.registry.example.com");
2552 }
2553
2554 #[test]
2555 fn registry_get_index_base_derives_from_api_http() {
2556 let reg = Registry {
2557 name: "local".to_string(),
2558 api_base: "http://localhost:8080".to_string(),
2559 index_base: None,
2560 };
2561 assert_eq!(reg.get_index_base(), "http://index.localhost:8080");
2562 }
2563
2564 #[test]
2565 fn registry_get_index_base_uses_explicit_value() {
2566 let reg = Registry {
2567 name: "custom".to_string(),
2568 api_base: "https://api.example.com".to_string(),
2569 index_base: Some("https://my-index.example.com".to_string()),
2570 };
2571 assert_eq!(reg.get_index_base(), "https://my-index.example.com");
2572 }
2573
2574 #[test]
2575 fn registry_crates_io_get_index_base() {
2576 let reg = Registry::crates_io();
2577 assert_eq!(reg.get_index_base(), "https://index.crates.io");
2578 }
2579
2580 #[test]
2581 fn registry_serde_skips_none_index_base() {
2582 let reg = Registry {
2583 name: "test".to_string(),
2584 api_base: "https://test.io".to_string(),
2585 index_base: None,
2586 };
2587 let json = serde_json::to_string(®).unwrap();
2588 assert!(!json.contains("index_base"));
2589 }
2590
2591 #[test]
2594 fn error_class_serde_values() {
2595 let classes = [
2596 ErrorClass::Retryable,
2597 ErrorClass::Permanent,
2598 ErrorClass::Ambiguous,
2599 ];
2600 let expected = [r#""retryable""#, r#""permanent""#, r#""ambiguous""#];
2601 for (class, exp) in classes.iter().zip(expected.iter()) {
2602 let json = serde_json::to_string(class).unwrap();
2603 assert_eq!(&json, exp);
2604 }
2605 }
2606
2607 #[test]
2608 fn error_class_clone_and_eq() {
2609 let original = ErrorClass::Retryable;
2610 let cloned = original.clone();
2611 assert_eq!(original, cloned);
2612 }
2613
2614 #[test]
2617 fn execution_result_serde_values() {
2618 let results = [
2619 ExecutionResult::Success,
2620 ExecutionResult::PartialFailure,
2621 ExecutionResult::CompleteFailure,
2622 ];
2623 let expected = [
2624 r#""success""#,
2625 r#""partial_failure""#,
2626 r#""complete_failure""#,
2627 ];
2628 for (result, exp) in results.iter().zip(expected.iter()) {
2629 let json = serde_json::to_string(result).unwrap();
2630 assert_eq!(&json, exp);
2631 }
2632 }
2633
2634 #[test]
2637 fn auth_type_serde_values() {
2638 let types = [
2639 AuthType::Token,
2640 AuthType::TrustedPublishing,
2641 AuthType::Unknown,
2642 ];
2643 let expected = [r#""token""#, r#""trusted_publishing""#, r#""unknown""#];
2644 for (auth, exp) in types.iter().zip(expected.iter()) {
2645 let json = serde_json::to_string(auth).unwrap();
2646 assert_eq!(&json, exp);
2647 }
2648 }
2649
2650 #[test]
2653 fn finishability_serde_values() {
2654 let fins = [
2655 Finishability::Proven,
2656 Finishability::NotProven,
2657 Finishability::Failed,
2658 ];
2659 let expected = [r#""proven""#, r#""not_proven""#, r#""failed""#];
2660 for (fin, exp) in fins.iter().zip(expected.iter()) {
2661 let json = serde_json::to_string(fin).unwrap();
2662 assert_eq!(&json, exp);
2663 }
2664 }
2665
2666 #[test]
2669 fn parallel_config_default_values() {
2670 let config = ParallelConfig::default();
2671 assert!(!config.enabled);
2672 assert_eq!(config.max_concurrent, 4);
2673 assert_eq!(config.per_package_timeout, Duration::from_secs(1800));
2674 }
2675
2676 #[test]
2677 fn parallel_config_serde_roundtrip() {
2678 let config = ParallelConfig {
2679 enabled: true,
2680 max_concurrent: 16,
2681 per_package_timeout: Duration::from_secs(300),
2682 };
2683 let json = serde_json::to_string(&config).unwrap();
2684 let parsed: ParallelConfig = serde_json::from_str(&json).unwrap();
2685 assert!(parsed.enabled);
2686 assert_eq!(parsed.max_concurrent, 16);
2687 assert_eq!(parsed.per_package_timeout, Duration::from_secs(300));
2688 }
2689
2690 #[test]
2693 fn package_state_pending_json() {
2694 let json = serde_json::to_string(&PackageState::Pending).unwrap();
2695 assert_eq!(json, r#"{"state":"pending"}"#);
2696 }
2697
2698 #[test]
2699 fn package_state_published_json() {
2700 let json = serde_json::to_string(&PackageState::Published).unwrap();
2701 assert_eq!(json, r#"{"state":"published"}"#);
2702 }
2703
2704 #[test]
2705 fn package_state_skipped_json_contains_reason() {
2706 let state = PackageState::Skipped {
2707 reason: "version exists".to_string(),
2708 };
2709 let json = serde_json::to_string(&state).unwrap();
2710 assert!(json.contains(r#""state":"skipped""#));
2711 assert!(json.contains(r#""reason":"version exists""#));
2712 }
2713
2714 #[test]
2715 fn package_state_ambiguous_serde_roundtrip() {
2716 let state = PackageState::Ambiguous {
2717 message: "timeout during readiness".to_string(),
2718 };
2719 let json = serde_json::to_string(&state).unwrap();
2720 let parsed: PackageState = serde_json::from_str(&json).unwrap();
2721 assert_eq!(parsed, state);
2722 }
2723
2724 #[test]
2727 fn event_type_preflight_variants_roundtrip() {
2728 let events = vec![
2729 EventType::PreflightStarted,
2730 EventType::PreflightWorkspaceVerify {
2731 passed: true,
2732 output: "all good".to_string(),
2733 },
2734 EventType::PreflightNewCrateDetected {
2735 crate_name: "new-crate".to_string(),
2736 },
2737 EventType::PreflightOwnershipCheck {
2738 crate_name: "my-crate".to_string(),
2739 verified: true,
2740 },
2741 EventType::PreflightComplete {
2742 finishability: Finishability::Proven,
2743 },
2744 ];
2745 for event in &events {
2746 let json = serde_json::to_string(event).unwrap();
2747 let parsed: EventType = serde_json::from_str(&json).unwrap();
2748 let reparsed_json = serde_json::to_string(&parsed).unwrap();
2749 assert_eq!(json, reparsed_json);
2750 }
2751 }
2752
2753 #[test]
2756 fn git_context_all_none_roundtrip() {
2757 let ctx = GitContext {
2758 commit: None,
2759 branch: None,
2760 tag: None,
2761 dirty: None,
2762 };
2763 let json = serde_json::to_string(&ctx).unwrap();
2764 let parsed: GitContext = serde_json::from_str(&json).unwrap();
2765 assert!(parsed.commit.is_none());
2766 assert!(parsed.branch.is_none());
2767 assert!(parsed.tag.is_none());
2768 assert!(parsed.dirty.is_none());
2769 }
2770
2771 #[test]
2772 fn git_context_all_some_roundtrip() {
2773 let ctx = GitContext {
2774 commit: Some("abc123".to_string()),
2775 branch: Some("main".to_string()),
2776 tag: Some("v1.0.0".to_string()),
2777 dirty: Some(true),
2778 };
2779 let json = serde_json::to_string(&ctx).unwrap();
2780 let parsed: GitContext = serde_json::from_str(&json).unwrap();
2781 assert_eq!(parsed.commit.as_deref(), Some("abc123"));
2782 assert_eq!(parsed.dirty, Some(true));
2783 }
2784
2785 #[test]
2788 fn environment_fingerprint_optional_fields_roundtrip() {
2789 let fp = EnvironmentFingerprint {
2790 shipper_version: "0.3.0".to_string(),
2791 cargo_version: None,
2792 rust_version: None,
2793 os: "wasm".to_string(),
2794 arch: "wasm32".to_string(),
2795 };
2796 let json = serde_json::to_string(&fp).unwrap();
2797 let parsed: EnvironmentFingerprint = serde_json::from_str(&json).unwrap();
2798 assert!(parsed.cargo_version.is_none());
2799 assert!(parsed.rust_version.is_none());
2800 assert_eq!(parsed.os, "wasm");
2801 }
2802
2803 #[test]
2806 fn attempt_evidence_duration_serialized_as_millis() {
2807 let evidence = AttemptEvidence {
2808 attempt_number: 1,
2809 command: "cargo publish".to_string(),
2810 exit_code: 0,
2811 stdout_tail: String::new(),
2812 stderr_tail: String::new(),
2813 timestamp: Utc::now(),
2814 duration: Duration::from_secs(5),
2815 };
2816 let json = serde_json::to_string(&evidence).unwrap();
2817 assert!(json.contains("5000"));
2818 }
2819
2820 #[test]
2821 fn readiness_evidence_duration_serialized_as_millis() {
2822 let evidence = ReadinessEvidence {
2823 attempt: 1,
2824 visible: true,
2825 timestamp: Utc::now(),
2826 delay_before: Duration::from_millis(2500),
2827 };
2828 let json = serde_json::to_string(&evidence).unwrap();
2829 assert!(json.contains("2500"));
2830 }
2831
2832 #[test]
2835 fn readiness_config_serde_with_index_path_roundtrip() {
2836 let config = ReadinessConfig {
2837 enabled: true,
2838 method: ReadinessMethod::Index,
2839 initial_delay: Duration::from_secs(2),
2840 max_delay: Duration::from_secs(120),
2841 max_total_wait: Duration::from_secs(600),
2842 poll_interval: Duration::from_secs(5),
2843 jitter_factor: 0.3,
2844 index_path: Some(PathBuf::from("/tmp/test-index")),
2845 prefer_index: true,
2846 };
2847 let json = serde_json::to_string(&config).unwrap();
2848 let parsed: ReadinessConfig = serde_json::from_str(&json).unwrap();
2849 assert_eq!(parsed.index_path, Some(PathBuf::from("/tmp/test-index")));
2850 assert!(parsed.prefer_index);
2851 }
2852
2853 #[test]
2854 fn readiness_config_defaults_from_json_empty_object() {
2855 let config: ReadinessConfig = serde_json::from_str("{}").unwrap();
2856 assert!(config.enabled);
2857 assert_eq!(config.method, ReadinessMethod::Api);
2858 assert_eq!(config.jitter_factor, 0.5);
2859 assert!(!config.prefer_index);
2860 assert!(config.index_path.is_none());
2861 }
2862
2863 mod debug_snapshots {
2866 use super::*;
2867
2868 #[test]
2869 fn publish_policy_debug_snapshot() {
2870 insta::assert_debug_snapshot!(PublishPolicy::Safe);
2871 insta::assert_debug_snapshot!(PublishPolicy::Balanced);
2872 insta::assert_debug_snapshot!(PublishPolicy::Fast);
2873 }
2874
2875 #[test]
2876 fn verify_mode_debug_snapshot() {
2877 insta::assert_debug_snapshot!(VerifyMode::Workspace);
2878 insta::assert_debug_snapshot!(VerifyMode::Package);
2879 insta::assert_debug_snapshot!(VerifyMode::None);
2880 }
2881
2882 #[test]
2883 fn readiness_method_debug_snapshot() {
2884 insta::assert_debug_snapshot!(ReadinessMethod::Api);
2885 insta::assert_debug_snapshot!(ReadinessMethod::Index);
2886 insta::assert_debug_snapshot!(ReadinessMethod::Both);
2887 }
2888
2889 #[test]
2890 fn runtime_options_debug_snapshot() {
2891 let opts = super::make_default_runtime_options();
2892 insta::assert_debug_snapshot!(opts);
2893 }
2894
2895 #[test]
2896 fn package_progress_debug_snapshot() {
2897 let t = "2025-01-15T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
2898 let progress = PackageProgress {
2899 name: "snapshot-crate".to_string(),
2900 version: "1.0.0".to_string(),
2901 attempts: 2,
2902 state: PackageState::Failed {
2903 class: ErrorClass::Retryable,
2904 message: "timeout".to_string(),
2905 },
2906 last_updated_at: t,
2907 };
2908 insta::assert_debug_snapshot!(progress);
2909 }
2910 }
2911
2912 mod snapshots {
2913 use super::*;
2914
2915 fn fixed_time() -> DateTime<Utc> {
2916 "2025-01-15T12:00:00Z".parse::<DateTime<Utc>>().unwrap()
2917 }
2918
2919 #[test]
2920 fn release_plan_snapshot() {
2921 let plan = ReleasePlan {
2922 plan_version: "shipper.plan.v1".to_string(),
2923 plan_id: "abc123".to_string(),
2924 created_at: fixed_time(),
2925 registry: Registry::crates_io(),
2926 packages: vec![
2927 PlannedPackage {
2928 name: "core-lib".to_string(),
2929 version: "0.1.0".to_string(),
2930 manifest_path: PathBuf::from("crates/core-lib/Cargo.toml"),
2931 },
2932 PlannedPackage {
2933 name: "my-cli".to_string(),
2934 version: "0.2.0".to_string(),
2935 manifest_path: PathBuf::from("crates/my-cli/Cargo.toml"),
2936 },
2937 ],
2938 dependencies: BTreeMap::from([(
2939 "my-cli".to_string(),
2940 vec!["core-lib".to_string()],
2941 )]),
2942 };
2943 insta::assert_yaml_snapshot!(plan);
2944 }
2945
2946 #[test]
2947 fn package_state_all_variants() {
2948 let variants: Vec<(&str, PackageState)> = vec![
2949 ("pending", PackageState::Pending),
2950 ("uploaded", PackageState::Uploaded),
2951 ("published", PackageState::Published),
2952 (
2953 "skipped",
2954 PackageState::Skipped {
2955 reason: "already published".to_string(),
2956 },
2957 ),
2958 (
2959 "failed",
2960 PackageState::Failed {
2961 class: ErrorClass::Retryable,
2962 message: "network timeout".to_string(),
2963 },
2964 ),
2965 (
2966 "ambiguous",
2967 PackageState::Ambiguous {
2968 message: "unclear outcome".to_string(),
2969 },
2970 ),
2971 ];
2972 for (label, state) in variants {
2973 insta::assert_yaml_snapshot!(format!("package_state_{label}"), state);
2974 }
2975 }
2976
2977 #[test]
2978 fn receipt_full_snapshot() {
2979 let t = fixed_time();
2980 let receipt = Receipt {
2981 receipt_version: "shipper.receipt.v1".to_string(),
2982 plan_id: "plan-42".to_string(),
2983 registry: Registry::crates_io(),
2984 started_at: t,
2985 finished_at: t,
2986 packages: vec![PackageReceipt {
2987 name: "demo".to_string(),
2988 version: "1.0.0".to_string(),
2989 attempts: 1,
2990 state: PackageState::Published,
2991 started_at: t,
2992 finished_at: t,
2993 duration_ms: 4500,
2994 evidence: PackageEvidence {
2995 attempts: vec![AttemptEvidence {
2996 attempt_number: 1,
2997 command: "cargo publish -p demo".to_string(),
2998 exit_code: 0,
2999 stdout_tail: "Uploading demo v1.0.0".to_string(),
3000 stderr_tail: String::new(),
3001 timestamp: t,
3002 duration: Duration::from_millis(4200),
3003 }],
3004 readiness_checks: vec![ReadinessEvidence {
3005 attempt: 1,
3006 visible: true,
3007 timestamp: t,
3008 delay_before: Duration::from_secs(2),
3009 }],
3010 },
3011 compromised_at: None,
3012 compromised_by: None,
3013 superseded_by: None,
3014 }],
3015 event_log_path: PathBuf::from(".shipper/events.jsonl"),
3016 git_context: Some(GitContext {
3017 commit: Some("abcdef1234567890".to_string()),
3018 branch: Some("main".to_string()),
3019 tag: Some("v1.0.0".to_string()),
3020 dirty: Some(false),
3021 }),
3022 environment: EnvironmentFingerprint {
3023 shipper_version: "0.2.0".to_string(),
3024 cargo_version: Some("1.82.0".to_string()),
3025 rust_version: Some("1.82.0".to_string()),
3026 os: "linux".to_string(),
3027 arch: "x86_64".to_string(),
3028 },
3029 };
3030 insta::assert_yaml_snapshot!(receipt);
3031 }
3032
3033 #[test]
3034 fn execution_state_snapshot() {
3035 let t = fixed_time();
3036 let mut packages = BTreeMap::new();
3037 packages.insert(
3038 "core-lib@0.1.0".to_string(),
3039 PackageProgress {
3040 name: "core-lib".to_string(),
3041 version: "0.1.0".to_string(),
3042 attempts: 1,
3043 state: PackageState::Published,
3044 last_updated_at: t,
3045 },
3046 );
3047 packages.insert(
3048 "my-cli@0.2.0".to_string(),
3049 PackageProgress {
3050 name: "my-cli".to_string(),
3051 version: "0.2.0".to_string(),
3052 attempts: 0,
3053 state: PackageState::Pending,
3054 last_updated_at: t,
3055 },
3056 );
3057 let state = ExecutionState {
3058 state_version: "shipper.state.v1".to_string(),
3059 plan_id: "plan-42".to_string(),
3060 registry: Registry::crates_io(),
3061 created_at: t,
3062 updated_at: t,
3063 packages,
3064 };
3065 insta::assert_yaml_snapshot!(state);
3066 }
3067
3068 #[test]
3069 fn preflight_report_snapshot() {
3070 let report = PreflightReport {
3071 plan_id: "plan-42".to_string(),
3072 token_detected: true,
3073 finishability: Finishability::Proven,
3074 packages: vec![
3075 PreflightPackage {
3076 name: "core-lib".to_string(),
3077 version: "0.1.0".to_string(),
3078 already_published: false,
3079 is_new_crate: true,
3080 auth_type: Some(AuthType::Token),
3081 ownership_verified: true,
3082 dry_run_passed: true,
3083 dry_run_output: None,
3084 },
3085 PreflightPackage {
3086 name: "my-cli".to_string(),
3087 version: "0.2.0".to_string(),
3088 already_published: false,
3089 is_new_crate: false,
3090 auth_type: Some(AuthType::TrustedPublishing),
3091 ownership_verified: true,
3092 dry_run_passed: true,
3093 dry_run_output: Some("dry-run ok".to_string()),
3094 },
3095 ],
3096 timestamp: fixed_time(),
3097 dry_run_output: Some("workspace dry-run passed".to_string()),
3098 };
3099 insta::assert_yaml_snapshot!(report);
3100 }
3101
3102 #[test]
3105 fn release_plan_single_package() {
3106 let plan = ReleasePlan {
3107 plan_version: "shipper.plan.v1".to_string(),
3108 plan_id: "single-pkg-001".to_string(),
3109 created_at: fixed_time(),
3110 registry: Registry::crates_io(),
3111 packages: vec![PlannedPackage {
3112 name: "solo-crate".to_string(),
3113 version: "1.0.0".to_string(),
3114 manifest_path: PathBuf::from("Cargo.toml"),
3115 }],
3116 dependencies: BTreeMap::new(),
3117 };
3118 insta::assert_yaml_snapshot!(plan);
3119 }
3120
3121 #[test]
3122 fn release_plan_custom_registry() {
3123 let plan = ReleasePlan {
3124 plan_version: "shipper.plan.v1".to_string(),
3125 plan_id: "custom-reg-001".to_string(),
3126 created_at: fixed_time(),
3127 registry: Registry {
3128 name: "my-private-registry".to_string(),
3129 api_base: "https://registry.example.com".to_string(),
3130 index_base: Some("https://index.registry.example.com".to_string()),
3131 },
3132 packages: vec![
3133 PlannedPackage {
3134 name: "internal-utils".to_string(),
3135 version: "2.1.0".to_string(),
3136 manifest_path: PathBuf::from("crates/internal-utils/Cargo.toml"),
3137 },
3138 PlannedPackage {
3139 name: "internal-api".to_string(),
3140 version: "3.0.0".to_string(),
3141 manifest_path: PathBuf::from("crates/internal-api/Cargo.toml"),
3142 },
3143 ],
3144 dependencies: BTreeMap::from([(
3145 "internal-api".to_string(),
3146 vec!["internal-utils".to_string()],
3147 )]),
3148 };
3149 insta::assert_yaml_snapshot!(plan);
3150 }
3151
3152 #[test]
3153 fn release_plan_deep_dependency_chain() {
3154 let plan = ReleasePlan {
3155 plan_version: "shipper.plan.v1".to_string(),
3156 plan_id: "deep-deps-001".to_string(),
3157 created_at: fixed_time(),
3158 registry: Registry::crates_io(),
3159 packages: vec![
3160 PlannedPackage {
3161 name: "foundation".to_string(),
3162 version: "0.1.0".to_string(),
3163 manifest_path: PathBuf::from("crates/foundation/Cargo.toml"),
3164 },
3165 PlannedPackage {
3166 name: "middleware".to_string(),
3167 version: "0.2.0".to_string(),
3168 manifest_path: PathBuf::from("crates/middleware/Cargo.toml"),
3169 },
3170 PlannedPackage {
3171 name: "service".to_string(),
3172 version: "0.3.0".to_string(),
3173 manifest_path: PathBuf::from("crates/service/Cargo.toml"),
3174 },
3175 PlannedPackage {
3176 name: "gateway".to_string(),
3177 version: "1.0.0".to_string(),
3178 manifest_path: PathBuf::from("crates/gateway/Cargo.toml"),
3179 },
3180 ],
3181 dependencies: BTreeMap::from([
3182 ("foundation".to_string(), Vec::new()),
3183 ("middleware".to_string(), vec!["foundation".to_string()]),
3184 (
3185 "service".to_string(),
3186 vec!["foundation".to_string(), "middleware".to_string()],
3187 ),
3188 ("gateway".to_string(), vec!["service".to_string()]),
3189 ]),
3190 };
3191 insta::assert_yaml_snapshot!(plan);
3192 }
3193
3194 #[test]
3197 fn receipt_partial_failure() {
3198 let t = fixed_time();
3199 let receipt = Receipt {
3200 receipt_version: "shipper.receipt.v1".to_string(),
3201 plan_id: "plan-partial".to_string(),
3202 registry: Registry::crates_io(),
3203 started_at: t,
3204 finished_at: t,
3205 packages: vec![
3206 PackageReceipt {
3207 name: "core-lib".to_string(),
3208 version: "0.1.0".to_string(),
3209 attempts: 1,
3210 state: PackageState::Published,
3211 started_at: t,
3212 finished_at: t,
3213 duration_ms: 3200,
3214 evidence: PackageEvidence {
3215 attempts: vec![AttemptEvidence {
3216 attempt_number: 1,
3217 command: "cargo publish -p core-lib".to_string(),
3218 exit_code: 0,
3219 stdout_tail: "Uploading core-lib v0.1.0".to_string(),
3220 stderr_tail: String::new(),
3221 timestamp: t,
3222 duration: Duration::from_millis(3000),
3223 }],
3224 readiness_checks: vec![ReadinessEvidence {
3225 attempt: 1,
3226 visible: true,
3227 timestamp: t,
3228 delay_before: Duration::from_secs(1),
3229 }],
3230 },
3231 compromised_at: None,
3232 compromised_by: None,
3233 superseded_by: None,
3234 },
3235 PackageReceipt {
3236 name: "api-server".to_string(),
3237 version: "0.2.0".to_string(),
3238 attempts: 3,
3239 state: PackageState::Failed {
3240 class: ErrorClass::Retryable,
3241 message: "rate limited by registry".to_string(),
3242 },
3243 started_at: t,
3244 finished_at: t,
3245 duration_ms: 15000,
3246 evidence: PackageEvidence {
3247 attempts: vec![
3248 AttemptEvidence {
3249 attempt_number: 1,
3250 command: "cargo publish -p api-server".to_string(),
3251 exit_code: 1,
3252 stdout_tail: String::new(),
3253 stderr_tail: "error: rate limit exceeded".to_string(),
3254 timestamp: t,
3255 duration: Duration::from_millis(500),
3256 },
3257 AttemptEvidence {
3258 attempt_number: 2,
3259 command: "cargo publish -p api-server".to_string(),
3260 exit_code: 1,
3261 stdout_tail: String::new(),
3262 stderr_tail: "error: rate limit exceeded".to_string(),
3263 timestamp: t,
3264 duration: Duration::from_millis(600),
3265 },
3266 AttemptEvidence {
3267 attempt_number: 3,
3268 command: "cargo publish -p api-server".to_string(),
3269 exit_code: 1,
3270 stdout_tail: String::new(),
3271 stderr_tail: "error: rate limit exceeded".to_string(),
3272 timestamp: t,
3273 duration: Duration::from_millis(700),
3274 },
3275 ],
3276 readiness_checks: vec![],
3277 },
3278 compromised_at: None,
3279 compromised_by: None,
3280 superseded_by: None,
3281 },
3282 PackageReceipt {
3283 name: "old-compat".to_string(),
3284 version: "0.1.0".to_string(),
3285 attempts: 0,
3286 state: PackageState::Skipped {
3287 reason: "version already exists on registry".to_string(),
3288 },
3289 started_at: t,
3290 finished_at: t,
3291 duration_ms: 50,
3292 evidence: PackageEvidence {
3293 attempts: vec![],
3294 readiness_checks: vec![],
3295 },
3296 compromised_at: None,
3297 compromised_by: None,
3298 superseded_by: None,
3299 },
3300 ],
3301 event_log_path: PathBuf::from(".shipper/events.jsonl"),
3302 git_context: Some(GitContext {
3303 commit: Some("deadbeef12345678".to_string()),
3304 branch: Some("release/v0.2".to_string()),
3305 tag: None,
3306 dirty: Some(true),
3307 }),
3308 environment: EnvironmentFingerprint {
3309 shipper_version: "0.3.0".to_string(),
3310 cargo_version: Some("1.82.0".to_string()),
3311 rust_version: Some("1.82.0".to_string()),
3312 os: "linux".to_string(),
3313 arch: "x86_64".to_string(),
3314 },
3315 };
3316 insta::assert_yaml_snapshot!(receipt);
3317 }
3318
3319 #[test]
3320 fn receipt_no_git_context() {
3321 let t = fixed_time();
3322 let receipt = Receipt {
3323 receipt_version: "shipper.receipt.v1".to_string(),
3324 plan_id: "plan-nogit".to_string(),
3325 registry: Registry::crates_io(),
3326 started_at: t,
3327 finished_at: t,
3328 packages: vec![PackageReceipt {
3329 name: "headless-lib".to_string(),
3330 version: "0.5.0".to_string(),
3331 attempts: 1,
3332 state: PackageState::Published,
3333 started_at: t,
3334 finished_at: t,
3335 duration_ms: 2000,
3336 evidence: PackageEvidence {
3337 attempts: vec![],
3338 readiness_checks: vec![],
3339 },
3340 compromised_at: None,
3341 compromised_by: None,
3342 superseded_by: None,
3343 }],
3344 event_log_path: PathBuf::from(".shipper/events.jsonl"),
3345 git_context: None,
3346 environment: EnvironmentFingerprint {
3347 shipper_version: "0.3.0".to_string(),
3348 cargo_version: None,
3349 rust_version: None,
3350 os: "windows".to_string(),
3351 arch: "aarch64".to_string(),
3352 },
3353 };
3354 insta::assert_yaml_snapshot!(receipt);
3355 }
3356
3357 #[test]
3358 fn receipt_complete_failure() {
3359 let t = fixed_time();
3360 let receipt = Receipt {
3361 receipt_version: "shipper.receipt.v1".to_string(),
3362 plan_id: "plan-allfail".to_string(),
3363 registry: Registry::crates_io(),
3364 started_at: t,
3365 finished_at: t,
3366 packages: vec![
3367 PackageReceipt {
3368 name: "broken-crate".to_string(),
3369 version: "0.1.0".to_string(),
3370 attempts: 3,
3371 state: PackageState::Failed {
3372 class: ErrorClass::Permanent,
3373 message: "invalid credentials".to_string(),
3374 },
3375 started_at: t,
3376 finished_at: t,
3377 duration_ms: 800,
3378 evidence: PackageEvidence {
3379 attempts: vec![AttemptEvidence {
3380 attempt_number: 1,
3381 command: "cargo publish -p broken-crate".to_string(),
3382 exit_code: 1,
3383 stdout_tail: String::new(),
3384 stderr_tail: "error: 403 Forbidden".to_string(),
3385 timestamp: t,
3386 duration: Duration::from_millis(200),
3387 }],
3388 readiness_checks: vec![],
3389 },
3390 compromised_at: None,
3391 compromised_by: None,
3392 superseded_by: None,
3393 },
3394 PackageReceipt {
3395 name: "dependent-crate".to_string(),
3396 version: "0.2.0".to_string(),
3397 attempts: 0,
3398 state: PackageState::Skipped {
3399 reason: "dependency broken-crate failed".to_string(),
3400 },
3401 started_at: t,
3402 finished_at: t,
3403 duration_ms: 0,
3404 evidence: PackageEvidence {
3405 attempts: vec![],
3406 readiness_checks: vec![],
3407 },
3408 compromised_at: None,
3409 compromised_by: None,
3410 superseded_by: None,
3411 },
3412 ],
3413 event_log_path: PathBuf::from(".shipper/events.jsonl"),
3414 git_context: Some(GitContext {
3415 commit: Some("abcdef0123456789".to_string()),
3416 branch: Some("main".to_string()),
3417 tag: Some("v0.1.0".to_string()),
3418 dirty: Some(false),
3419 }),
3420 environment: EnvironmentFingerprint {
3421 shipper_version: "0.3.0".to_string(),
3422 cargo_version: Some("1.82.0".to_string()),
3423 rust_version: Some("1.82.0".to_string()),
3424 os: "macos".to_string(),
3425 arch: "aarch64".to_string(),
3426 },
3427 };
3428 insta::assert_yaml_snapshot!(receipt);
3429 }
3430
3431 #[test]
3434 fn execution_state_all_pending() {
3435 let t = fixed_time();
3436 let mut packages = BTreeMap::new();
3437 for (name, ver) in [("alpha", "0.1.0"), ("beta", "0.2.0"), ("gamma", "0.3.0")] {
3438 packages.insert(
3439 format!("{name}@{ver}"),
3440 PackageProgress {
3441 name: name.to_string(),
3442 version: ver.to_string(),
3443 attempts: 0,
3444 state: PackageState::Pending,
3445 last_updated_at: t,
3446 },
3447 );
3448 }
3449 let state = ExecutionState {
3450 state_version: "shipper.state.v1".to_string(),
3451 plan_id: "plan-fresh".to_string(),
3452 registry: Registry::crates_io(),
3453 created_at: t,
3454 updated_at: t,
3455 packages,
3456 };
3457 insta::assert_yaml_snapshot!(state);
3458 }
3459
3460 #[test]
3461 fn execution_state_completed() {
3462 let t = fixed_time();
3463 let mut packages = BTreeMap::new();
3464 for (name, ver) in [("alpha", "0.1.0"), ("beta", "0.2.0")] {
3465 packages.insert(
3466 format!("{name}@{ver}"),
3467 PackageProgress {
3468 name: name.to_string(),
3469 version: ver.to_string(),
3470 attempts: 1,
3471 state: PackageState::Published,
3472 last_updated_at: t,
3473 },
3474 );
3475 }
3476 let state = ExecutionState {
3477 state_version: "shipper.state.v1".to_string(),
3478 plan_id: "plan-done".to_string(),
3479 registry: Registry::crates_io(),
3480 created_at: t,
3481 updated_at: t,
3482 packages,
3483 };
3484 insta::assert_yaml_snapshot!(state);
3485 }
3486
3487 #[test]
3488 fn execution_state_mixed_with_failures() {
3489 let t = fixed_time();
3490 let mut packages = BTreeMap::new();
3491 packages.insert(
3492 "core@0.1.0".to_string(),
3493 PackageProgress {
3494 name: "core".to_string(),
3495 version: "0.1.0".to_string(),
3496 attempts: 1,
3497 state: PackageState::Published,
3498 last_updated_at: t,
3499 },
3500 );
3501 packages.insert(
3502 "net@0.2.0".to_string(),
3503 PackageProgress {
3504 name: "net".to_string(),
3505 version: "0.2.0".to_string(),
3506 attempts: 3,
3507 state: PackageState::Failed {
3508 class: ErrorClass::Retryable,
3509 message: "connection reset".to_string(),
3510 },
3511 last_updated_at: t,
3512 },
3513 );
3514 packages.insert(
3515 "cli@0.3.0".to_string(),
3516 PackageProgress {
3517 name: "cli".to_string(),
3518 version: "0.3.0".to_string(),
3519 attempts: 1,
3520 state: PackageState::Ambiguous {
3521 message: "upload succeeded but readiness timed out".to_string(),
3522 },
3523 last_updated_at: t,
3524 },
3525 );
3526 packages.insert(
3527 "compat@0.1.0".to_string(),
3528 PackageProgress {
3529 name: "compat".to_string(),
3530 version: "0.1.0".to_string(),
3531 attempts: 0,
3532 state: PackageState::Skipped {
3533 reason: "version already on registry".to_string(),
3534 },
3535 last_updated_at: t,
3536 },
3537 );
3538 let state = ExecutionState {
3539 state_version: "shipper.state.v1".to_string(),
3540 plan_id: "plan-mixed".to_string(),
3541 registry: Registry::crates_io(),
3542 created_at: t,
3543 updated_at: t,
3544 packages,
3545 };
3546 insta::assert_yaml_snapshot!(state);
3547 }
3548
3549 #[test]
3552 fn readiness_config_default_snapshot() {
3553 let config = ReadinessConfig::default();
3554 insta::assert_yaml_snapshot!(config);
3555 }
3556
3557 #[test]
3558 fn readiness_config_custom_snapshot() {
3559 let config = ReadinessConfig {
3560 enabled: false,
3561 method: ReadinessMethod::Both,
3562 initial_delay: Duration::from_millis(500),
3563 max_delay: Duration::from_secs(120),
3564 max_total_wait: Duration::from_secs(900),
3565 poll_interval: Duration::from_secs(10),
3566 jitter_factor: 0.25,
3567 index_path: Some(PathBuf::from("/tmp/test-index")),
3568 prefer_index: true,
3569 };
3570 insta::assert_yaml_snapshot!(config);
3571 }
3572
3573 #[test]
3574 fn parallel_config_default_snapshot() {
3575 let config = ParallelConfig::default();
3576 insta::assert_yaml_snapshot!(config);
3577 }
3578
3579 #[test]
3580 fn parallel_config_enabled_snapshot() {
3581 let config = ParallelConfig {
3582 enabled: true,
3583 max_concurrent: 8,
3584 per_package_timeout: Duration::from_secs(600),
3585 };
3586 insta::assert_yaml_snapshot!(config);
3587 }
3588
3589 #[test]
3592 fn environment_fingerprint_snapshot() {
3593 let fp = EnvironmentFingerprint {
3594 shipper_version: "0.3.0".to_string(),
3595 cargo_version: Some("1.82.0".to_string()),
3596 rust_version: Some("1.82.0".to_string()),
3597 os: "linux".to_string(),
3598 arch: "x86_64".to_string(),
3599 };
3600 insta::assert_yaml_snapshot!(fp);
3601 }
3602
3603 #[test]
3604 fn git_context_full_snapshot() {
3605 let ctx = GitContext {
3606 commit: Some("a1b2c3d4e5f6".to_string()),
3607 branch: Some("release/v2.0".to_string()),
3608 tag: Some("v2.0.0".to_string()),
3609 dirty: Some(false),
3610 };
3611 insta::assert_yaml_snapshot!(ctx);
3612 }
3613
3614 #[test]
3615 fn git_context_minimal_snapshot() {
3616 let ctx = GitContext {
3617 commit: None,
3618 branch: None,
3619 tag: None,
3620 dirty: None,
3621 };
3622 insta::assert_yaml_snapshot!(ctx);
3623 }
3624
3625 #[test]
3626 fn publish_event_lifecycle_snapshot() {
3627 let t = fixed_time();
3628 let events = vec![
3629 PublishEvent {
3630 timestamp: t,
3631 event_type: EventType::PlanCreated {
3632 plan_id: "plan-99".to_string(),
3633 package_count: 3,
3634 },
3635 package: String::new(),
3636 },
3637 PublishEvent {
3638 timestamp: t,
3639 event_type: EventType::ExecutionStarted,
3640 package: String::new(),
3641 },
3642 PublishEvent {
3643 timestamp: t,
3644 event_type: EventType::ExecutionFinished {
3645 result: ExecutionResult::PartialFailure,
3646 },
3647 package: String::new(),
3648 },
3649 ];
3650 insta::assert_yaml_snapshot!(events);
3651 }
3652
3653 #[test]
3654 fn publish_event_package_flow_snapshot() {
3655 let t = fixed_time();
3656 let events = vec![
3657 PublishEvent {
3658 timestamp: t,
3659 event_type: EventType::PackageStarted {
3660 name: "my-crate".to_string(),
3661 version: "1.0.0".to_string(),
3662 },
3663 package: "my-crate@1.0.0".to_string(),
3664 },
3665 PublishEvent {
3666 timestamp: t,
3667 event_type: EventType::PackageAttempted {
3668 attempt: 1,
3669 command: "cargo publish -p my-crate".to_string(),
3670 },
3671 package: "my-crate@1.0.0".to_string(),
3672 },
3673 PublishEvent {
3674 timestamp: t,
3675 event_type: EventType::PackageOutput {
3676 stdout_tail: "Uploading my-crate v1.0.0".to_string(),
3677 stderr_tail: String::new(),
3678 },
3679 package: "my-crate@1.0.0".to_string(),
3680 },
3681 PublishEvent {
3682 timestamp: t,
3683 event_type: EventType::PackagePublished { duration_ms: 4500 },
3684 package: "my-crate@1.0.0".to_string(),
3685 },
3686 ];
3687 insta::assert_yaml_snapshot!(events);
3688 }
3689
3690 #[test]
3691 fn error_class_all_variants_snapshot() {
3692 let variants: Vec<(&str, ErrorClass)> = vec![
3693 ("retryable", ErrorClass::Retryable),
3694 ("permanent", ErrorClass::Permanent),
3695 ("ambiguous", ErrorClass::Ambiguous),
3696 ];
3697 for (label, class) in variants {
3698 insta::assert_yaml_snapshot!(format!("error_class_{label}"), class);
3699 }
3700 }
3701
3702 #[test]
3703 fn execution_result_all_variants_snapshot() {
3704 let variants: Vec<(&str, ExecutionResult)> = vec![
3705 ("success", ExecutionResult::Success),
3706 ("partial_failure", ExecutionResult::PartialFailure),
3707 ("complete_failure", ExecutionResult::CompleteFailure),
3708 ];
3709 for (label, result) in variants {
3710 insta::assert_yaml_snapshot!(format!("execution_result_{label}"), result);
3711 }
3712 }
3713
3714 #[test]
3715 fn finishability_all_variants_snapshot() {
3716 let variants: Vec<(&str, Finishability)> = vec![
3717 ("proven", Finishability::Proven),
3718 ("not_proven", Finishability::NotProven),
3719 ("failed", Finishability::Failed),
3720 ];
3721 for (label, fin) in variants {
3722 insta::assert_yaml_snapshot!(format!("finishability_{label}"), fin);
3723 }
3724 }
3725
3726 #[test]
3727 fn preflight_report_failed_snapshot() {
3728 let report = PreflightReport {
3729 plan_id: "plan-fail-preflight".to_string(),
3730 token_detected: false,
3731 finishability: Finishability::Failed,
3732 packages: vec![PreflightPackage {
3733 name: "broken".to_string(),
3734 version: "0.1.0".to_string(),
3735 already_published: false,
3736 is_new_crate: true,
3737 auth_type: None,
3738 ownership_verified: false,
3739 dry_run_passed: false,
3740 dry_run_output: Some("error: could not compile".to_string()),
3741 }],
3742 timestamp: fixed_time(),
3743 dry_run_output: Some("workspace dry-run failed".to_string()),
3744 };
3745 insta::assert_yaml_snapshot!(report);
3746 }
3747 }
3748
3749 #[cfg(test)]
3752 mod proptests {
3753 use super::*;
3754 use proptest::prelude::*;
3755
3756 proptest! {
3757 #[test]
3759 fn preflight_report_roundtrip(
3760 plan_id in "[a-z0-9-]+",
3761 token_detected in any::<bool>(),
3762 finishability_variant in 0u8..3,
3763 package_count in 0usize..10,
3764 ) {
3765 let finishability = match finishability_variant {
3766 0 => Finishability::Proven,
3767 1 => Finishability::NotProven,
3768 _ => Finishability::Failed,
3769 };
3770
3771 let packages: Vec<PreflightPackage> = (0..package_count)
3772 .map(|i| PreflightPackage {
3773 name: format!("crate-{}", i),
3774 version: format!("0.{}.0", i),
3775 already_published: i % 2 == 0,
3776 is_new_crate: i % 3 == 0,
3777 auth_type: if i % 2 == 0 { Some(AuthType::Token) } else { None },
3778 ownership_verified: i % 3 != 0,
3779 dry_run_passed: i % 5 != 0,
3780 dry_run_output: if i % 5 == 0 { Some("failed".to_string()) } else { None },
3781 })
3782 .collect();
3783
3784 let report = PreflightReport {
3785 plan_id: plan_id.clone(),
3786 token_detected,
3787 finishability,
3788 packages: packages.clone(),
3789 timestamp: Utc::now(),
3790 dry_run_output: Some("workspace dry-run output".to_string()),
3791 };
3792
3793 let json = serde_json::to_string(&report).unwrap();
3795 let parsed: PreflightReport = serde_json::from_str(&json).unwrap();
3796
3797 assert_eq!(parsed.plan_id, report.plan_id);
3799 assert_eq!(parsed.token_detected, report.token_detected);
3800 assert_eq!(parsed.finishability, report.finishability);
3801 assert_eq!(parsed.packages.len(), report.packages.len());
3802 assert_eq!(parsed.dry_run_output, report.dry_run_output);
3803 for (orig, parsed_pkg) in report.packages.iter().zip(parsed.packages.iter()) {
3804 assert_eq!(parsed_pkg.name, orig.name);
3805 assert_eq!(parsed_pkg.version, orig.version);
3806 assert_eq!(parsed_pkg.already_published, orig.already_published);
3807 assert_eq!(parsed_pkg.is_new_crate, orig.is_new_crate);
3808 assert_eq!(parsed_pkg.auth_type, orig.auth_type);
3809 assert_eq!(parsed_pkg.ownership_verified, orig.ownership_verified);
3810 assert_eq!(parsed_pkg.dry_run_passed, orig.dry_run_passed);
3811 assert_eq!(parsed_pkg.dry_run_output, orig.dry_run_output);
3812 }
3813 }
3814
3815 #[test]
3817 fn preflight_package_roundtrip(
3818 name in "[a-z][a-z0-9-]*",
3819 version in "[0-9]+\\.[0-9]+\\.[0-9]+",
3820 already_published in any::<bool>(),
3821 is_new_crate in any::<bool>(),
3822 auth_type_variant in 0u8..4,
3823 ownership_verified in any::<bool>(),
3824 dry_run_passed in any::<bool>(),
3825 dry_run_output in proptest::option::of(".*"),
3826 ) {
3827 let auth_type = match auth_type_variant {
3828 0 => Some(AuthType::Token),
3829 1 => Some(AuthType::TrustedPublishing),
3830 2 => Some(AuthType::Unknown),
3831 _ => None,
3832 };
3833
3834 let pkg = PreflightPackage {
3835 name: name.clone(),
3836 version: version.clone(),
3837 already_published,
3838 is_new_crate,
3839 auth_type: auth_type.clone(),
3840 ownership_verified,
3841 dry_run_passed,
3842 dry_run_output: dry_run_output.clone(),
3843 };
3844
3845 let json = serde_json::to_string(&pkg).unwrap();
3847 let parsed: PreflightPackage = serde_json::from_str(&json).unwrap();
3848
3849 assert_eq!(parsed.name, pkg.name);
3851 assert_eq!(parsed.version, pkg.version);
3852 assert_eq!(parsed.already_published, pkg.already_published);
3853 assert_eq!(parsed.is_new_crate, pkg.is_new_crate);
3854 assert_eq!(parsed.auth_type, pkg.auth_type);
3855 assert_eq!(parsed.ownership_verified, pkg.ownership_verified);
3856 assert_eq!(parsed.dry_run_passed, pkg.dry_run_passed);
3857 assert_eq!(parsed.dry_run_output, pkg.dry_run_output);
3858 }
3859
3860 #[test]
3862 fn auth_type_roundtrip(auth_type_variant in 0u8..3) {
3863 let auth_type = match auth_type_variant {
3864 0 => AuthType::Token,
3865 1 => AuthType::TrustedPublishing,
3866 _ => AuthType::Unknown,
3867 };
3868
3869 let json = serde_json::to_string(&auth_type).unwrap();
3870 let parsed: AuthType = serde_json::from_str(&json).unwrap();
3871
3872 assert_eq!(parsed, auth_type);
3873 }
3874
3875 #[test]
3877 fn finishability_roundtrip(finishability_variant in 0u8..3) {
3878 let finishability = match finishability_variant {
3879 0 => Finishability::Proven,
3880 1 => Finishability::NotProven,
3881 _ => Finishability::Failed,
3882 };
3883
3884 let json = serde_json::to_string(&finishability).unwrap();
3885 let parsed: Finishability = serde_json::from_str(&json).unwrap();
3886
3887 assert_eq!(parsed, finishability);
3888 }
3889
3890 #[test]
3892 fn environment_fingerprint_roundtrip(
3893 shipper_version in "[0-9]+\\.[0-9]+\\.[0-9]+",
3894 cargo_version in prop::option::of("[0-9]+\\.[0-9]+\\.[0-9]+"),
3895 rust_version in prop::option::of("[0-9]+\\.[0-9]+\\.[0-9]+"),
3896 os in "[a-z]+",
3897 arch in "[a-z0-9_]+",
3898 ) {
3899 let fingerprint = EnvironmentFingerprint {
3900 shipper_version: shipper_version.clone(),
3901 cargo_version: cargo_version.clone(),
3902 rust_version: rust_version.clone(),
3903 os: os.clone(),
3904 arch: arch.clone(),
3905 };
3906
3907 let json = serde_json::to_string(&fingerprint).unwrap();
3909 let parsed: EnvironmentFingerprint = serde_json::from_str(&json).unwrap();
3910
3911 assert_eq!(parsed.shipper_version, fingerprint.shipper_version);
3913 assert_eq!(parsed.cargo_version, fingerprint.cargo_version);
3914 assert_eq!(parsed.rust_version, fingerprint.rust_version);
3915 assert_eq!(parsed.os, fingerprint.os);
3916 assert_eq!(parsed.arch, fingerprint.arch);
3917 }
3918
3919 #[test]
3921 fn git_context_roundtrip(
3922 commit in prop::option::of("[a-f0-9]+"),
3923 branch in prop::option::of("[a-z0-9-]+"),
3924 tag in prop::option::of("[a-z0-9-\\.]+"),
3925 dirty in prop::option::of(any::<bool>()),
3926 ) {
3927 let git_context = GitContext {
3928 commit: commit.clone(),
3929 branch: branch.clone(),
3930 tag: tag.clone(),
3931 dirty,
3932 };
3933
3934 let json = serde_json::to_string(&git_context).unwrap();
3936 let parsed: GitContext = serde_json::from_str(&json).unwrap();
3937
3938 assert_eq!(parsed.commit, git_context.commit);
3940 assert_eq!(parsed.branch, git_context.branch);
3941 assert_eq!(parsed.tag, git_context.tag);
3942 assert_eq!(parsed.dirty, git_context.dirty);
3943 }
3944
3945 #[test]
3947 fn registry_roundtrip(
3948 name in "[a-z0-9-]+",
3949 api_base in "https?://[a-z0-9.-]+",
3950 index_base in prop::option::of("https?://[a-z0-9.-]+"),
3951 ) {
3952 let registry = Registry {
3953 name: name.clone(),
3954 api_base: api_base.clone(),
3955 index_base: index_base.clone(),
3956 };
3957
3958 let json = serde_json::to_string(®istry).unwrap();
3960 let parsed: Registry = serde_json::from_str(&json).unwrap();
3961
3962 assert_eq!(parsed.name, registry.name);
3964 assert_eq!(parsed.api_base, registry.api_base);
3965 assert_eq!(parsed.index_base, registry.index_base);
3966 }
3967
3968 #[test]
3970 fn readiness_config_roundtrip(
3971 enabled in any::<bool>(),
3972 method_variant in 0u8..3,
3973 initial_delay_ms in 0u64..10000,
3974 max_delay_ms in 0u64..100000,
3975 max_total_wait_ms in 0u64..1000000,
3976 poll_interval_ms in 0u64..10000,
3977 jitter_factor in 0.0f64..1.0,
3978 prefer_index in any::<bool>(),
3979 ) {
3980 let method = match method_variant {
3981 0 => ReadinessMethod::Api,
3982 1 => ReadinessMethod::Index,
3983 _ => ReadinessMethod::Both,
3984 };
3985
3986 let config = ReadinessConfig {
3987 enabled,
3988 method,
3989 initial_delay: Duration::from_millis(initial_delay_ms),
3990 max_delay: Duration::from_millis(max_delay_ms),
3991 max_total_wait: Duration::from_millis(max_total_wait_ms),
3992 poll_interval: Duration::from_millis(poll_interval_ms),
3993 jitter_factor,
3994 index_path: None,
3995 prefer_index,
3996 };
3997
3998 let json = serde_json::to_string(&config).unwrap();
4000 let parsed: ReadinessConfig = serde_json::from_str(&json).unwrap();
4001
4002 assert_eq!(parsed.enabled, config.enabled);
4004 assert_eq!(parsed.method, config.method);
4005 assert_eq!(parsed.initial_delay, config.initial_delay);
4006 assert_eq!(parsed.max_delay, config.max_delay);
4007 assert_eq!(parsed.max_total_wait, config.max_total_wait);
4008 assert_eq!(parsed.poll_interval, config.poll_interval);
4009 assert!((parsed.jitter_factor - config.jitter_factor).abs() < 1e-10,
4010 "jitter_factor mismatch: {} vs {}", parsed.jitter_factor, config.jitter_factor);
4011 assert_eq!(parsed.prefer_index, config.prefer_index);
4012 }
4013
4014 #[test]
4016 fn index_path_deterministic(crate_name in "[a-z0-9-]+") {
4017 let first = calculate_index_path_for_crate(&crate_name);
4019 let second = calculate_index_path_for_crate(&crate_name);
4020 assert_eq!(first, second, "Index path calculation should be deterministic");
4021 }
4022
4023 #[test]
4025 fn index_path_follows_pattern(crate_name in "[a-z0-9-]{3,20}") {
4026 let path = calculate_index_path_for_crate(&crate_name);
4027 let lower = crate_name.to_lowercase();
4028 let parts: Vec<&str> = path.split('/').collect();
4029
4030 match lower.len() {
4031 3 => {
4032 assert_eq!(parts.len(), 3, "3-char crate should have 3 parts");
4033 assert_eq!(parts[0], "3");
4034 assert_eq!(parts[1], &lower[..1]);
4035 assert_eq!(parts[2], lower);
4036 }
4037 n if n >= 4 => {
4038 assert_eq!(parts.len(), 3, "4+ char crate should have 3 parts");
4039 assert_eq!(parts[0], &lower[..2]);
4040 assert_eq!(parts[1], &lower[2..4]);
4041 assert_eq!(parts[2], lower);
4042 }
4043 _ => unreachable!("regex guarantees at least 3 chars"),
4044 }
4045 }
4046
4047 #[test]
4049 fn schema_version_parsing_deterministic(
4050 middle in "[a-z]+",
4051 version_num in 1u32..1000,
4052 ) {
4053 let version_str = format!("shipper.{}.v{}", middle, version_num);
4054
4055 let first = parse_schema_version_for_test(&version_str);
4056 let second = parse_schema_version_for_test(&version_str);
4057
4058 assert_eq!(first, second, "Schema version parsing should be deterministic");
4059 assert_eq!(first, Ok(version_num));
4060 }
4061 }
4062
4063 proptest! {
4065 #[test]
4066 fn package_state_pending_roundtrip(_dummy in 0u8..1) {
4067 let state = PackageState::Pending;
4068 let json = serde_json::to_string(&state).unwrap();
4069 let parsed: PackageState = serde_json::from_str(&json).unwrap();
4070 assert_eq!(parsed, state);
4071 }
4072
4073 #[test]
4074 fn package_state_uploaded_roundtrip(_dummy in 0u8..1) {
4075 let state = PackageState::Uploaded;
4076 let json = serde_json::to_string(&state).unwrap();
4077 let parsed: PackageState = serde_json::from_str(&json).unwrap();
4078 assert_eq!(parsed, state);
4079 }
4080
4081 #[test]
4082 fn package_state_published_roundtrip(_dummy in 0u8..1) {
4083 let state = PackageState::Published;
4084 let json = serde_json::to_string(&state).unwrap();
4085 let parsed: PackageState = serde_json::from_str(&json).unwrap();
4086 assert_eq!(parsed, state);
4087 }
4088
4089 #[test]
4090 fn package_state_skipped_roundtrip(reason in "\\PC{0,50}") {
4091 let state = PackageState::Skipped { reason };
4092 let json = serde_json::to_string(&state).unwrap();
4093 let parsed: PackageState = serde_json::from_str(&json).unwrap();
4094 assert_eq!(parsed, state);
4095 }
4096
4097 #[test]
4098 fn package_state_failed_roundtrip(
4099 class_variant in 0u8..3,
4100 message in "\\PC{0,80}",
4101 ) {
4102 let class = match class_variant {
4103 0 => ErrorClass::Retryable,
4104 1 => ErrorClass::Permanent,
4105 _ => ErrorClass::Ambiguous,
4106 };
4107 let state = PackageState::Failed { class, message };
4108 let json = serde_json::to_string(&state).unwrap();
4109 let parsed: PackageState = serde_json::from_str(&json).unwrap();
4110 assert_eq!(parsed, state);
4111 }
4112
4113 #[test]
4114 fn package_state_ambiguous_roundtrip(message in "\\PC{0,80}") {
4115 let state = PackageState::Ambiguous { message };
4116 let json = serde_json::to_string(&state).unwrap();
4117 let parsed: PackageState = serde_json::from_str(&json).unwrap();
4118 assert_eq!(parsed, state);
4119 }
4120
4121 #[test]
4123 fn error_class_roundtrip(variant in 0u8..3) {
4124 let class = match variant {
4125 0 => ErrorClass::Retryable,
4126 1 => ErrorClass::Permanent,
4127 _ => ErrorClass::Ambiguous,
4128 };
4129 let json = serde_json::to_string(&class).unwrap();
4130 let parsed: ErrorClass = serde_json::from_str(&json).unwrap();
4131 assert_eq!(parsed, class);
4132 }
4133
4134 #[test]
4136 fn execution_result_roundtrip(variant in 0u8..3) {
4137 let result = match variant {
4138 0 => ExecutionResult::Success,
4139 1 => ExecutionResult::PartialFailure,
4140 _ => ExecutionResult::CompleteFailure,
4141 };
4142 let json = serde_json::to_string(&result).unwrap();
4143 let parsed: ExecutionResult = serde_json::from_str(&json).unwrap();
4144 assert_eq!(parsed, result);
4145 }
4146
4147 #[test]
4149 fn publish_policy_roundtrip(variant in 0u8..3) {
4150 let policy = match variant {
4151 0 => PublishPolicy::Safe,
4152 1 => PublishPolicy::Balanced,
4153 _ => PublishPolicy::Fast,
4154 };
4155 let json = serde_json::to_string(&policy).unwrap();
4156 let parsed: PublishPolicy = serde_json::from_str(&json).unwrap();
4157 assert_eq!(parsed, policy);
4158 }
4159
4160 #[test]
4162 fn verify_mode_roundtrip(variant in 0u8..3) {
4163 let mode = match variant {
4164 0 => VerifyMode::Workspace,
4165 1 => VerifyMode::Package,
4166 _ => VerifyMode::None,
4167 };
4168 let json = serde_json::to_string(&mode).unwrap();
4169 let parsed: VerifyMode = serde_json::from_str(&json).unwrap();
4170 assert_eq!(parsed, mode);
4171 }
4172
4173 #[test]
4175 fn readiness_method_roundtrip(variant in 0u8..3) {
4176 let method = match variant {
4177 0 => ReadinessMethod::Api,
4178 1 => ReadinessMethod::Index,
4179 _ => ReadinessMethod::Both,
4180 };
4181 let json = serde_json::to_string(&method).unwrap();
4182 let parsed: ReadinessMethod = serde_json::from_str(&json).unwrap();
4183 assert_eq!(parsed, method);
4184 }
4185
4186 #[test]
4188 fn planned_package_roundtrip(
4189 name in "[a-z][a-z0-9-]{0,20}",
4190 version in "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}",
4191 ) {
4192 let pkg = PlannedPackage {
4193 name,
4194 version,
4195 manifest_path: PathBuf::from("crates/test/Cargo.toml"),
4196 };
4197 let json = serde_json::to_string(&pkg).unwrap();
4198 let parsed: PlannedPackage = serde_json::from_str(&json).unwrap();
4199 assert_eq!(parsed.name, pkg.name);
4200 assert_eq!(parsed.version, pkg.version);
4201 assert_eq!(parsed.manifest_path, pkg.manifest_path);
4202 }
4203
4204 #[test]
4206 fn publish_level_roundtrip(
4207 level in 0usize..10,
4208 pkg_count in 1usize..5,
4209 ) {
4210 let packages: Vec<PlannedPackage> = (0..pkg_count)
4211 .map(|i| PlannedPackage {
4212 name: format!("crate-{i}"),
4213 version: format!("{i}.0.0"),
4214 manifest_path: PathBuf::from(format!("crates/crate-{i}/Cargo.toml")),
4215 })
4216 .collect();
4217 let lvl = PublishLevel { level, packages };
4218 let json = serde_json::to_string(&lvl).unwrap();
4219 let parsed: PublishLevel = serde_json::from_str(&json).unwrap();
4220 assert_eq!(parsed.level, lvl.level);
4221 assert_eq!(parsed.packages.len(), lvl.packages.len());
4222 }
4223
4224 #[test]
4226 fn release_plan_roundtrip(
4227 plan_id in "[a-f0-9]{8,64}",
4228 pkg_count in 1usize..5,
4229 ) {
4230 let packages: Vec<PlannedPackage> = (0..pkg_count)
4231 .map(|i| PlannedPackage {
4232 name: format!("crate-{i}"),
4233 version: format!("{i}.0.0"),
4234 manifest_path: PathBuf::from(format!("crates/crate-{i}/Cargo.toml")),
4235 })
4236 .collect();
4237 let mut deps = BTreeMap::new();
4238 if pkg_count > 1 {
4239 deps.insert(
4240 "crate-1".to_string(),
4241 vec!["crate-0".to_string()],
4242 );
4243 }
4244 let plan = ReleasePlan {
4245 plan_version: "shipper.plan.v1".to_string(),
4246 plan_id,
4247 created_at: Utc::now(),
4248 registry: Registry::crates_io(),
4249 packages,
4250 dependencies: deps,
4251 };
4252 let json = serde_json::to_string(&plan).unwrap();
4253 let parsed: ReleasePlan = serde_json::from_str(&json).unwrap();
4254 assert_eq!(parsed.plan_id, plan.plan_id);
4255 assert_eq!(parsed.plan_version, plan.plan_version);
4256 assert_eq!(parsed.packages.len(), plan.packages.len());
4257 assert_eq!(parsed.dependencies, plan.dependencies);
4258 }
4259
4260 #[test]
4262 fn package_progress_roundtrip(
4263 name in "[a-z][a-z0-9-]{0,15}",
4264 version in "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}",
4265 attempts in 0u32..10,
4266 state_variant in 0u8..4,
4267 ) {
4268 let state = match state_variant {
4269 0 => PackageState::Pending,
4270 1 => PackageState::Uploaded,
4271 2 => PackageState::Published,
4272 _ => PackageState::Skipped { reason: "already exists".to_string() },
4273 };
4274 let progress = PackageProgress {
4275 name,
4276 version,
4277 attempts,
4278 state,
4279 last_updated_at: Utc::now(),
4280 };
4281 let json = serde_json::to_string(&progress).unwrap();
4282 let parsed: PackageProgress = serde_json::from_str(&json).unwrap();
4283 assert_eq!(parsed.name, progress.name);
4284 assert_eq!(parsed.version, progress.version);
4285 assert_eq!(parsed.attempts, progress.attempts);
4286 assert_eq!(parsed.state, progress.state);
4287 }
4288
4289 #[test]
4291 fn execution_state_roundtrip(
4292 plan_id in "[a-f0-9]{8,64}",
4293 pkg_count in 0usize..5,
4294 ) {
4295 let mut packages = BTreeMap::new();
4296 for i in 0..pkg_count {
4297 let key = format!("crate-{i}@{i}.0.0");
4298 packages.insert(key, PackageProgress {
4299 name: format!("crate-{i}"),
4300 version: format!("{i}.0.0"),
4301 attempts: i as u32,
4302 state: PackageState::Pending,
4303 last_updated_at: Utc::now(),
4304 });
4305 }
4306 let state = ExecutionState {
4307 state_version: "shipper.state.v1".to_string(),
4308 plan_id,
4309 registry: Registry::crates_io(),
4310 created_at: Utc::now(),
4311 updated_at: Utc::now(),
4312 packages,
4313 };
4314 let json = serde_json::to_string(&state).unwrap();
4315 let parsed: ExecutionState = serde_json::from_str(&json).unwrap();
4316 assert_eq!(parsed.plan_id, state.plan_id);
4317 assert_eq!(parsed.packages.len(), state.packages.len());
4318 }
4319
4320 #[test]
4322 fn parallel_config_roundtrip(
4323 enabled in any::<bool>(),
4324 max_concurrent in 1usize..32,
4325 timeout_secs in 1u64..7200,
4326 ) {
4327 let config = ParallelConfig {
4328 enabled,
4329 max_concurrent,
4330 per_package_timeout: Duration::from_secs(timeout_secs),
4331 };
4332 let json = serde_json::to_string(&config).unwrap();
4333 let parsed: ParallelConfig = serde_json::from_str(&json).unwrap();
4334 assert_eq!(parsed.enabled, config.enabled);
4335 assert_eq!(parsed.max_concurrent, config.max_concurrent);
4336 assert_eq!(parsed.per_package_timeout, config.per_package_timeout);
4337 }
4338
4339 #[test]
4341 fn attempt_evidence_roundtrip(
4342 attempt_number in 1u32..10,
4343 exit_code in -1i32..256,
4344 duration_ms in 0u64..600_000,
4345 ) {
4346 let evidence = AttemptEvidence {
4347 attempt_number,
4348 command: "cargo publish -p test".to_string(),
4349 exit_code,
4350 stdout_tail: "Uploading test v1.0.0".to_string(),
4351 stderr_tail: String::new(),
4352 timestamp: Utc::now(),
4353 duration: Duration::from_millis(duration_ms),
4354 };
4355 let json = serde_json::to_string(&evidence).unwrap();
4356 let parsed: AttemptEvidence = serde_json::from_str(&json).unwrap();
4357 assert_eq!(parsed.attempt_number, evidence.attempt_number);
4358 assert_eq!(parsed.exit_code, evidence.exit_code);
4359 assert_eq!(parsed.duration, evidence.duration);
4360 }
4361
4362 #[test]
4364 fn readiness_evidence_roundtrip(
4365 attempt in 1u32..20,
4366 visible in any::<bool>(),
4367 delay_ms in 0u64..120_000,
4368 ) {
4369 let evidence = ReadinessEvidence {
4370 attempt,
4371 visible,
4372 timestamp: Utc::now(),
4373 delay_before: Duration::from_millis(delay_ms),
4374 };
4375 let json = serde_json::to_string(&evidence).unwrap();
4376 let parsed: ReadinessEvidence = serde_json::from_str(&json).unwrap();
4377 assert_eq!(parsed.attempt, evidence.attempt);
4378 assert_eq!(parsed.visible, evidence.visible);
4379 assert_eq!(parsed.delay_before, evidence.delay_before);
4380 }
4381
4382 #[test]
4384 fn package_evidence_roundtrip(attempt_count in 0usize..4) {
4385 let attempts: Vec<AttemptEvidence> = (0..attempt_count)
4386 .map(|i| AttemptEvidence {
4387 attempt_number: i as u32 + 1,
4388 command: format!("cargo publish attempt {i}"),
4389 exit_code: if i == attempt_count - 1 { 0 } else { 1 },
4390 stdout_tail: "output".to_string(),
4391 stderr_tail: String::new(),
4392 timestamp: Utc::now(),
4393 duration: Duration::from_secs(5),
4394 })
4395 .collect();
4396 let evidence = PackageEvidence {
4397 attempts,
4398 readiness_checks: vec![],
4399 };
4400 let json = serde_json::to_string(&evidence).unwrap();
4401 let parsed: PackageEvidence = serde_json::from_str(&json).unwrap();
4402 assert_eq!(parsed.attempts.len(), evidence.attempts.len());
4403 }
4404
4405 #[test]
4407 fn package_receipt_roundtrip(
4408 name in "[a-z][a-z0-9-]{0,15}",
4409 version in "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}",
4410 attempts in 1u32..5,
4411 duration_ms in 0u128..600_000,
4412 ) {
4413 let now = Utc::now();
4414 let receipt = PackageReceipt {
4415 name,
4416 version,
4417 attempts,
4418 state: PackageState::Published,
4419 started_at: now,
4420 finished_at: now,
4421 duration_ms,
4422 evidence: PackageEvidence {
4423 attempts: vec![],
4424 readiness_checks: vec![],
4425 },
4426 compromised_at: None,
4427 compromised_by: None,
4428 superseded_by: None,
4429 };
4430 let json = serde_json::to_string(&receipt).unwrap();
4431 let parsed: PackageReceipt = serde_json::from_str(&json).unwrap();
4432 assert_eq!(parsed.name, receipt.name);
4433 assert_eq!(parsed.version, receipt.version);
4434 assert_eq!(parsed.attempts, receipt.attempts);
4435 assert_eq!(parsed.state, receipt.state);
4436 assert_eq!(parsed.duration_ms, receipt.duration_ms);
4437 }
4438
4439 #[test]
4441 fn receipt_roundtrip(
4442 plan_id in "[a-f0-9]{8,64}",
4443 pkg_count in 0usize..3,
4444 ) {
4445 let now = Utc::now();
4446 let packages: Vec<PackageReceipt> = (0..pkg_count)
4447 .map(|i| PackageReceipt {
4448 name: format!("crate-{i}"),
4449 version: format!("{i}.0.0"),
4450 attempts: 1,
4451 state: PackageState::Published,
4452 started_at: now,
4453 finished_at: now,
4454 duration_ms: 1000,
4455 evidence: PackageEvidence {
4456 attempts: vec![],
4457 readiness_checks: vec![],
4458 },
4459 compromised_at: None,
4460 compromised_by: None,
4461 superseded_by: None,
4462 })
4463 .collect();
4464 let receipt = Receipt {
4465 receipt_version: "shipper.receipt.v1".to_string(),
4466 plan_id,
4467 registry: Registry::crates_io(),
4468 started_at: now,
4469 finished_at: now,
4470 packages,
4471 event_log_path: PathBuf::from(".shipper/events.jsonl"),
4472 git_context: Some(GitContext {
4473 commit: Some("abc123".to_string()),
4474 branch: Some("main".to_string()),
4475 tag: None,
4476 dirty: Some(false),
4477 }),
4478 environment: EnvironmentFingerprint {
4479 shipper_version: "0.3.0".to_string(),
4480 cargo_version: Some("1.80.0".to_string()),
4481 rust_version: Some("1.80.0".to_string()),
4482 os: "linux".to_string(),
4483 arch: "x86_64".to_string(),
4484 },
4485 };
4486 let json = serde_json::to_string(&receipt).unwrap();
4487 let parsed: Receipt = serde_json::from_str(&json).unwrap();
4488 assert_eq!(parsed.plan_id, receipt.plan_id);
4489 assert_eq!(parsed.packages.len(), receipt.packages.len());
4490 assert_eq!(parsed.receipt_version, receipt.receipt_version);
4491 assert!(parsed.git_context.is_some());
4492 }
4493
4494 #[test]
4496 fn publish_event_roundtrip(variant in 0u8..5) {
4497 let event_type = match variant {
4498 0 => EventType::ExecutionStarted,
4499 1 => EventType::PlanCreated {
4500 plan_id: "abc".to_string(),
4501 package_count: 3,
4502 },
4503 2 => EventType::PackageStarted {
4504 name: "test".to_string(),
4505 version: "1.0.0".to_string(),
4506 },
4507 3 => EventType::PackageFailed {
4508 class: ErrorClass::Retryable,
4509 message: "timeout".to_string(),
4510 },
4511 _ => EventType::ExecutionFinished {
4512 result: ExecutionResult::Success,
4513 },
4514 };
4515 let event = PublishEvent {
4516 timestamp: Utc::now(),
4517 event_type,
4518 package: "test@1.0.0".to_string(),
4519 };
4520 let json = serde_json::to_string(&event).unwrap();
4521 let parsed: PublishEvent = serde_json::from_str(&json).unwrap();
4522 assert_eq!(parsed.package, event.package);
4523 }
4524
4525 #[test]
4527 fn event_type_all_variants_roundtrip(variant in 0u8..18) {
4528 let event_type = match variant {
4529 0 => EventType::PlanCreated { plan_id: "id1".to_string(), package_count: 5 },
4530 1 => EventType::ExecutionStarted,
4531 2 => EventType::ExecutionFinished { result: ExecutionResult::Success },
4532 3 => EventType::PackageStarted { name: "a".to_string(), version: "1.0.0".to_string() },
4533 4 => EventType::PackageAttempted { attempt: 1, command: "cargo publish".to_string() },
4534 5 => EventType::PackageOutput { stdout_tail: "ok".to_string(), stderr_tail: "".to_string() },
4535 6 => EventType::PackagePublished { duration_ms: 100 },
4536 7 => EventType::PackageFailed { class: ErrorClass::Retryable, message: "err".to_string() },
4537 8 => EventType::PackageSkipped { reason: "exists".to_string() },
4538 9 => EventType::ReadinessStarted { method: ReadinessMethod::Api },
4539 10 => EventType::ReadinessPoll { attempt: 1, visible: false },
4540 11 => EventType::ReadinessComplete { duration_ms: 500, attempts: 3 },
4541 12 => EventType::ReadinessTimeout { max_wait_ms: 60000 },
4542 13 => EventType::IndexReadinessStarted { crate_name: "a".to_string(), version: "1.0.0".to_string() },
4543 14 => EventType::IndexReadinessCheck { crate_name: "a".to_string(), version: "1.0.0".to_string(), found: true },
4544 15 => EventType::IndexReadinessComplete { crate_name: "a".to_string(), version: "1.0.0".to_string(), visible: true },
4545 16 => EventType::PreflightStarted,
4546 _ => EventType::PreflightComplete { finishability: Finishability::Proven },
4547 };
4548 let json = serde_json::to_string(&event_type).unwrap();
4549 let _parsed: EventType = serde_json::from_str(&json).unwrap();
4550 }
4551 }
4552
4553 fn valid_next_states(state: &PackageState) -> Vec<PackageState> {
4557 match state {
4558 PackageState::Pending => vec![
4559 PackageState::Uploaded,
4560 PackageState::Failed {
4561 class: ErrorClass::Retryable,
4562 message: "err".to_string(),
4563 },
4564 PackageState::Skipped {
4565 reason: "already published".to_string(),
4566 },
4567 ],
4568 PackageState::Uploaded => vec![
4569 PackageState::Published,
4570 PackageState::Failed {
4571 class: ErrorClass::Retryable,
4572 message: "readiness timeout".to_string(),
4573 },
4574 PackageState::Ambiguous {
4575 message: "unclear".to_string(),
4576 },
4577 ],
4578 PackageState::Failed { .. } => vec![
4579 PackageState::Pending, ],
4581 PackageState::Published => vec![],
4583 PackageState::Skipped { .. } => vec![],
4584 PackageState::Ambiguous { .. } => vec![],
4585 }
4586 }
4587
4588 fn is_terminal(state: &PackageState) -> bool {
4589 matches!(
4590 state,
4591 PackageState::Published
4592 | PackageState::Skipped { .. }
4593 | PackageState::Ambiguous { .. }
4594 )
4595 }
4596
4597 proptest! {
4598 #[test]
4599 fn package_state_transitions_are_valid(
4600 start_variant in 0u8..6,
4601 ) {
4602 let start = match start_variant {
4603 0 => PackageState::Pending,
4604 1 => PackageState::Uploaded,
4605 2 => PackageState::Published,
4606 3 => PackageState::Skipped { reason: "exists".to_string() },
4607 4 => PackageState::Failed { class: ErrorClass::Retryable, message: "err".to_string() },
4608 _ => PackageState::Ambiguous { message: "unclear".to_string() },
4609 };
4610
4611 let nexts = valid_next_states(&start);
4612 if is_terminal(&start) {
4613 assert!(nexts.is_empty(), "Terminal state {:?} should have no valid transitions", start);
4614 } else {
4615 assert!(!nexts.is_empty(), "Non-terminal state {:?} should have valid transitions", start);
4616 }
4617 }
4618
4619 #[test]
4621 fn failed_state_can_retry(
4622 class_variant in 0u8..3,
4623 message in "[a-z ]{1,30}",
4624 ) {
4625 let class = match class_variant {
4626 0 => ErrorClass::Retryable,
4627 1 => ErrorClass::Permanent,
4628 _ => ErrorClass::Ambiguous,
4629 };
4630 let failed = PackageState::Failed { class, message };
4631 let nexts = valid_next_states(&failed);
4632 assert!(nexts.contains(&PackageState::Pending),
4633 "Failed state should allow retry to Pending");
4634 }
4635
4636 #[test]
4638 fn pending_has_expected_transitions(_dummy in 0u8..1) {
4639 let nexts = valid_next_states(&PackageState::Pending);
4640 assert_eq!(nexts.len(), 3);
4641 assert!(matches!(nexts[0], PackageState::Uploaded));
4642 assert!(matches!(nexts[1], PackageState::Failed { .. }));
4643 assert!(matches!(nexts[2], PackageState::Skipped { .. }));
4644 }
4645 }
4646
4647 proptest! {
4650 #[test]
4652 fn plan_id_deterministic_for_same_inputs(
4653 pkg_count in 1usize..6,
4654 seed in 0u64..1000,
4655 ) {
4656 use std::collections::hash_map::DefaultHasher;
4657 use std::hash::{Hash, Hasher};
4658
4659 fn compute_plan_id(packages: &[PlannedPackage], registry_name: &str) -> String {
4661 let mut hasher = DefaultHasher::new();
4662 registry_name.hash(&mut hasher);
4663 for pkg in packages {
4664 pkg.name.hash(&mut hasher);
4665 pkg.version.hash(&mut hasher);
4666 }
4667 format!("{:016x}", hasher.finish())
4668 }
4669
4670 let packages: Vec<PlannedPackage> = (0..pkg_count)
4671 .map(|i| PlannedPackage {
4672 name: format!("crate-{}-{}", seed, i),
4673 version: format!("{}.0.0", i),
4674 manifest_path: PathBuf::from(format!("crates/crate-{i}/Cargo.toml")),
4675 })
4676 .collect();
4677
4678 let id1 = compute_plan_id(&packages, "crates-io");
4679 let id2 = compute_plan_id(&packages, "crates-io");
4680 assert_eq!(id1, id2, "Same inputs must produce the same plan_id");
4681 }
4682
4683 #[test]
4685 fn plan_id_differs_for_different_inputs(
4686 seed in 0u64..1000,
4687 ) {
4688 use std::collections::hash_map::DefaultHasher;
4689 use std::hash::{Hash, Hasher};
4690
4691 fn compute_plan_id(packages: &[PlannedPackage], registry_name: &str) -> String {
4692 let mut hasher = DefaultHasher::new();
4693 registry_name.hash(&mut hasher);
4694 for pkg in packages {
4695 pkg.name.hash(&mut hasher);
4696 pkg.version.hash(&mut hasher);
4697 }
4698 format!("{:016x}", hasher.finish())
4699 }
4700
4701 let pkgs_a = vec![PlannedPackage {
4702 name: format!("crate-a-{seed}"),
4703 version: "1.0.0".to_string(),
4704 manifest_path: PathBuf::from("Cargo.toml"),
4705 }];
4706 let pkgs_b = vec![PlannedPackage {
4707 name: format!("crate-b-{seed}"),
4708 version: "1.0.0".to_string(),
4709 manifest_path: PathBuf::from("Cargo.toml"),
4710 }];
4711
4712 let id_a = compute_plan_id(&pkgs_a, "crates-io");
4713 let id_b = compute_plan_id(&pkgs_b, "crates-io");
4714 assert_ne!(id_a, id_b, "Different inputs must produce different plan_ids");
4715 }
4716 }
4717
4718 fn build_receipt_from_state(state: &ExecutionState) -> Receipt {
4721 let now = Utc::now();
4722 let packages: Vec<PackageReceipt> = state
4723 .packages
4724 .values()
4725 .map(|progress| PackageReceipt {
4726 name: progress.name.clone(),
4727 version: progress.version.clone(),
4728 attempts: progress.attempts,
4729 state: progress.state.clone(),
4730 started_at: state.created_at,
4731 finished_at: now,
4732 duration_ms: 0,
4733 evidence: PackageEvidence {
4734 attempts: vec![],
4735 readiness_checks: vec![],
4736 },
4737 compromised_at: None,
4738 compromised_by: None,
4739 superseded_by: None,
4740 })
4741 .collect();
4742
4743 Receipt {
4744 receipt_version: "shipper.receipt.v1".to_string(),
4745 plan_id: state.plan_id.clone(),
4746 registry: state.registry.clone(),
4747 started_at: state.created_at,
4748 finished_at: now,
4749 packages,
4750 event_log_path: PathBuf::from(".shipper/events.jsonl"),
4751 git_context: None,
4752 environment: EnvironmentFingerprint {
4753 shipper_version: "0.3.0".to_string(),
4754 cargo_version: None,
4755 rust_version: None,
4756 os: "test".to_string(),
4757 arch: "test".to_string(),
4758 },
4759 }
4760 }
4761
4762 proptest! {
4763 #[test]
4764 fn receipt_from_state_preserves_plan_id(
4765 plan_id in "[a-f0-9]{8,32}",
4766 pkg_count in 0usize..5,
4767 ) {
4768 let mut packages = BTreeMap::new();
4769 for i in 0..pkg_count {
4770 packages.insert(
4771 format!("pkg-{i}@{i}.0.0"),
4772 PackageProgress {
4773 name: format!("pkg-{i}"),
4774 version: format!("{i}.0.0"),
4775 attempts: 1,
4776 state: PackageState::Published,
4777 last_updated_at: Utc::now(),
4778 },
4779 );
4780 }
4781 let state = ExecutionState {
4782 state_version: "shipper.state.v1".to_string(),
4783 plan_id: plan_id.clone(),
4784 registry: Registry::crates_io(),
4785 created_at: Utc::now(),
4786 updated_at: Utc::now(),
4787 packages,
4788 };
4789
4790 let receipt = build_receipt_from_state(&state);
4791 assert_eq!(receipt.plan_id, plan_id);
4792 assert_eq!(receipt.packages.len(), pkg_count);
4793 for pkg_receipt in &receipt.packages {
4794 assert!(state.packages.values().any(|p| p.name == pkg_receipt.name));
4795 assert_eq!(pkg_receipt.state, PackageState::Published);
4796 }
4797 }
4798
4799 #[test]
4800 fn receipt_from_state_includes_all_packages(pkg_count in 1usize..8) {
4801 let mut packages = BTreeMap::new();
4802 for i in 0..pkg_count {
4803 let state_variant = match i % 3 {
4804 0 => PackageState::Published,
4805 1 => PackageState::Skipped { reason: "exists".to_string() },
4806 _ => PackageState::Failed {
4807 class: ErrorClass::Permanent,
4808 message: "auth failure".to_string(),
4809 },
4810 };
4811 packages.insert(
4812 format!("pkg-{i}@{i}.0.0"),
4813 PackageProgress {
4814 name: format!("pkg-{i}"),
4815 version: format!("{i}.0.0"),
4816 attempts: (i as u32) + 1,
4817 state: state_variant,
4818 last_updated_at: Utc::now(),
4819 },
4820 );
4821 }
4822 let state = ExecutionState {
4823 state_version: "shipper.state.v1".to_string(),
4824 plan_id: "test-plan".to_string(),
4825 registry: Registry::crates_io(),
4826 created_at: Utc::now(),
4827 updated_at: Utc::now(),
4828 packages,
4829 };
4830
4831 let receipt = build_receipt_from_state(&state);
4832 assert_eq!(receipt.packages.len(), pkg_count);
4833 let json = serde_json::to_string(&receipt).unwrap();
4835 let parsed: Receipt = serde_json::from_str(&json).unwrap();
4836 assert_eq!(parsed.packages.len(), pkg_count);
4837 }
4838 }
4839
4840 fn parse_version(v: &str) -> Option<(u32, u32, u32, Option<String>)> {
4844 let (main, pre) = if let Some(idx) = v.find('-') {
4845 (&v[..idx], Some(v[idx + 1..].to_string()))
4846 } else {
4847 (v, None)
4848 };
4849 let parts: Vec<&str> = main.split('.').collect();
4850 if parts.len() != 3 {
4851 return None;
4852 }
4853 let major = parts[0].parse::<u32>().ok()?;
4854 let minor = parts[1].parse::<u32>().ok()?;
4855 let patch = parts[2].parse::<u32>().ok()?;
4856 Some((major, minor, patch, pre))
4857 }
4858
4859 fn format_version(major: u32, minor: u32, patch: u32, pre: Option<&str>) -> String {
4860 match pre {
4861 Some(p) => format!("{major}.{minor}.{patch}-{p}"),
4862 None => format!("{major}.{minor}.{patch}"),
4863 }
4864 }
4865
4866 proptest! {
4867 #[test]
4869 fn version_string_roundtrip(
4870 major in 0u32..100,
4871 minor in 0u32..100,
4872 patch in 0u32..100,
4873 ) {
4874 let version = format!("{major}.{minor}.{patch}");
4875 let (m, mi, p, pre) = parse_version(&version).unwrap();
4876 assert_eq!(m, major);
4877 assert_eq!(mi, minor);
4878 assert_eq!(p, patch);
4879 assert!(pre.is_none());
4880 let reconstructed = format_version(m, mi, p, pre.as_deref());
4881 assert_eq!(reconstructed, version);
4882 }
4883
4884 #[test]
4886 fn version_string_with_prerelease_roundtrip(
4887 major in 0u32..100,
4888 minor in 0u32..100,
4889 patch in 0u32..100,
4890 pre_tag in "[a-z]{1,5}\\.[0-9]{1,3}",
4891 ) {
4892 let version = format!("{major}.{minor}.{patch}-{pre_tag}");
4893 let (m, mi, p, pre) = parse_version(&version).unwrap();
4894 assert_eq!(m, major);
4895 assert_eq!(mi, minor);
4896 assert_eq!(p, patch);
4897 assert_eq!(pre.as_deref(), Some(pre_tag.as_str()));
4898 let reconstructed = format_version(m, mi, p, pre.as_deref());
4899 assert_eq!(reconstructed, version);
4900 }
4901
4902 #[test]
4904 fn version_in_planned_package_roundtrip(
4905 major in 0u32..100,
4906 minor in 0u32..100,
4907 patch in 0u32..100,
4908 ) {
4909 let version = format!("{major}.{minor}.{patch}");
4910 let pkg = PlannedPackage {
4911 name: "test-crate".to_string(),
4912 version: version.clone(),
4913 manifest_path: PathBuf::from("Cargo.toml"),
4914 };
4915 let json = serde_json::to_string(&pkg).unwrap();
4916 let parsed: PlannedPackage = serde_json::from_str(&json).unwrap();
4917 let (m, mi, p, _) = parse_version(&parsed.version).unwrap();
4918 assert_eq!((m, mi, p), (major, minor, patch));
4919 }
4920 }
4921
4922 proptest! {
4925 #[test]
4926 fn release_plan_with_custom_registry_roundtrip(
4927 plan_id in "[a-f0-9]{8,64}",
4928 registry_name in "[a-z][a-z0-9-]{0,15}",
4929 api_base in "https://[a-z]{3,10}\\.[a-z]{2,5}",
4930 index_base in prop::option::of("https://index\\.[a-z]{3,10}\\.[a-z]{2,5}"),
4931 pkg_count in 1usize..6,
4932 dep_count in 0usize..3,
4933 ) {
4934 let packages: Vec<PlannedPackage> = (0..pkg_count)
4935 .map(|i| PlannedPackage {
4936 name: format!("crate-{i}"),
4937 version: format!("{}.0.0", i + 1),
4938 manifest_path: PathBuf::from(format!("crates/crate-{i}/Cargo.toml")),
4939 })
4940 .collect();
4941 let mut deps = BTreeMap::new();
4942 for d in 0..dep_count.min(pkg_count.saturating_sub(1)) {
4943 deps.insert(
4944 format!("crate-{}", d + 1),
4945 vec![format!("crate-{d}")],
4946 );
4947 }
4948 let plan = ReleasePlan {
4949 plan_version: "shipper.plan.v1".to_string(),
4950 plan_id: plan_id.clone(),
4951 created_at: Utc::now(),
4952 registry: Registry {
4953 name: registry_name.clone(),
4954 api_base: api_base.clone(),
4955 index_base: index_base.clone(),
4956 },
4957 packages,
4958 dependencies: deps.clone(),
4959 };
4960 let json = serde_json::to_string(&plan).unwrap();
4961 let parsed: ReleasePlan = serde_json::from_str(&json).unwrap();
4962 assert_eq!(parsed.plan_id, plan_id);
4963 assert_eq!(parsed.registry.name, registry_name);
4964 assert_eq!(parsed.registry.api_base, api_base);
4965 assert_eq!(parsed.registry.index_base, index_base);
4966 assert_eq!(parsed.packages.len(), pkg_count);
4967 assert_eq!(parsed.dependencies, deps);
4968 }
4969 }
4970
4971 proptest! {
4974 #[test]
4975 fn runtime_options_durations_positive(
4976 base_delay_ms in 1u64..60_000,
4977 max_delay_ms in 1u64..600_000,
4978 verify_timeout_ms in 1u64..3_600_000,
4979 verify_poll_ms in 1u64..60_000,
4980 lock_timeout_ms in 1u64..86_400_000,
4981 pkg_timeout_ms in 1u64..7_200_000,
4982 readiness_initial_ms in 1u64..10_000,
4983 readiness_max_ms in 1u64..120_000,
4984 readiness_total_ms in 1u64..600_000,
4985 readiness_poll_ms in 1u64..10_000,
4986 ) {
4987 let opts = RuntimeOptions {
4988 allow_dirty: false,
4989 skip_ownership_check: false,
4990 strict_ownership: false,
4991 no_verify: false,
4992 max_attempts: 3,
4993 base_delay: Duration::from_millis(base_delay_ms),
4994 max_delay: Duration::from_millis(max_delay_ms),
4995 retry_strategy: shipper_retry::RetryStrategyType::Exponential,
4996 retry_jitter: 0.5,
4997 retry_per_error: shipper_retry::PerErrorConfig::default(),
4998 verify_timeout: Duration::from_millis(verify_timeout_ms),
4999 verify_poll_interval: Duration::from_millis(verify_poll_ms),
5000 state_dir: PathBuf::from(".shipper"),
5001 force_resume: false,
5002 policy: PublishPolicy::Safe,
5003 verify_mode: VerifyMode::Workspace,
5004 readiness: ReadinessConfig {
5005 enabled: true,
5006 method: ReadinessMethod::Api,
5007 initial_delay: Duration::from_millis(readiness_initial_ms),
5008 max_delay: Duration::from_millis(readiness_max_ms),
5009 max_total_wait: Duration::from_millis(readiness_total_ms),
5010 poll_interval: Duration::from_millis(readiness_poll_ms),
5011 jitter_factor: 0.5,
5012 index_path: None,
5013 prefer_index: false,
5014 },
5015 output_lines: 1000,
5016 force: false,
5017 lock_timeout: Duration::from_millis(lock_timeout_ms),
5018 parallel: ParallelConfig {
5019 enabled: false,
5020 max_concurrent: 4,
5021 per_package_timeout: Duration::from_millis(pkg_timeout_ms),
5022 },
5023 webhook: WebhookConfig::default(),
5024 encryption: EncryptionSettings::default(),
5025 registries: vec![],
5026 resume_from: None,
5027 rehearsal_registry: None,
5028 rehearsal_skip: false,
5029 rehearsal_smoke_install: None,
5030 };
5031
5032 assert!(opts.base_delay > Duration::ZERO);
5034 assert!(opts.max_delay > Duration::ZERO);
5035 assert!(opts.verify_timeout > Duration::ZERO);
5036 assert!(opts.verify_poll_interval > Duration::ZERO);
5037 assert!(opts.lock_timeout > Duration::ZERO);
5038 assert!(opts.parallel.per_package_timeout > Duration::ZERO);
5039 assert!(opts.readiness.initial_delay > Duration::ZERO);
5040 assert!(opts.readiness.max_delay > Duration::ZERO);
5041 assert!(opts.readiness.max_total_wait > Duration::ZERO);
5042 assert!(opts.readiness.poll_interval > Duration::ZERO);
5043 }
5044 }
5045
5046 proptest! {
5049 #[test]
5050 fn receipt_with_mixed_states_roundtrip(
5051 plan_id in "[a-f0-9]{8,32}",
5052 pkg_count in 1usize..6,
5053 git_commit in prop::option::of("[a-f0-9]{7,40}"),
5054 git_branch in prop::option::of("[a-z0-9/-]{1,20}"),
5055 shipper_ver in "[0-9]{1,2}\\.[0-9]{1,2}\\.[0-9]{1,2}",
5056 os_name in "[a-z]{3,10}",
5057 ) {
5058 let now = Utc::now();
5059 let packages: Vec<PackageReceipt> = (0..pkg_count)
5060 .map(|i| {
5061 let state = match i % 5 {
5062 0 => PackageState::Published,
5063 1 => PackageState::Skipped { reason: "already exists".to_string() },
5064 2 => PackageState::Failed {
5065 class: ErrorClass::Permanent,
5066 message: "auth error".to_string(),
5067 },
5068 3 => PackageState::Ambiguous { message: "timeout".to_string() },
5069 _ => PackageState::Uploaded,
5070 };
5071 PackageReceipt {
5072 name: format!("crate-{i}"),
5073 version: format!("{i}.1.0"),
5074 attempts: (i as u32) + 1,
5075 state,
5076 started_at: now,
5077 finished_at: now,
5078 duration_ms: (i as u128 + 1) * 500,
5079 evidence: PackageEvidence {
5080 attempts: vec![],
5081 readiness_checks: vec![],
5082 },
5083 compromised_at: None,
5084 compromised_by: None,
5085 superseded_by: None,
5086 }
5087 })
5088 .collect();
5089 let receipt = Receipt {
5090 receipt_version: "shipper.receipt.v1".to_string(),
5091 plan_id: plan_id.clone(),
5092 registry: Registry::crates_io(),
5093 started_at: now,
5094 finished_at: now,
5095 packages: packages.clone(),
5096 event_log_path: PathBuf::from(".shipper/events.jsonl"),
5097 git_context: Some(GitContext {
5098 commit: git_commit.clone(),
5099 branch: git_branch.clone(),
5100 tag: None,
5101 dirty: Some(false),
5102 }),
5103 environment: EnvironmentFingerprint {
5104 shipper_version: shipper_ver.clone(),
5105 cargo_version: None,
5106 rust_version: None,
5107 os: os_name.clone(),
5108 arch: "x86_64".to_string(),
5109 },
5110 };
5111 let json = serde_json::to_string(&receipt).unwrap();
5112 let parsed: Receipt = serde_json::from_str(&json).unwrap();
5113 assert_eq!(parsed.plan_id, plan_id);
5114 assert_eq!(parsed.packages.len(), pkg_count);
5115 assert_eq!(parsed.environment.shipper_version, shipper_ver);
5116 assert_eq!(parsed.environment.os, os_name);
5117 let ctx = parsed.git_context.unwrap();
5118 assert_eq!(ctx.commit, git_commit);
5119 assert_eq!(ctx.branch, git_branch);
5120 for (orig, p) in packages.iter().zip(parsed.packages.iter()) {
5121 assert_eq!(p.name, orig.name);
5122 assert_eq!(p.state, orig.state);
5123 assert_eq!(p.duration_ms, orig.duration_ms);
5124 }
5125 }
5126 }
5127
5128 proptest! {
5131 #[test]
5132 fn execution_state_with_varied_states_roundtrip(
5133 plan_id in "[a-f0-9]{8,32}",
5134 pkg_count in 1usize..6,
5135 ) {
5136 let mut packages = BTreeMap::new();
5137 for i in 0..pkg_count {
5138 let state = match i % 5 {
5139 0 => PackageState::Pending,
5140 1 => PackageState::Uploaded,
5141 2 => PackageState::Published,
5142 3 => PackageState::Skipped { reason: "exists".to_string() },
5143 _ => PackageState::Failed {
5144 class: ErrorClass::Retryable,
5145 message: "timeout".to_string(),
5146 },
5147 };
5148 packages.insert(
5149 format!("crate-{i}@{i}.0.0"),
5150 PackageProgress {
5151 name: format!("crate-{i}"),
5152 version: format!("{i}.0.0"),
5153 attempts: (i as u32) + 1,
5154 state,
5155 last_updated_at: Utc::now(),
5156 },
5157 );
5158 }
5159 let exec_state = ExecutionState {
5160 state_version: "shipper.state.v1".to_string(),
5161 plan_id: plan_id.clone(),
5162 registry: Registry::crates_io(),
5163 created_at: Utc::now(),
5164 updated_at: Utc::now(),
5165 packages: packages.clone(),
5166 };
5167 let json = serde_json::to_string(&exec_state).unwrap();
5168 let parsed: ExecutionState = serde_json::from_str(&json).unwrap();
5169 assert_eq!(parsed.plan_id, plan_id);
5170 assert_eq!(parsed.packages.len(), pkg_count);
5171 for (key, orig) in &packages {
5172 let p = parsed.packages.get(key).unwrap();
5173 assert_eq!(p.name, orig.name);
5174 assert_eq!(p.version, orig.version);
5175 assert_eq!(p.attempts, orig.attempts);
5176 assert_eq!(p.state, orig.state);
5177 }
5178 }
5179 }
5180
5181 fn state_ordinal(state: &PackageState) -> u8 {
5186 match state {
5187 PackageState::Pending => 0,
5188 PackageState::Uploaded => 1,
5189 PackageState::Published => 2,
5190 PackageState::Skipped { .. } => 2, PackageState::Failed { .. } => 1, PackageState::Ambiguous { .. } => 2, }
5194 }
5195
5196 proptest! {
5197 #[test]
5199 fn package_state_forward_transitions_monotonic(
5200 start_variant in 0u8..6,
5201 ) {
5202 let start = match start_variant {
5203 0 => PackageState::Pending,
5204 1 => PackageState::Uploaded,
5205 2 => PackageState::Published,
5206 3 => PackageState::Skipped { reason: "exists".to_string() },
5207 4 => PackageState::Failed {
5208 class: ErrorClass::Retryable,
5209 message: "err".to_string(),
5210 },
5211 _ => PackageState::Ambiguous { message: "unclear".to_string() },
5212 };
5213 let start_ord = state_ordinal(&start);
5214 let nexts = valid_next_states(&start);
5215 for next in &nexts {
5216 let is_retry = matches!(
5218 (&start, next),
5219 (PackageState::Failed { .. }, PackageState::Pending)
5220 );
5221 if !is_retry {
5222 assert!(
5223 state_ordinal(next) >= start_ord,
5224 "Non-retry transition {:?} -> {:?} must not decrease ordinal ({} -> {})",
5225 start, next, start_ord, state_ordinal(next)
5226 );
5227 }
5228 }
5229 }
5230
5231 #[test]
5233 fn happy_path_is_strictly_monotonic(_dummy in 0u8..1) {
5234 let path = [
5235 PackageState::Pending,
5236 PackageState::Uploaded,
5237 PackageState::Published,
5238 ];
5239 for w in path.windows(2) {
5240 assert!(
5241 state_ordinal(&w[1]) > state_ordinal(&w[0]),
5242 "Happy path must be strictly increasing: {:?} -> {:?}",
5243 w[0], w[1]
5244 );
5245 }
5246 }
5247
5248 #[test]
5250 fn terminal_states_have_no_transitions(variant in 0u8..3) {
5251 let state = match variant {
5252 0 => PackageState::Published,
5253 1 => PackageState::Skipped { reason: "exists".to_string() },
5254 _ => PackageState::Ambiguous { message: "unclear".to_string() },
5255 };
5256 let nexts = valid_next_states(&state);
5257 assert!(
5258 nexts.is_empty(),
5259 "Terminal state {:?} must have no transitions but has {:?}",
5260 state, nexts
5261 );
5262 }
5263 }
5264
5265 proptest! {
5268 #[test]
5269 fn package_state_debug_never_panics(
5270 variant in 0u8..6,
5271 message in "\\PC{0,200}",
5272 ) {
5273 let state = match variant {
5274 0 => PackageState::Pending,
5275 1 => PackageState::Uploaded,
5276 2 => PackageState::Published,
5277 3 => PackageState::Skipped { reason: message.clone() },
5278 4 => PackageState::Failed {
5279 class: ErrorClass::Retryable,
5280 message: message.clone(),
5281 },
5282 _ => PackageState::Ambiguous { message },
5283 };
5284 let debug = format!("{:?}", state);
5285 assert!(!debug.is_empty());
5286 }
5287
5288 #[test]
5289 fn error_class_debug_never_panics(variant in 0u8..3) {
5290 let class = match variant {
5291 0 => ErrorClass::Retryable,
5292 1 => ErrorClass::Permanent,
5293 _ => ErrorClass::Ambiguous,
5294 };
5295 let debug = format!("{:?}", class);
5296 assert!(!debug.is_empty());
5297 }
5298
5299 #[test]
5300 fn execution_result_debug_never_panics(variant in 0u8..3) {
5301 let result = match variant {
5302 0 => ExecutionResult::Success,
5303 1 => ExecutionResult::PartialFailure,
5304 _ => ExecutionResult::CompleteFailure,
5305 };
5306 let debug = format!("{:?}", result);
5307 assert!(!debug.is_empty());
5308 }
5309
5310 #[test]
5311 fn finishability_debug_never_panics(variant in 0u8..3) {
5312 let fin = match variant {
5313 0 => Finishability::Proven,
5314 1 => Finishability::NotProven,
5315 _ => Finishability::Failed,
5316 };
5317 let debug = format!("{:?}", fin);
5318 assert!(!debug.is_empty());
5319 }
5320
5321 #[test]
5322 fn event_type_debug_never_panics(
5323 variant in 0u8..18,
5324 msg in "\\PC{0,100}",
5325 ) {
5326 let event_type = match variant {
5327 0 => EventType::PlanCreated { plan_id: msg.clone(), package_count: 5 },
5328 1 => EventType::ExecutionStarted,
5329 2 => EventType::ExecutionFinished { result: ExecutionResult::Success },
5330 3 => EventType::PackageStarted { name: msg.clone(), version: "1.0.0".to_string() },
5331 4 => EventType::PackageAttempted { attempt: 1, command: msg.clone() },
5332 5 => EventType::PackageOutput { stdout_tail: msg.clone(), stderr_tail: String::new() },
5333 6 => EventType::PackagePublished { duration_ms: 100 },
5334 7 => EventType::PackageFailed { class: ErrorClass::Retryable, message: msg.clone() },
5335 8 => EventType::PackageSkipped { reason: msg.clone() },
5336 9 => EventType::ReadinessStarted { method: ReadinessMethod::Api },
5337 10 => EventType::ReadinessPoll { attempt: 1, visible: false },
5338 11 => EventType::ReadinessComplete { duration_ms: 500, attempts: 3 },
5339 12 => EventType::ReadinessTimeout { max_wait_ms: 60000 },
5340 13 => EventType::IndexReadinessStarted { crate_name: msg.clone(), version: "1.0.0".to_string() },
5341 14 => EventType::IndexReadinessCheck { crate_name: msg.clone(), version: "1.0.0".to_string(), found: true },
5342 15 => EventType::IndexReadinessComplete { crate_name: msg.clone(), version: "1.0.0".to_string(), visible: true },
5343 16 => EventType::PreflightStarted,
5344 _ => EventType::PreflightComplete { finishability: Finishability::Proven },
5345 };
5346 let debug = format!("{:?}", event_type);
5347 assert!(!debug.is_empty());
5348 }
5349
5350 #[test]
5351 fn publish_event_debug_never_panics(
5352 pkg in "[a-z][a-z0-9-]{0,15}@[0-9]+\\.[0-9]+\\.[0-9]+",
5353 ) {
5354 let event = PublishEvent {
5355 timestamp: Utc::now(),
5356 event_type: EventType::ExecutionStarted,
5357 package: pkg,
5358 };
5359 let debug = format!("{:?}", event);
5360 assert!(!debug.is_empty());
5361 }
5362 }
5363
5364 proptest! {
5367 #[test]
5369 fn arbitrary_package_state_sequence(steps in 1usize..10) {
5370 let mut current = PackageState::Pending;
5371 for _ in 0..steps {
5372 let nexts = valid_next_states(¤t);
5373 if nexts.is_empty() {
5374 break; }
5376 current = nexts[0].clone();
5378 }
5379 let debug = format!("{:?}", current);
5381 assert!(!debug.is_empty());
5382 }
5383
5384 #[test]
5386 fn happy_path_always_reaches_published(_seed in 0u64..100) {
5387 let mut state = PackageState::Pending;
5388 let nexts = valid_next_states(&state);
5390 assert!(nexts.iter().any(|s| matches!(s, PackageState::Uploaded)));
5391 state = PackageState::Uploaded;
5392 let nexts = valid_next_states(&state);
5394 assert!(nexts.iter().any(|s| matches!(s, PackageState::Published)));
5395 state = PackageState::Published;
5396 assert!(valid_next_states(&state).is_empty());
5398 }
5399
5400 #[test]
5402 fn receipt_evidence_attempt_counts_preserved(
5403 attempt_count in 0usize..5,
5404 readiness_count in 0usize..5,
5405 ) {
5406 let now = Utc::now();
5407 let attempts: Vec<AttemptEvidence> = (0..attempt_count)
5408 .map(|i| AttemptEvidence {
5409 attempt_number: i as u32 + 1,
5410 command: format!("cargo publish attempt {i}"),
5411 exit_code: 0,
5412 stdout_tail: "ok".to_string(),
5413 stderr_tail: String::new(),
5414 timestamp: now,
5415 duration: Duration::from_secs(1),
5416 })
5417 .collect();
5418 let checks: Vec<ReadinessEvidence> = (0..readiness_count)
5419 .map(|i| ReadinessEvidence {
5420 attempt: i as u32 + 1,
5421 visible: i == readiness_count - 1,
5422 timestamp: now,
5423 delay_before: Duration::from_secs(2),
5424 })
5425 .collect();
5426 let evidence = PackageEvidence {
5427 attempts: attempts.clone(),
5428 readiness_checks: checks.clone(),
5429 };
5430 let json = serde_json::to_string(&evidence).unwrap();
5431 let parsed: PackageEvidence = serde_json::from_str(&json).unwrap();
5432 assert_eq!(parsed.attempts.len(), attempt_count);
5433 assert_eq!(parsed.readiness_checks.len(), readiness_count);
5434 for (orig, p) in attempts.iter().zip(parsed.attempts.iter()) {
5435 assert_eq!(orig.attempt_number, p.attempt_number);
5436 assert_eq!(orig.exit_code, p.exit_code);
5437 }
5438 }
5439 }
5440
5441 fn calculate_index_path_for_crate(crate_name: &str) -> String {
5444 let lower = crate_name.to_lowercase();
5445 match lower.len() {
5446 1 => format!("1/{}", lower),
5447 2 => format!("2/{}", lower),
5448 3 => format!("3/{}/{}", &lower[..1], lower),
5449 _ => format!("{}/{}/{}", &lower[..2], &lower[2..4], lower),
5450 }
5451 }
5452
5453 fn parse_schema_version_for_test(version: &str) -> Result<u32, String> {
5454 let parts: Vec<&str> = version.split('.').collect();
5455 if parts.len() != 3 || !parts[0].starts_with("shipper") || !parts[2].starts_with('v') {
5456 return Err("invalid format".to_string());
5457 }
5458
5459 let version_part = &parts[2][1..];
5460 version_part.parse::<u32>().map_err(|e| e.to_string())
5461 }
5462
5463 proptest! {
5466 #[test]
5468 fn release_plan_with_deps_roundtrip(
5469 pkg_count in 0usize..8,
5470 plan_id in "[a-f0-9]{8}",
5471 ) {
5472 let packages: Vec<PlannedPackage> = (0..pkg_count)
5473 .map(|i| PlannedPackage {
5474 name: format!("crate-{i}"),
5475 version: format!("0.{i}.0"),
5476 manifest_path: PathBuf::from(format!("crates/crate-{i}/Cargo.toml")),
5477 })
5478 .collect();
5479
5480 let mut deps = BTreeMap::new();
5481 for i in 1..pkg_count {
5482 deps.insert(
5483 format!("crate-{i}"),
5484 vec![format!("crate-{}", i - 1)],
5485 );
5486 }
5487
5488 let plan = ReleasePlan {
5489 plan_version: "shipper.plan.v1".to_string(),
5490 plan_id: plan_id.clone(),
5491 created_at: Utc::now(),
5492 registry: Registry::crates_io(),
5493 packages: packages.clone(),
5494 dependencies: deps.clone(),
5495 };
5496
5497 let json = serde_json::to_string(&plan).unwrap();
5498 let parsed: ReleasePlan = serde_json::from_str(&json).unwrap();
5499
5500 prop_assert_eq!(parsed.plan_id, plan.plan_id);
5501 prop_assert_eq!(parsed.packages.len(), pkg_count);
5502 prop_assert_eq!(parsed.dependencies.len(), deps.len());
5503 for (orig, p) in plan.packages.iter().zip(parsed.packages.iter()) {
5504 prop_assert_eq!(&p.name, &orig.name);
5505 prop_assert_eq!(&p.version, &orig.version);
5506 }
5507 }
5508
5509 #[test]
5511 fn plan_levels_respect_dependency_ordering(
5512 pkg_count in 1usize..10,
5513 ) {
5514 let packages: Vec<PlannedPackage> = (0..pkg_count)
5515 .map(|i| PlannedPackage {
5516 name: format!("crate-{i}"),
5517 version: format!("0.{i}.0"),
5518 manifest_path: PathBuf::from(format!("crates/crate-{i}/Cargo.toml")),
5519 })
5520 .collect();
5521
5522 let mut deps = BTreeMap::new();
5524 for i in 1..pkg_count {
5525 deps.insert(
5526 format!("crate-{i}"),
5527 vec![format!("crate-{}", i - 1)],
5528 );
5529 }
5530
5531 let plan = ReleasePlan {
5532 plan_version: "shipper.plan.v1".to_string(),
5533 plan_id: "test-plan".to_string(),
5534 created_at: Utc::now(),
5535 registry: Registry::crates_io(),
5536 packages,
5537 dependencies: deps.clone(),
5538 };
5539
5540 let levels = plan.group_by_levels();
5541
5542 let mut pkg_level: BTreeMap<String, usize> = BTreeMap::new();
5544 for level in &levels {
5545 for pkg in &level.packages {
5546 pkg_level.insert(pkg.name.clone(), level.level);
5547 }
5548 }
5549
5550 for (name, dep_list) in &deps {
5552 if let Some(&my_level) = pkg_level.get(name.as_str()) {
5553 for dep in dep_list {
5554 if let Some(&dep_level) = pkg_level.get(dep.as_str()) {
5555 prop_assert!(
5556 dep_level < my_level,
5557 "{name} (level {my_level}) depends on {dep} (level {dep_level})"
5558 );
5559 }
5560 }
5561 }
5562 }
5563 }
5564
5565 #[test]
5567 fn receipt_contains_all_plan_packages(
5568 pkg_count in 1usize..8,
5569 ) {
5570 let now = Utc::now();
5571 let packages: Vec<PlannedPackage> = (0..pkg_count)
5572 .map(|i| PlannedPackage {
5573 name: format!("crate-{i}"),
5574 version: format!("0.{i}.0"),
5575 manifest_path: PathBuf::from(format!("crates/crate-{i}/Cargo.toml")),
5576 })
5577 .collect();
5578
5579 let receipts: Vec<PackageReceipt> = packages
5580 .iter()
5581 .map(|pkg| PackageReceipt {
5582 name: pkg.name.clone(),
5583 version: pkg.version.clone(),
5584 attempts: 1,
5585 state: PackageState::Published,
5586 started_at: now,
5587 finished_at: now,
5588 duration_ms: 100,
5589 evidence: PackageEvidence {
5590 attempts: vec![],
5591 readiness_checks: vec![],
5592 },
5593 compromised_at: None,
5594 compromised_by: None,
5595 superseded_by: None,
5596 })
5597 .collect();
5598
5599 let receipt = Receipt {
5600 receipt_version: "shipper.receipt.v1".to_string(),
5601 plan_id: "plan-test".to_string(),
5602 registry: Registry::crates_io(),
5603 started_at: now,
5604 finished_at: now,
5605 packages: receipts.clone(),
5606 event_log_path: PathBuf::from(".shipper/events.jsonl"),
5607 git_context: None,
5608 environment: EnvironmentFingerprint {
5609 shipper_version: "0.1.0".to_string(),
5610 cargo_version: None,
5611 rust_version: None,
5612 os: "linux".to_string(),
5613 arch: "x86_64".to_string(),
5614 },
5615 };
5616
5617 for pkg in &packages {
5619 let found = receipt.packages.iter().any(|r| r.name == pkg.name && r.version == pkg.version);
5620 prop_assert!(found, "package {}@{} missing from receipt", pkg.name, pkg.version);
5621 }
5622 prop_assert_eq!(receipt.packages.len(), packages.len());
5623
5624 let json = serde_json::to_string(&receipt).unwrap();
5626 let parsed: Receipt = serde_json::from_str(&json).unwrap();
5627 prop_assert_eq!(parsed.packages.len(), receipt.packages.len());
5628 }
5629 }
5630 }
5631}