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, Copy, PartialEq, Eq, Serialize, Deserialize)]
630#[serde(rename_all = "snake_case")]
631pub enum PublishRegime {
632 FirstPublish,
634 Update,
636}
637
638impl PublishRegime {
639 pub fn is_new_crate(self) -> bool {
644 matches!(self, PublishRegime::FirstPublish)
645 }
646}
647
648#[derive(Debug, Clone, Serialize, Deserialize)]
668pub struct PlannedPackage {
669 pub name: String,
670 pub version: String,
671 pub manifest_path: PathBuf,
672 #[serde(default, skip_serializing_if = "Option::is_none")]
680 pub regime: Option<PublishRegime>,
681}
682
683#[derive(Debug, Clone, Serialize, Deserialize)]
717pub struct PublishLevel {
718 pub level: usize,
720 pub packages: Vec<PlannedPackage>,
722}
723
724#[derive(Debug, Clone, Serialize, Deserialize)]
749pub struct ReleasePlan {
750 pub plan_version: String,
751 pub plan_id: String,
752 pub created_at: DateTime<Utc>,
753 pub registry: Registry,
754 pub packages: Vec<PlannedPackage>,
756 #[serde(default)]
759 pub dependencies: BTreeMap<String, Vec<String>>,
760}
761
762#[derive(Debug, Clone, Serialize, Deserialize)]
767pub struct SkippedPackage {
768 pub name: String,
770 pub version: String,
772 pub reason: String,
774}
775
776#[derive(Debug, Clone)]
781pub struct PlannedWorkspace {
782 pub workspace_root: PathBuf,
784 pub plan: ReleasePlan,
786 pub skipped: Vec<SkippedPackage>,
788}
789
790impl ReleasePlan {
791 pub fn group_by_levels(&self) -> Vec<PublishLevel> {
796 group_packages_by_levels(&self.packages, |pkg| pkg.name.as_str(), &self.dependencies)
797 .into_iter()
798 .map(|l| PublishLevel {
799 level: l.level,
800 packages: l.packages,
801 })
802 .collect()
803 }
804}
805
806#[derive(Debug, Clone, PartialEq, Eq)]
810pub struct GenericPublishLevel<T> {
811 pub level: usize,
813 pub packages: Vec<T>,
815}
816
817pub fn group_packages_by_levels<T, F>(
824 ordered_packages: &[T],
825 package_name: F,
826 dependencies: &BTreeMap<String, Vec<String>>,
827) -> Vec<GenericPublishLevel<T>>
828where
829 T: Clone,
830 F: Fn(&T) -> &str,
831{
832 use std::collections::BTreeSet;
833
834 let mut ordered_names: Vec<String> = Vec::new();
835 let mut package_lookup: BTreeMap<String, T> = BTreeMap::new();
836
837 for package in ordered_packages {
838 let name = package_name(package).to_string();
839 if package_lookup.contains_key(&name) {
840 continue;
841 }
842 ordered_names.push(name.clone());
843 package_lookup.insert(name, package.clone());
844 }
845
846 if ordered_names.is_empty() {
847 return Vec::new();
848 }
849
850 let package_set: BTreeSet<String> = ordered_names.iter().cloned().collect();
851 let mut indegree: BTreeMap<String, usize> = package_set
852 .iter()
853 .map(|name| (name.clone(), 0usize))
854 .collect();
855 let mut dependents: BTreeMap<String, Vec<String>> = BTreeMap::new();
856
857 for name in &ordered_names {
858 if let Some(deps) = dependencies.get(name) {
859 for dep in deps {
860 if !package_set.contains(dep) {
861 continue;
862 }
863 if let Some(degree) = indegree.get_mut(name) {
864 *degree += 1;
865 }
866 dependents
867 .entry(dep.clone())
868 .or_default()
869 .push(name.clone());
870 }
871 }
872 }
873
874 let mut remaining: BTreeSet<String> = package_set;
875 let mut levels: Vec<GenericPublishLevel<T>> = Vec::new();
876
877 while !remaining.is_empty() {
878 let mut current: Vec<String> = ordered_names
879 .iter()
880 .filter(|name| {
881 remaining.contains(*name) && indegree.get(*name).copied().unwrap_or(0) == 0
882 })
883 .cloned()
884 .collect();
885
886 if current.is_empty() {
887 if let Some(name) = ordered_names
888 .iter()
889 .find(|name| remaining.contains(*name))
890 .cloned()
891 {
892 current.push(name);
893 } else {
894 break;
895 }
896 }
897
898 let packages = current
899 .iter()
900 .filter_map(|name| package_lookup.get(name).cloned())
901 .collect();
902
903 levels.push(GenericPublishLevel {
904 level: levels.len(),
905 packages,
906 });
907
908 for name in current {
909 remaining.remove(&name);
910 if let Some(children) = dependents.get(&name) {
911 for child in children {
912 if !remaining.contains(child) {
913 continue;
914 }
915 if let Some(degree) = indegree.get_mut(child) {
916 *degree = degree.saturating_sub(1);
917 }
918 }
919 }
920 }
921 }
922
923 levels
924}
925
926#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
968#[serde(tag = "state", rename_all = "snake_case")]
969pub enum PackageState {
970 Pending,
971 Uploaded,
972 Published,
973 Skipped { reason: String },
974 Failed { class: ErrorClass, message: String },
975 Ambiguous { message: String },
976}
977
978#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1030#[serde(rename_all = "snake_case")]
1031pub enum ErrorClass {
1032 Retryable,
1033 Permanent,
1034 Ambiguous,
1035}
1036
1037#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1052pub struct StateEventDrift {
1053 pub in_events_only: Vec<String>,
1059 pub in_state_only: Vec<String>,
1065}
1066
1067impl StateEventDrift {
1068 pub fn is_consistent(&self) -> bool {
1070 self.in_events_only.is_empty() && self.in_state_only.is_empty()
1071 }
1072}
1073
1074#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1083#[serde(tag = "outcome", rename_all = "snake_case")]
1084pub enum ReconciliationOutcome {
1085 Published { attempts: u32, elapsed_ms: u64 },
1088 NotPublished { attempts: u32, elapsed_ms: u64 },
1092 StillUnknown {
1097 attempts: u32,
1098 elapsed_ms: u64,
1099 reason: String,
1100 },
1101}
1102
1103#[derive(Debug, Clone, Serialize, Deserialize)]
1110pub struct ReconciliationReport {
1111 pub schema_version: String,
1112 pub plan_id: String,
1113 pub registry: Registry,
1114 pub generated_at: DateTime<Utc>,
1115 pub evidence_sources: Vec<ReconciliationEvidenceSource>,
1116 pub records: Vec<ReconciliationRecord>,
1117}
1118
1119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1121pub struct ReconciliationEvidenceSource {
1122 pub kind: ReconciliationEvidenceKind,
1123 pub path: String,
1124}
1125
1126#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1128#[serde(rename_all = "snake_case")]
1129pub enum ReconciliationEvidenceKind {
1130 EventLog,
1131 State,
1132 Receipt,
1133}
1134
1135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1137pub struct ReconciliationRecord {
1138 pub package: String,
1139 pub name: String,
1140 pub version: String,
1141 pub trigger: ReconciliationTrigger,
1142 pub method: Option<ReadinessMethod>,
1143 pub cargo_exit_class: Option<ErrorClass>,
1144 pub outcome: ReconciliationOutcome,
1145 pub operator_action: ReconciliationOperatorAction,
1146}
1147
1148#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1150#[serde(rename_all = "snake_case")]
1151pub enum ReconciliationTrigger {
1152 CargoAmbiguousExit,
1153 ResumeAmbiguousState,
1154}
1155
1156#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1158#[serde(rename_all = "snake_case")]
1159pub enum ReconciliationOperatorAction {
1160 MarkPublishedContinue,
1161 RetryAllowed,
1162 OperatorActionRequired,
1163}
1164
1165#[derive(Debug, Clone, Serialize, Deserialize)]
1186pub struct PackageProgress {
1187 pub name: String,
1188 pub version: String,
1189 pub attempts: u32,
1190 pub state: PackageState,
1191 pub last_updated_at: DateTime<Utc>,
1192}
1193
1194#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1200pub struct AttemptDetail {
1201 pub package: String,
1202 pub version: String,
1203 pub attempt: u32,
1204 pub max_attempts: u32,
1205 pub started_at: DateTime<Utc>,
1206 pub ended_at: DateTime<Utc>,
1207 #[serde(default, skip_serializing_if = "Option::is_none")]
1208 pub error_class: Option<ErrorClass>,
1209 #[serde(default, skip_serializing_if = "Option::is_none")]
1210 pub next_attempt_at: Option<DateTime<Utc>>,
1211 #[serde(default, skip_serializing_if = "Option::is_none")]
1212 pub redacted_message: Option<String>,
1213}
1214
1215#[derive(Debug, Clone, Serialize, Deserialize)]
1246pub struct ExecutionState {
1247 pub state_version: String,
1248 pub plan_id: String,
1249 pub registry: Registry,
1250 pub created_at: DateTime<Utc>,
1251 pub updated_at: DateTime<Utc>,
1252 #[serde(default)]
1255 pub attempt_history: Vec<AttemptDetail>,
1256 pub packages: BTreeMap<String, PackageProgress>,
1257}
1258
1259#[derive(Debug, Clone, Serialize, Deserialize)]
1289pub struct PackageReceipt {
1290 pub name: String,
1291 pub version: String,
1292 pub attempts: u32,
1293 pub state: PackageState,
1294 pub started_at: DateTime<Utc>,
1295 pub finished_at: DateTime<Utc>,
1296 pub duration_ms: u128,
1297 pub evidence: PackageEvidence,
1298
1299 #[serde(default, skip_serializing_if = "Option::is_none")]
1308 pub compromised_at: Option<DateTime<Utc>>,
1309 #[serde(default, skip_serializing_if = "Option::is_none")]
1313 pub compromised_by: Option<String>,
1314 #[serde(default, skip_serializing_if = "Option::is_none")]
1318 pub superseded_by: Option<String>,
1319}
1320
1321#[derive(Debug, Clone, Serialize, Deserialize)]
1331pub struct PackageEvidence {
1332 pub attempts: Vec<AttemptEvidence>,
1333 pub readiness_checks: Vec<ReadinessEvidence>,
1334}
1335
1336#[serde_as]
1359#[derive(Debug, Clone, Serialize, Deserialize)]
1360pub struct AttemptEvidence {
1361 pub attempt_number: u32,
1362 pub command: String,
1363 pub exit_code: i32,
1364 pub stdout_tail: String,
1365 pub stderr_tail: String,
1366 pub timestamp: DateTime<Utc>,
1367 #[serde_as(as = "DurationMilliSeconds<u64>")]
1368 pub duration: Duration,
1369}
1370
1371#[serde_as]
1390#[derive(Debug, Clone, Serialize, Deserialize)]
1391pub struct ReadinessEvidence {
1392 pub attempt: u32,
1393 pub visible: bool,
1394 pub timestamp: DateTime<Utc>,
1395 #[serde_as(as = "DurationMilliSeconds<u64>")]
1396 pub delay_before: Duration,
1397}
1398
1399#[derive(Debug, Clone, Serialize, Deserialize)]
1418pub struct EnvironmentFingerprint {
1419 pub shipper_version: String,
1420 pub cargo_version: Option<String>,
1421 pub rust_version: Option<String>,
1422 pub os: String,
1423 pub arch: String,
1424}
1425
1426#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1444pub struct GitContext {
1445 pub commit: Option<String>,
1446 pub branch: Option<String>,
1447 pub tag: Option<String>,
1448 pub dirty: Option<bool>,
1449}
1450
1451impl GitContext {
1452 pub fn new() -> Self {
1454 Self::default()
1455 }
1456
1457 pub fn has_commit(&self) -> bool {
1459 self.commit.is_some()
1460 }
1461
1462 pub fn is_dirty(&self) -> bool {
1467 self.dirty.unwrap_or(true)
1468 }
1469
1470 pub fn short_commit(&self) -> Option<&str> {
1476 self.commit
1477 .as_ref()
1478 .map(|c| if c.len() > 7 { &c[..7] } else { c.as_str() })
1479 }
1480}
1481
1482#[derive(Debug, Clone, Serialize, Deserialize)]
1522pub struct Receipt {
1523 pub receipt_version: String,
1524 pub plan_id: String,
1525 pub registry: Registry,
1526 pub started_at: DateTime<Utc>,
1527 pub finished_at: DateTime<Utc>,
1528 pub packages: Vec<PackageReceipt>,
1529 pub event_log_path: PathBuf,
1530 #[serde(default)]
1531 pub git_context: Option<GitContext>,
1532 pub environment: EnvironmentFingerprint,
1533 #[serde(default, skip_serializing_if = "Option::is_none")]
1534 pub auth_evidence: Option<AuthEvidence>,
1535}
1536
1537#[derive(Debug, Clone, Serialize, Deserialize)]
1557pub struct PublishEvent {
1558 pub timestamp: DateTime<Utc>,
1559 pub event_type: EventType,
1560 pub package: String, }
1562
1563#[derive(Debug, Clone, Serialize, Deserialize)]
1611#[serde(tag = "type", rename_all = "snake_case")]
1612pub enum EventType {
1613 PlanCreated {
1615 plan_id: String,
1616 package_count: usize,
1617 },
1618 ExecutionStarted,
1619 ExecutionFinished {
1620 result: ExecutionResult,
1621 },
1622 AuthEvidenceRecorded {
1623 evidence: AuthEvidence,
1624 },
1625
1626 PackageStarted {
1628 name: String,
1629 version: String,
1630 },
1631 PackageAttempted {
1632 attempt: u32,
1633 command: String,
1634 },
1635 PackageOutput {
1636 stdout_tail: String,
1637 stderr_tail: String,
1638 },
1639 PackagePublished {
1640 duration_ms: u64,
1641 },
1642 PackageFailed {
1643 class: ErrorClass,
1644 message: String,
1645 },
1646 PackageSkipped {
1647 reason: String,
1648 },
1649
1650 PublishWaiting {
1654 reason: String,
1655 delay_ms: u64,
1656 until: DateTime<Utc>,
1657 },
1658
1659 RateLimitObserved {
1663 is_new_crate: bool,
1664 retry_after_ms: Option<u64>,
1665 message: String,
1666 },
1667
1668 PublishReconciling {
1670 method: ReadinessMethod,
1671 },
1672 PublishReconciled {
1673 outcome: ReconciliationOutcome,
1674 },
1675
1676 StateEventDriftDetected {
1678 drift: StateEventDrift,
1679 },
1680
1681 PackageYanked {
1686 crate_name: String,
1687 version: String,
1688 reason: String,
1689 exit_code: i32,
1690 },
1691
1692 RehearsalStarted {
1703 registry: String,
1704 plan_id: String,
1705 package_count: usize,
1706 },
1707 RehearsalPackagePublished {
1708 name: String,
1709 version: String,
1710 duration_ms: u128,
1711 },
1712 RehearsalPackageFailed {
1713 name: String,
1714 version: String,
1715 class: ErrorClass,
1716 message: String,
1717 },
1718 RehearsalComplete {
1719 passed: bool,
1720 registry: String,
1721 plan_id: String,
1726 summary: String,
1727 },
1728
1729 RehearsalSmokeCheckStarted {
1735 name: String,
1736 version: String,
1737 registry: String,
1738 },
1739 RehearsalSmokeCheckSucceeded {
1740 name: String,
1741 version: String,
1742 duration_ms: u128,
1743 },
1744 RehearsalSmokeCheckFailed {
1745 name: String,
1746 version: String,
1747 message: String,
1748 },
1749
1750 RetryBackoffStarted {
1756 attempt: u32,
1757 max_attempts: u32,
1758 delay_ms: u64,
1759 next_attempt_at: DateTime<Utc>,
1760 reason: ErrorClass,
1761 message: String,
1762 },
1763 RetryScheduled {
1764 attempt: u32,
1765 max_attempts: u32,
1766 delay_ms: u64,
1767 next_attempt_at: DateTime<Utc>,
1768 reason: ErrorClass,
1769 message: String,
1770 },
1771
1772 ReadinessStarted {
1774 method: ReadinessMethod,
1775 },
1776 ReadinessPoll {
1777 attempt: u32,
1778 visible: bool,
1779 },
1780 ReadinessPollScheduled {
1781 attempt: u32,
1782 delay_ms: u64,
1783 next_poll_at: DateTime<Utc>,
1784 },
1785 ReadinessComplete {
1786 duration_ms: u64,
1787 attempts: u32,
1788 },
1789 ReadinessTimeout {
1790 max_wait_ms: u64,
1791 },
1792 IndexReadinessStarted {
1794 crate_name: String,
1795 version: String,
1796 },
1797 IndexReadinessCheck {
1798 crate_name: String,
1799 version: String,
1800 found: bool,
1801 },
1802 IndexReadinessComplete {
1803 crate_name: String,
1804 version: String,
1805 visible: bool,
1806 },
1807
1808 PreflightStarted,
1810 PreflightWorkspaceVerify {
1811 passed: bool,
1812 output: String,
1813 },
1814 PreflightNewCrateDetected {
1815 crate_name: String,
1816 },
1817 PreflightOwnershipCheck {
1818 crate_name: String,
1819 verified: bool,
1820 },
1821 PreflightComplete {
1822 finishability: Finishability,
1823 },
1824}
1825
1826#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1847#[serde(rename_all = "snake_case")]
1848pub enum ExecutionResult {
1849 Success,
1850 PartialFailure,
1851 CompleteFailure,
1852}
1853
1854#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1875#[serde(rename_all = "snake_case")]
1876pub enum AuthType {
1877 Token,
1878 TrustedPublishing,
1879 Unknown,
1880}
1881
1882#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1890pub struct AuthEvidence {
1891 pub schema_version: String,
1892 pub registry: String,
1893 pub auth_mode: AuthEvidenceMode,
1894 pub token_detected: bool,
1895 pub oidc_request_url_present: bool,
1896 pub oidc_request_token_present: bool,
1897}
1898
1899#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1901#[serde(rename_all = "snake_case")]
1902pub enum AuthEvidenceMode {
1903 CargoToken,
1904 TrustedPublishingContext,
1905 CargoTokenWithOidcContext,
1906 PartialOidcContext,
1907 Missing,
1908 Unknown,
1909}
1910
1911#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1932#[serde(rename_all = "snake_case")]
1933pub enum Finishability {
1934 Proven,
1935 NotProven,
1936 Failed,
1937}
1938
1939#[derive(Debug, Clone, Serialize, Deserialize)]
1977pub struct PreflightReport {
1978 pub plan_id: String,
1979 pub token_detected: bool,
1980 pub finishability: Finishability,
1981 pub packages: Vec<PreflightPackage>,
1982 pub timestamp: DateTime<Utc>,
1983 #[serde(default, skip_serializing_if = "Option::is_none")]
1985 pub estimated_publish_duration: Option<PreflightDurationEstimate>,
1986 pub dry_run_output: Option<String>,
1988}
1989
1990#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1996pub struct PreflightDurationEstimate {
1997 pub registry_profile: String,
1999 pub first_publish_count: usize,
2001 pub update_count: usize,
2003 #[serde(
2006 deserialize_with = "deserialize_duration",
2007 serialize_with = "serialize_duration"
2008 )]
2009 pub minimum_registry_pacing: Duration,
2010 pub notes: Vec<String>,
2012}
2013
2014#[derive(Debug, Clone, Serialize, Deserialize)]
2036pub struct PreflightPackage {
2037 pub name: String,
2038 pub version: String,
2039 pub already_published: bool,
2040 pub is_new_crate: bool,
2041 pub auth_type: Option<AuthType>,
2042 pub ownership_verified: bool,
2043 pub dry_run_passed: bool,
2044 pub dry_run_output: Option<String>,
2046}
2047
2048#[cfg(test)]
2049mod tests {
2050 use super::*;
2051
2052 #[test]
2053 fn crates_io_registry_defaults_are_expected() {
2054 let reg = Registry::crates_io();
2055 assert_eq!(reg.name, "crates-io");
2056 assert_eq!(reg.api_base, "https://crates.io");
2057 }
2058
2059 #[test]
2060 fn uploaded_state_serde_roundtrip() {
2061 let st = PackageState::Uploaded;
2062 let json = serde_json::to_string(&st).expect("serialize");
2063 assert_eq!(json, r#"{"state":"uploaded"}"#);
2064 let rt: PackageState = serde_json::from_str(&json).expect("deserialize");
2065 assert_eq!(rt, PackageState::Uploaded);
2066 }
2067
2068 #[test]
2069 fn package_state_serializes_with_tagged_representation() {
2070 let st = PackageState::Failed {
2071 class: ErrorClass::Permanent,
2072 message: "nope".to_string(),
2073 };
2074
2075 let json = serde_json::to_string(&st).expect("serialize");
2076 assert!(json.contains("\"state\":\"failed\""));
2077 assert!(json.contains("\"class\":\"permanent\""));
2078
2079 let rt: PackageState = serde_json::from_str(&json).expect("deserialize");
2080 assert_eq!(rt, st);
2081 }
2082
2083 #[test]
2084 fn execution_state_roundtrips_json() {
2085 let mut packages = BTreeMap::new();
2086 packages.insert(
2087 "demo@1.2.3".to_string(),
2088 PackageProgress {
2089 name: "demo".to_string(),
2090 version: "1.2.3".to_string(),
2091 attempts: 2,
2092 state: PackageState::Published,
2093 last_updated_at: Utc::now(),
2094 },
2095 );
2096
2097 let st = ExecutionState {
2098 state_version: "shipper.state.v1".to_string(),
2099 plan_id: "plan-1".to_string(),
2100 registry: Registry::crates_io(),
2101 created_at: Utc::now(),
2102 updated_at: Utc::now(),
2103 attempt_history: Vec::new(),
2104 packages,
2105 };
2106
2107 let json = serde_json::to_string_pretty(&st).expect("serialize");
2108 let parsed: ExecutionState = serde_json::from_str(&json).expect("deserialize");
2109 assert_eq!(parsed.plan_id, "plan-1");
2110 assert!(parsed.packages.contains_key("demo@1.2.3"));
2111 }
2112
2113 #[test]
2114 fn registry_get_index_base_strips_sparse_prefix() {
2115 let registry = Registry {
2116 name: "crates-io".to_string(),
2117 api_base: "https://crates.io".to_string(),
2118 index_base: Some("sparse+https://index.crates.io".to_string()),
2119 };
2120
2121 assert_eq!(registry.get_index_base(), "https://index.crates.io");
2122 }
2123
2124 #[test]
2125 fn readiness_method_default_is_api() {
2126 let method = ReadinessMethod::default();
2127 assert_eq!(method, ReadinessMethod::Api);
2128 }
2129
2130 #[test]
2131 fn readiness_config_default_values() {
2132 let config = ReadinessConfig::default();
2133 assert!(config.enabled);
2134 assert_eq!(config.method, ReadinessMethod::Api);
2135 assert_eq!(config.initial_delay, Duration::from_secs(1));
2136 assert_eq!(config.max_delay, Duration::from_secs(60));
2137 assert_eq!(config.max_total_wait, Duration::from_secs(300));
2138 assert_eq!(config.poll_interval, Duration::from_secs(2));
2139 assert_eq!(config.jitter_factor, 0.5);
2140 }
2141
2142 #[test]
2143 fn readiness_config_can_be_customized() {
2144 let config = ReadinessConfig {
2145 enabled: false,
2146 method: ReadinessMethod::Both,
2147 initial_delay: Duration::from_millis(500),
2148 max_delay: Duration::from_secs(30),
2149 max_total_wait: Duration::from_secs(600),
2150 poll_interval: Duration::from_secs(5),
2151 jitter_factor: 0.25,
2152 index_path: None,
2153 prefer_index: false,
2154 };
2155 assert!(!config.enabled);
2156 assert_eq!(config.method, ReadinessMethod::Both);
2157 assert_eq!(config.initial_delay, Duration::from_millis(500));
2158 assert_eq!(config.max_delay, Duration::from_secs(30));
2159 assert_eq!(config.max_total_wait, Duration::from_secs(600));
2160 assert_eq!(config.poll_interval, Duration::from_secs(5));
2161 assert_eq!(config.jitter_factor, 0.25);
2162 }
2163
2164 #[test]
2167 fn package_state_pending_to_uploaded_is_valid() {
2168 let pending = PackageState::Pending;
2169 let uploaded = PackageState::Uploaded;
2170 assert_eq!(pending, PackageState::Pending);
2171 assert_eq!(uploaded, PackageState::Uploaded);
2172 assert_ne!(pending, uploaded);
2174 }
2175
2176 #[test]
2177 fn package_state_uploaded_to_published_is_valid() {
2178 let uploaded = PackageState::Uploaded;
2179 let published = PackageState::Published;
2180 assert_ne!(uploaded, published);
2181 }
2182
2183 #[test]
2184 fn package_state_pending_to_failed_is_valid() {
2185 let pending = PackageState::Pending;
2186 let failed = PackageState::Failed {
2187 class: ErrorClass::Retryable,
2188 message: "connection refused".to_string(),
2189 };
2190 assert_ne!(pending, failed);
2191 }
2192
2193 #[test]
2194 fn package_state_pending_to_skipped_is_valid() {
2195 let skipped = PackageState::Skipped {
2196 reason: "already published".to_string(),
2197 };
2198 assert!(matches!(skipped, PackageState::Skipped { .. }));
2199 }
2200
2201 #[test]
2202 fn package_state_uploaded_to_ambiguous_is_valid() {
2203 let ambiguous = PackageState::Ambiguous {
2204 message: "upload succeeded but timed out waiting for visibility".to_string(),
2205 };
2206 assert!(matches!(ambiguous, PackageState::Ambiguous { .. }));
2207 }
2208
2209 #[test]
2210 fn package_state_failed_equality_requires_matching_fields() {
2211 let f1 = PackageState::Failed {
2212 class: ErrorClass::Retryable,
2213 message: "timeout".to_string(),
2214 };
2215 let f2 = PackageState::Failed {
2216 class: ErrorClass::Retryable,
2217 message: "timeout".to_string(),
2218 };
2219 let f3 = PackageState::Failed {
2220 class: ErrorClass::Permanent,
2221 message: "timeout".to_string(),
2222 };
2223 let f4 = PackageState::Failed {
2224 class: ErrorClass::Retryable,
2225 message: "different".to_string(),
2226 };
2227 assert_eq!(f1, f2);
2228 assert_ne!(f1, f3);
2229 assert_ne!(f1, f4);
2230 }
2231
2232 #[test]
2233 fn package_state_skipped_equality_by_reason() {
2234 let s1 = PackageState::Skipped {
2235 reason: "exists".to_string(),
2236 };
2237 let s2 = PackageState::Skipped {
2238 reason: "exists".to_string(),
2239 };
2240 let s3 = PackageState::Skipped {
2241 reason: "other".to_string(),
2242 };
2243 assert_eq!(s1, s2);
2244 assert_ne!(s1, s3);
2245 }
2246
2247 #[test]
2248 fn package_state_all_unit_variants_are_distinct() {
2249 let states: Vec<PackageState> = vec![
2250 PackageState::Pending,
2251 PackageState::Uploaded,
2252 PackageState::Published,
2253 ];
2254 for (i, a) in states.iter().enumerate() {
2255 for (j, b) in states.iter().enumerate() {
2256 if i == j {
2257 assert_eq!(a, b);
2258 } else {
2259 assert_ne!(a, b);
2260 }
2261 }
2262 }
2263 }
2264
2265 #[test]
2273 fn planned_package_backward_compat_no_regime_field() {
2274 let legacy_json = r#"{
2277 "name": "legacy-crate",
2278 "version": "0.1.0",
2279 "manifest_path": "crates/legacy-crate/Cargo.toml"
2280 }"#;
2281 let parsed: PlannedPackage = serde_json::from_str(legacy_json).unwrap();
2282 assert_eq!(parsed.name, "legacy-crate");
2283 assert_eq!(parsed.version, "0.1.0");
2284 assert!(
2285 parsed.regime.is_none(),
2286 "missing regime should deserialize to None for backward compat"
2287 );
2288 }
2289
2290 #[test]
2295 fn planned_package_regime_none_is_skipped_in_serialization() {
2296 let pkg = PlannedPackage {
2297 name: "demo".to_string(),
2298 version: "0.1.0".to_string(),
2299 manifest_path: PathBuf::from("Cargo.toml"),
2300 regime: None,
2301 };
2302 let json = serde_json::to_string(&pkg).unwrap();
2303 assert!(
2304 !json.contains("regime"),
2305 "None regime should be skipped, got: {json}"
2306 );
2307 }
2308
2309 #[test]
2312 fn planned_package_regime_some_round_trips() {
2313 for regime in [PublishRegime::FirstPublish, PublishRegime::Update] {
2314 let pkg = PlannedPackage {
2315 name: "demo".to_string(),
2316 version: "0.1.0".to_string(),
2317 manifest_path: PathBuf::from("Cargo.toml"),
2318 regime: Some(regime),
2319 };
2320 let json = serde_json::to_string(&pkg).unwrap();
2321 assert!(
2322 json.contains("\"regime\""),
2323 "regime must be present: {json}"
2324 );
2325 let parsed: PlannedPackage = serde_json::from_str(&json).unwrap();
2326 assert_eq!(parsed.regime, Some(regime));
2327 }
2328 }
2329
2330 #[test]
2331 fn publish_regime_is_new_crate_matches_variant() {
2332 assert!(PublishRegime::FirstPublish.is_new_crate());
2333 assert!(!PublishRegime::Update.is_new_crate());
2334 }
2335
2336 #[test]
2339 fn release_plan_serde_roundtrip_preserves_all_fields() {
2340 let plan = ReleasePlan {
2341 plan_version: "shipper.plan.v1".to_string(),
2342 plan_id: "deadbeef01234567".to_string(),
2343 created_at: "2025-06-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap(),
2344 registry: Registry::crates_io(),
2345 packages: vec![
2346 PlannedPackage {
2347 name: "alpha".to_string(),
2348 version: "1.0.0".to_string(),
2349 manifest_path: PathBuf::from("crates/alpha/Cargo.toml"),
2350 regime: None,
2351 },
2352 PlannedPackage {
2353 name: "beta".to_string(),
2354 version: "2.0.0".to_string(),
2355 manifest_path: PathBuf::from("crates/beta/Cargo.toml"),
2356 regime: None,
2357 },
2358 ],
2359 dependencies: BTreeMap::from([("beta".to_string(), vec!["alpha".to_string()])]),
2360 };
2361 let json = serde_json::to_string(&plan).unwrap();
2362 let parsed: ReleasePlan = serde_json::from_str(&json).unwrap();
2363 assert_eq!(parsed.plan_version, plan.plan_version);
2364 assert_eq!(parsed.plan_id, plan.plan_id);
2365 assert_eq!(parsed.packages.len(), 2);
2366 assert_eq!(parsed.packages[0].name, "alpha");
2367 assert_eq!(parsed.packages[1].name, "beta");
2368 assert_eq!(parsed.dependencies.len(), 1);
2369 assert_eq!(parsed.dependencies["beta"], vec!["alpha".to_string()]);
2370 assert_eq!(parsed.registry.name, "crates-io");
2371 }
2372
2373 #[test]
2374 fn release_plan_empty_dependencies_roundtrip() {
2375 let plan = ReleasePlan {
2376 plan_version: "shipper.plan.v1".to_string(),
2377 plan_id: "nodeps".to_string(),
2378 created_at: Utc::now(),
2379 registry: Registry::crates_io(),
2380 packages: vec![PlannedPackage {
2381 name: "standalone".to_string(),
2382 version: "0.1.0".to_string(),
2383 manifest_path: PathBuf::from("Cargo.toml"),
2384 regime: None,
2385 }],
2386 dependencies: BTreeMap::new(),
2387 };
2388 let json = serde_json::to_string(&plan).unwrap();
2389 let parsed: ReleasePlan = serde_json::from_str(&json).unwrap();
2390 assert!(parsed.dependencies.is_empty());
2391 }
2392
2393 #[test]
2394 fn release_plan_group_by_levels_single_crate() {
2395 let plan = ReleasePlan {
2396 plan_version: "shipper.plan.v1".to_string(),
2397 plan_id: "single".to_string(),
2398 created_at: Utc::now(),
2399 registry: Registry::crates_io(),
2400 packages: vec![PlannedPackage {
2401 name: "solo".to_string(),
2402 version: "1.0.0".to_string(),
2403 manifest_path: PathBuf::from("Cargo.toml"),
2404 regime: None,
2405 }],
2406 dependencies: BTreeMap::new(),
2407 };
2408 let levels = plan.group_by_levels();
2409 assert_eq!(levels.len(), 1);
2410 assert_eq!(levels[0].level, 0);
2411 assert_eq!(levels[0].packages.len(), 1);
2412 assert_eq!(levels[0].packages[0].name, "solo");
2413 }
2414
2415 #[test]
2416 fn release_plan_group_by_levels_chain() {
2417 let plan = ReleasePlan {
2418 plan_version: "shipper.plan.v1".to_string(),
2419 plan_id: "chain".to_string(),
2420 created_at: Utc::now(),
2421 registry: Registry::crates_io(),
2422 packages: vec![
2423 PlannedPackage {
2424 name: "a".to_string(),
2425 version: "1.0.0".to_string(),
2426 manifest_path: PathBuf::from("a/Cargo.toml"),
2427 regime: None,
2428 },
2429 PlannedPackage {
2430 name: "b".to_string(),
2431 version: "1.0.0".to_string(),
2432 manifest_path: PathBuf::from("b/Cargo.toml"),
2433 regime: None,
2434 },
2435 PlannedPackage {
2436 name: "c".to_string(),
2437 version: "1.0.0".to_string(),
2438 manifest_path: PathBuf::from("c/Cargo.toml"),
2439 regime: None,
2440 },
2441 ],
2442 dependencies: BTreeMap::from([
2443 ("b".to_string(), vec!["a".to_string()]),
2444 ("c".to_string(), vec!["b".to_string()]),
2445 ]),
2446 };
2447 let levels = plan.group_by_levels();
2448 assert_eq!(levels.len(), 3);
2449 assert_eq!(levels[0].level, 0);
2450 assert_eq!(levels[0].packages[0].name, "a");
2451 assert_eq!(levels[1].level, 1);
2452 assert_eq!(levels[1].packages[0].name, "b");
2453 assert_eq!(levels[2].level, 2);
2454 assert_eq!(levels[2].packages[0].name, "c");
2455 }
2456
2457 #[test]
2458 fn release_plan_group_by_levels_parallel_at_level_zero() {
2459 let plan = ReleasePlan {
2460 plan_version: "shipper.plan.v1".to_string(),
2461 plan_id: "parallel".to_string(),
2462 created_at: Utc::now(),
2463 registry: Registry::crates_io(),
2464 packages: vec![
2465 PlannedPackage {
2466 name: "x".to_string(),
2467 version: "1.0.0".to_string(),
2468 manifest_path: PathBuf::from("x/Cargo.toml"),
2469 regime: None,
2470 },
2471 PlannedPackage {
2472 name: "y".to_string(),
2473 version: "1.0.0".to_string(),
2474 manifest_path: PathBuf::from("y/Cargo.toml"),
2475 regime: None,
2476 },
2477 PlannedPackage {
2478 name: "z".to_string(),
2479 version: "1.0.0".to_string(),
2480 manifest_path: PathBuf::from("z/Cargo.toml"),
2481 regime: None,
2482 },
2483 ],
2484 dependencies: BTreeMap::new(),
2485 };
2486 let levels = plan.group_by_levels();
2487 assert_eq!(levels.len(), 1);
2488 assert_eq!(levels[0].packages.len(), 3);
2489 }
2490
2491 #[test]
2494 fn receipt_with_ambiguous_state_roundtrip() {
2495 let t = "2025-01-15T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
2496 let receipt = Receipt {
2497 receipt_version: "shipper.receipt.v1".to_string(),
2498 plan_id: "ambig-test".to_string(),
2499 registry: Registry::crates_io(),
2500 started_at: t,
2501 finished_at: t,
2502 packages: vec![PackageReceipt {
2503 name: "ambig-crate".to_string(),
2504 version: "0.1.0".to_string(),
2505 attempts: 2,
2506 state: PackageState::Ambiguous {
2507 message: "upload ok but readiness timed out".to_string(),
2508 },
2509 started_at: t,
2510 finished_at: t,
2511 duration_ms: 60000,
2512 evidence: PackageEvidence {
2513 attempts: vec![],
2514 readiness_checks: vec![],
2515 },
2516 compromised_at: None,
2517 compromised_by: None,
2518 superseded_by: None,
2519 }],
2520 event_log_path: PathBuf::from(".shipper/events.jsonl"),
2521 git_context: None,
2522 environment: EnvironmentFingerprint {
2523 shipper_version: "0.3.0".to_string(),
2524 cargo_version: None,
2525 rust_version: None,
2526 os: "linux".to_string(),
2527 arch: "x86_64".to_string(),
2528 },
2529 auth_evidence: None,
2530 };
2531 let json = serde_json::to_string(&receipt).unwrap();
2532 let parsed: Receipt = serde_json::from_str(&json).unwrap();
2533 assert!(matches!(
2534 &parsed.packages[0].state,
2535 PackageState::Ambiguous { message } if message.contains("readiness timed out")
2536 ));
2537 }
2538
2539 #[test]
2540 fn receipt_empty_packages_roundtrip() {
2541 let t = Utc::now();
2542 let receipt = Receipt {
2543 receipt_version: "shipper.receipt.v1".to_string(),
2544 plan_id: "empty".to_string(),
2545 registry: Registry::crates_io(),
2546 started_at: t,
2547 finished_at: t,
2548 packages: vec![],
2549 event_log_path: PathBuf::from(".shipper/events.jsonl"),
2550 git_context: None,
2551 environment: EnvironmentFingerprint {
2552 shipper_version: "0.3.0".to_string(),
2553 cargo_version: None,
2554 rust_version: None,
2555 os: "linux".to_string(),
2556 arch: "x86_64".to_string(),
2557 },
2558 auth_evidence: None,
2559 };
2560 let json = serde_json::to_string(&receipt).unwrap();
2561 let parsed: Receipt = serde_json::from_str(&json).unwrap();
2562 assert!(parsed.packages.is_empty());
2563 }
2564
2565 #[test]
2566 fn receipt_all_state_variants_roundtrip() {
2567 let t = Utc::now();
2568 let states = vec![
2569 PackageState::Published,
2570 PackageState::Uploaded,
2571 PackageState::Pending,
2572 PackageState::Skipped {
2573 reason: "exists".to_string(),
2574 },
2575 PackageState::Failed {
2576 class: ErrorClass::Permanent,
2577 message: "auth".to_string(),
2578 },
2579 PackageState::Ambiguous {
2580 message: "unclear".to_string(),
2581 },
2582 ];
2583 let packages: Vec<PackageReceipt> = states
2584 .into_iter()
2585 .enumerate()
2586 .map(|(i, state)| PackageReceipt {
2587 name: format!("crate-{i}"),
2588 version: "1.0.0".to_string(),
2589 attempts: 1,
2590 state,
2591 started_at: t,
2592 finished_at: t,
2593 duration_ms: 100,
2594 evidence: PackageEvidence {
2595 attempts: vec![],
2596 readiness_checks: vec![],
2597 },
2598 compromised_at: None,
2599 compromised_by: None,
2600 superseded_by: None,
2601 })
2602 .collect();
2603 let receipt = Receipt {
2604 receipt_version: "shipper.receipt.v1".to_string(),
2605 plan_id: "all-variants".to_string(),
2606 registry: Registry::crates_io(),
2607 started_at: t,
2608 finished_at: t,
2609 packages,
2610 event_log_path: PathBuf::from(".shipper/events.jsonl"),
2611 git_context: None,
2612 environment: EnvironmentFingerprint {
2613 shipper_version: "0.3.0".to_string(),
2614 cargo_version: None,
2615 rust_version: None,
2616 os: "linux".to_string(),
2617 arch: "x86_64".to_string(),
2618 },
2619 auth_evidence: None,
2620 };
2621 let json = serde_json::to_string(&receipt).unwrap();
2622 let parsed: Receipt = serde_json::from_str(&json).unwrap();
2623 assert_eq!(parsed.packages.len(), 6);
2624 assert!(matches!(parsed.packages[0].state, PackageState::Published));
2625 assert!(matches!(parsed.packages[1].state, PackageState::Uploaded));
2626 assert!(matches!(parsed.packages[2].state, PackageState::Pending));
2627 assert!(matches!(
2628 parsed.packages[3].state,
2629 PackageState::Skipped { .. }
2630 ));
2631 assert!(matches!(
2632 parsed.packages[4].state,
2633 PackageState::Failed { .. }
2634 ));
2635 assert!(matches!(
2636 parsed.packages[5].state,
2637 PackageState::Ambiguous { .. }
2638 ));
2639 }
2640
2641 #[test]
2644 fn publish_policy_default_is_safe() {
2645 assert_eq!(PublishPolicy::default(), PublishPolicy::Safe);
2646 }
2647
2648 #[test]
2649 fn publish_policy_exhaustive_serde() {
2650 let policies = [
2651 PublishPolicy::Safe,
2652 PublishPolicy::Balanced,
2653 PublishPolicy::Fast,
2654 ];
2655 let expected_json = [r#""safe""#, r#""balanced""#, r#""fast""#];
2656 for (policy, expected) in policies.iter().zip(expected_json.iter()) {
2657 let json = serde_json::to_string(policy).unwrap();
2658 assert_eq!(&json, expected);
2659 let parsed: PublishPolicy = serde_json::from_str(&json).unwrap();
2660 assert_eq!(&parsed, policy);
2661 }
2662 }
2663
2664 #[test]
2665 fn verify_mode_default_is_workspace() {
2666 assert_eq!(VerifyMode::default(), VerifyMode::Workspace);
2667 }
2668
2669 #[test]
2670 fn verify_mode_exhaustive_serde() {
2671 let modes = [VerifyMode::Workspace, VerifyMode::Package, VerifyMode::None];
2672 let expected_json = [r#""workspace""#, r#""package""#, r#""none""#];
2673 for (mode, expected) in modes.iter().zip(expected_json.iter()) {
2674 let json = serde_json::to_string(mode).unwrap();
2675 assert_eq!(&json, expected);
2676 let parsed: VerifyMode = serde_json::from_str(&json).unwrap();
2677 assert_eq!(&parsed, mode);
2678 }
2679 }
2680
2681 #[test]
2682 fn readiness_method_exhaustive_serde() {
2683 let methods = [
2684 ReadinessMethod::Api,
2685 ReadinessMethod::Index,
2686 ReadinessMethod::Both,
2687 ];
2688 let expected_json = [r#""api""#, r#""index""#, r#""both""#];
2689 for (method, expected) in methods.iter().zip(expected_json.iter()) {
2690 let json = serde_json::to_string(method).unwrap();
2691 assert_eq!(&json, expected);
2692 let parsed: ReadinessMethod = serde_json::from_str(&json).unwrap();
2693 assert_eq!(&parsed, method);
2694 }
2695 }
2696
2697 #[test]
2700 fn package_progress_epoch_timestamp_roundtrip() {
2701 let epoch = DateTime::from_timestamp(0, 0).unwrap();
2702 let progress = PackageProgress {
2703 name: "epoch-crate".to_string(),
2704 version: "0.0.1".to_string(),
2705 attempts: 0,
2706 state: PackageState::Pending,
2707 last_updated_at: epoch,
2708 };
2709 let json = serde_json::to_string(&progress).unwrap();
2710 let parsed: PackageProgress = serde_json::from_str(&json).unwrap();
2711 assert_eq!(parsed.last_updated_at.timestamp(), 0);
2712 }
2713
2714 #[test]
2715 fn package_progress_far_future_timestamp_roundtrip() {
2716 let far_future = DateTime::from_timestamp(4102444800, 0).unwrap(); let progress = PackageProgress {
2718 name: "future-crate".to_string(),
2719 version: "99.0.0".to_string(),
2720 attempts: 0,
2721 state: PackageState::Pending,
2722 last_updated_at: far_future,
2723 };
2724 let json = serde_json::to_string(&progress).unwrap();
2725 let parsed: PackageProgress = serde_json::from_str(&json).unwrap();
2726 assert_eq!(parsed.last_updated_at.timestamp(), 4102444800);
2727 }
2728
2729 #[test]
2730 fn package_progress_all_states_roundtrip() {
2731 let states = vec![
2732 PackageState::Pending,
2733 PackageState::Uploaded,
2734 PackageState::Published,
2735 PackageState::Skipped {
2736 reason: "r".to_string(),
2737 },
2738 PackageState::Failed {
2739 class: ErrorClass::Ambiguous,
2740 message: "m".to_string(),
2741 },
2742 PackageState::Ambiguous {
2743 message: "a".to_string(),
2744 },
2745 ];
2746 for state in states {
2747 let progress = PackageProgress {
2748 name: "test".to_string(),
2749 version: "1.0.0".to_string(),
2750 attempts: 1,
2751 state: state.clone(),
2752 last_updated_at: Utc::now(),
2753 };
2754 let json = serde_json::to_string(&progress).unwrap();
2755 let parsed: PackageProgress = serde_json::from_str(&json).unwrap();
2756 assert_eq!(parsed.state, state);
2757 }
2758 }
2759
2760 fn make_default_runtime_options() -> RuntimeOptions {
2763 RuntimeOptions {
2764 allow_dirty: false,
2765 skip_ownership_check: false,
2766 strict_ownership: false,
2767 no_verify: false,
2768 max_attempts: 3,
2769 base_delay: Duration::from_secs(1),
2770 max_delay: Duration::from_secs(60),
2771 retry_strategy: shipper_retry::RetryStrategyType::Exponential,
2772 retry_jitter: 0.5,
2773 retry_per_error: shipper_retry::PerErrorConfig::default(),
2774 verify_timeout: Duration::from_secs(600),
2775 verify_poll_interval: Duration::from_secs(10),
2776 state_dir: PathBuf::from(".shipper"),
2777 force_resume: false,
2778 policy: PublishPolicy::Safe,
2779 verify_mode: VerifyMode::Workspace,
2780 readiness: ReadinessConfig::default(),
2781 output_lines: 1000,
2782 force: false,
2783 lock_timeout: Duration::from_secs(3600),
2784 parallel: ParallelConfig::default(),
2785 webhook: WebhookConfig::default(),
2786 encryption: EncryptionSettings::default(),
2787 registries: vec![],
2788 resume_from: None,
2789 rehearsal_registry: None,
2790 rehearsal_skip: false,
2791 rehearsal_smoke_install: None,
2792 }
2793 }
2794
2795 #[test]
2796 fn runtime_options_default_values() {
2797 let opts = make_default_runtime_options();
2798 assert!(!opts.allow_dirty);
2799 assert!(!opts.skip_ownership_check);
2800 assert!(!opts.strict_ownership);
2801 assert!(!opts.no_verify);
2802 assert_eq!(opts.max_attempts, 3);
2803 assert_eq!(opts.base_delay, Duration::from_secs(1));
2804 assert_eq!(opts.max_delay, Duration::from_secs(60));
2805 assert_eq!(opts.policy, PublishPolicy::Safe);
2806 assert_eq!(opts.verify_mode, VerifyMode::Workspace);
2807 assert_eq!(opts.output_lines, 1000);
2808 assert!(!opts.force);
2809 assert!(!opts.force_resume);
2810 assert!(opts.registries.is_empty());
2811 assert!(opts.resume_from.is_none());
2812 }
2813
2814 #[test]
2815 fn runtime_options_all_booleans_toggled() {
2816 let opts = RuntimeOptions {
2817 allow_dirty: true,
2818 skip_ownership_check: true,
2819 strict_ownership: true,
2820 no_verify: true,
2821 force_resume: true,
2822 force: true,
2823 ..make_default_runtime_options()
2824 };
2825 assert!(opts.allow_dirty);
2826 assert!(opts.skip_ownership_check);
2827 assert!(opts.strict_ownership);
2828 assert!(opts.no_verify);
2829 assert!(opts.force_resume);
2830 assert!(opts.force);
2831 }
2832
2833 #[test]
2834 fn runtime_options_with_multiple_registries() {
2835 let opts = RuntimeOptions {
2836 registries: vec![
2837 Registry::crates_io(),
2838 Registry {
2839 name: "private".to_string(),
2840 api_base: "https://registry.example.com".to_string(),
2841 index_base: None,
2842 },
2843 ],
2844 ..make_default_runtime_options()
2845 };
2846 assert_eq!(opts.registries.len(), 2);
2847 assert_eq!(opts.registries[0].name, "crates-io");
2848 assert_eq!(opts.registries[1].name, "private");
2849 }
2850
2851 #[test]
2852 fn runtime_options_with_resume_from() {
2853 let opts = RuntimeOptions {
2854 resume_from: Some("my-crate".to_string()),
2855 ..make_default_runtime_options()
2856 };
2857 assert_eq!(opts.resume_from.as_deref(), Some("my-crate"));
2858 }
2859
2860 #[test]
2863 fn registry_get_index_base_derives_from_api_https() {
2864 let reg = Registry {
2865 name: "custom".to_string(),
2866 api_base: "https://registry.example.com".to_string(),
2867 index_base: None,
2868 };
2869 assert_eq!(reg.get_index_base(), "https://index.registry.example.com");
2870 }
2871
2872 #[test]
2873 fn registry_get_index_base_derives_from_api_http() {
2874 let reg = Registry {
2875 name: "local".to_string(),
2876 api_base: "http://localhost:8080".to_string(),
2877 index_base: None,
2878 };
2879 assert_eq!(reg.get_index_base(), "http://index.localhost:8080");
2880 }
2881
2882 #[test]
2883 fn registry_get_index_base_uses_explicit_value() {
2884 let reg = Registry {
2885 name: "custom".to_string(),
2886 api_base: "https://api.example.com".to_string(),
2887 index_base: Some("https://my-index.example.com".to_string()),
2888 };
2889 assert_eq!(reg.get_index_base(), "https://my-index.example.com");
2890 }
2891
2892 #[test]
2893 fn registry_crates_io_get_index_base() {
2894 let reg = Registry::crates_io();
2895 assert_eq!(reg.get_index_base(), "https://index.crates.io");
2896 }
2897
2898 #[test]
2899 fn registry_serde_skips_none_index_base() {
2900 let reg = Registry {
2901 name: "test".to_string(),
2902 api_base: "https://test.io".to_string(),
2903 index_base: None,
2904 };
2905 let json = serde_json::to_string(®).unwrap();
2906 assert!(!json.contains("index_base"));
2907 }
2908
2909 #[test]
2912 fn error_class_serde_values() {
2913 let classes = [
2914 ErrorClass::Retryable,
2915 ErrorClass::Permanent,
2916 ErrorClass::Ambiguous,
2917 ];
2918 let expected = [r#""retryable""#, r#""permanent""#, r#""ambiguous""#];
2919 for (class, exp) in classes.iter().zip(expected.iter()) {
2920 let json = serde_json::to_string(class).unwrap();
2921 assert_eq!(&json, exp);
2922 }
2923 }
2924
2925 #[test]
2926 fn error_class_clone_and_eq() {
2927 let original = ErrorClass::Retryable;
2928 let cloned = original.clone();
2929 assert_eq!(original, cloned);
2930 }
2931
2932 #[test]
2935 fn execution_result_serde_values() {
2936 let results = [
2937 ExecutionResult::Success,
2938 ExecutionResult::PartialFailure,
2939 ExecutionResult::CompleteFailure,
2940 ];
2941 let expected = [
2942 r#""success""#,
2943 r#""partial_failure""#,
2944 r#""complete_failure""#,
2945 ];
2946 for (result, exp) in results.iter().zip(expected.iter()) {
2947 let json = serde_json::to_string(result).unwrap();
2948 assert_eq!(&json, exp);
2949 }
2950 }
2951
2952 #[test]
2955 fn auth_type_serde_values() {
2956 let types = [
2957 AuthType::Token,
2958 AuthType::TrustedPublishing,
2959 AuthType::Unknown,
2960 ];
2961 let expected = [r#""token""#, r#""trusted_publishing""#, r#""unknown""#];
2962 for (auth, exp) in types.iter().zip(expected.iter()) {
2963 let json = serde_json::to_string(auth).unwrap();
2964 assert_eq!(&json, exp);
2965 }
2966 }
2967
2968 #[test]
2971 fn finishability_serde_values() {
2972 let fins = [
2973 Finishability::Proven,
2974 Finishability::NotProven,
2975 Finishability::Failed,
2976 ];
2977 let expected = [r#""proven""#, r#""not_proven""#, r#""failed""#];
2978 for (fin, exp) in fins.iter().zip(expected.iter()) {
2979 let json = serde_json::to_string(fin).unwrap();
2980 assert_eq!(&json, exp);
2981 }
2982 }
2983
2984 #[test]
2987 fn parallel_config_default_values() {
2988 let config = ParallelConfig::default();
2989 assert!(!config.enabled);
2990 assert_eq!(config.max_concurrent, 4);
2991 assert_eq!(config.per_package_timeout, Duration::from_secs(1800));
2992 }
2993
2994 #[test]
2995 fn parallel_config_serde_roundtrip() {
2996 let config = ParallelConfig {
2997 enabled: true,
2998 max_concurrent: 16,
2999 per_package_timeout: Duration::from_secs(300),
3000 };
3001 let json = serde_json::to_string(&config).unwrap();
3002 let parsed: ParallelConfig = serde_json::from_str(&json).unwrap();
3003 assert!(parsed.enabled);
3004 assert_eq!(parsed.max_concurrent, 16);
3005 assert_eq!(parsed.per_package_timeout, Duration::from_secs(300));
3006 }
3007
3008 #[test]
3011 fn package_state_pending_json() {
3012 let json = serde_json::to_string(&PackageState::Pending).unwrap();
3013 assert_eq!(json, r#"{"state":"pending"}"#);
3014 }
3015
3016 #[test]
3017 fn package_state_published_json() {
3018 let json = serde_json::to_string(&PackageState::Published).unwrap();
3019 assert_eq!(json, r#"{"state":"published"}"#);
3020 }
3021
3022 #[test]
3023 fn package_state_skipped_json_contains_reason() {
3024 let state = PackageState::Skipped {
3025 reason: "version exists".to_string(),
3026 };
3027 let json = serde_json::to_string(&state).unwrap();
3028 assert!(json.contains(r#""state":"skipped""#));
3029 assert!(json.contains(r#""reason":"version exists""#));
3030 }
3031
3032 #[test]
3033 fn package_state_ambiguous_serde_roundtrip() {
3034 let state = PackageState::Ambiguous {
3035 message: "timeout during readiness".to_string(),
3036 };
3037 let json = serde_json::to_string(&state).unwrap();
3038 let parsed: PackageState = serde_json::from_str(&json).unwrap();
3039 assert_eq!(parsed, state);
3040 }
3041
3042 #[test]
3045 fn event_type_preflight_variants_roundtrip() {
3046 let events = vec![
3047 EventType::PreflightStarted,
3048 EventType::PreflightWorkspaceVerify {
3049 passed: true,
3050 output: "all good".to_string(),
3051 },
3052 EventType::PreflightNewCrateDetected {
3053 crate_name: "new-crate".to_string(),
3054 },
3055 EventType::PreflightOwnershipCheck {
3056 crate_name: "my-crate".to_string(),
3057 verified: true,
3058 },
3059 EventType::PreflightComplete {
3060 finishability: Finishability::Proven,
3061 },
3062 ];
3063 for event in &events {
3064 let json = serde_json::to_string(event).unwrap();
3065 let parsed: EventType = serde_json::from_str(&json).unwrap();
3066 let reparsed_json = serde_json::to_string(&parsed).unwrap();
3067 assert_eq!(json, reparsed_json);
3068 }
3069 }
3070
3071 #[test]
3074 fn git_context_all_none_roundtrip() {
3075 let ctx = GitContext {
3076 commit: None,
3077 branch: None,
3078 tag: None,
3079 dirty: None,
3080 };
3081 let json = serde_json::to_string(&ctx).unwrap();
3082 let parsed: GitContext = serde_json::from_str(&json).unwrap();
3083 assert!(parsed.commit.is_none());
3084 assert!(parsed.branch.is_none());
3085 assert!(parsed.tag.is_none());
3086 assert!(parsed.dirty.is_none());
3087 }
3088
3089 #[test]
3090 fn git_context_all_some_roundtrip() {
3091 let ctx = GitContext {
3092 commit: Some("abc123".to_string()),
3093 branch: Some("main".to_string()),
3094 tag: Some("v1.0.0".to_string()),
3095 dirty: Some(true),
3096 };
3097 let json = serde_json::to_string(&ctx).unwrap();
3098 let parsed: GitContext = serde_json::from_str(&json).unwrap();
3099 assert_eq!(parsed.commit.as_deref(), Some("abc123"));
3100 assert_eq!(parsed.dirty, Some(true));
3101 }
3102
3103 #[test]
3106 fn environment_fingerprint_optional_fields_roundtrip() {
3107 let fp = EnvironmentFingerprint {
3108 shipper_version: "0.3.0".to_string(),
3109 cargo_version: None,
3110 rust_version: None,
3111 os: "wasm".to_string(),
3112 arch: "wasm32".to_string(),
3113 };
3114 let json = serde_json::to_string(&fp).unwrap();
3115 let parsed: EnvironmentFingerprint = serde_json::from_str(&json).unwrap();
3116 assert!(parsed.cargo_version.is_none());
3117 assert!(parsed.rust_version.is_none());
3118 assert_eq!(parsed.os, "wasm");
3119 }
3120
3121 #[test]
3124 fn attempt_evidence_duration_serialized_as_millis() {
3125 let evidence = AttemptEvidence {
3126 attempt_number: 1,
3127 command: "cargo publish".to_string(),
3128 exit_code: 0,
3129 stdout_tail: String::new(),
3130 stderr_tail: String::new(),
3131 timestamp: Utc::now(),
3132 duration: Duration::from_secs(5),
3133 };
3134 let json = serde_json::to_string(&evidence).unwrap();
3135 assert!(json.contains("5000"));
3136 }
3137
3138 #[test]
3139 fn readiness_evidence_duration_serialized_as_millis() {
3140 let evidence = ReadinessEvidence {
3141 attempt: 1,
3142 visible: true,
3143 timestamp: Utc::now(),
3144 delay_before: Duration::from_millis(2500),
3145 };
3146 let json = serde_json::to_string(&evidence).unwrap();
3147 assert!(json.contains("2500"));
3148 }
3149
3150 #[test]
3153 fn readiness_config_serde_with_index_path_roundtrip() {
3154 let config = ReadinessConfig {
3155 enabled: true,
3156 method: ReadinessMethod::Index,
3157 initial_delay: Duration::from_secs(2),
3158 max_delay: Duration::from_secs(120),
3159 max_total_wait: Duration::from_secs(600),
3160 poll_interval: Duration::from_secs(5),
3161 jitter_factor: 0.3,
3162 index_path: Some(PathBuf::from("/tmp/test-index")),
3163 prefer_index: true,
3164 };
3165 let json = serde_json::to_string(&config).unwrap();
3166 let parsed: ReadinessConfig = serde_json::from_str(&json).unwrap();
3167 assert_eq!(parsed.index_path, Some(PathBuf::from("/tmp/test-index")));
3168 assert!(parsed.prefer_index);
3169 }
3170
3171 #[test]
3172 fn readiness_config_defaults_from_json_empty_object() {
3173 let config: ReadinessConfig = serde_json::from_str("{}").unwrap();
3174 assert!(config.enabled);
3175 assert_eq!(config.method, ReadinessMethod::Api);
3176 assert_eq!(config.jitter_factor, 0.5);
3177 assert!(!config.prefer_index);
3178 assert!(config.index_path.is_none());
3179 }
3180
3181 mod debug_snapshots {
3184 use super::*;
3185
3186 #[test]
3187 fn publish_policy_debug_snapshot() {
3188 insta::assert_debug_snapshot!(PublishPolicy::Safe);
3189 insta::assert_debug_snapshot!(PublishPolicy::Balanced);
3190 insta::assert_debug_snapshot!(PublishPolicy::Fast);
3191 }
3192
3193 #[test]
3194 fn verify_mode_debug_snapshot() {
3195 insta::assert_debug_snapshot!(VerifyMode::Workspace);
3196 insta::assert_debug_snapshot!(VerifyMode::Package);
3197 insta::assert_debug_snapshot!(VerifyMode::None);
3198 }
3199
3200 #[test]
3201 fn readiness_method_debug_snapshot() {
3202 insta::assert_debug_snapshot!(ReadinessMethod::Api);
3203 insta::assert_debug_snapshot!(ReadinessMethod::Index);
3204 insta::assert_debug_snapshot!(ReadinessMethod::Both);
3205 }
3206
3207 #[test]
3208 fn runtime_options_debug_snapshot() {
3209 let opts = super::make_default_runtime_options();
3210 insta::assert_debug_snapshot!(opts);
3211 }
3212
3213 #[test]
3214 fn package_progress_debug_snapshot() {
3215 let t = "2025-01-15T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
3216 let progress = PackageProgress {
3217 name: "snapshot-crate".to_string(),
3218 version: "1.0.0".to_string(),
3219 attempts: 2,
3220 state: PackageState::Failed {
3221 class: ErrorClass::Retryable,
3222 message: "timeout".to_string(),
3223 },
3224 last_updated_at: t,
3225 };
3226 insta::assert_debug_snapshot!(progress);
3227 }
3228 }
3229
3230 mod snapshots {
3231 use super::*;
3232
3233 fn fixed_time() -> DateTime<Utc> {
3234 "2025-01-15T12:00:00Z".parse::<DateTime<Utc>>().unwrap()
3235 }
3236
3237 #[test]
3238 fn release_plan_snapshot() {
3239 let plan = ReleasePlan {
3240 plan_version: "shipper.plan.v1".to_string(),
3241 plan_id: "abc123".to_string(),
3242 created_at: fixed_time(),
3243 registry: Registry::crates_io(),
3244 packages: vec![
3245 PlannedPackage {
3246 name: "core-lib".to_string(),
3247 version: "0.1.0".to_string(),
3248 manifest_path: PathBuf::from("crates/core-lib/Cargo.toml"),
3249 regime: None,
3250 },
3251 PlannedPackage {
3252 name: "my-cli".to_string(),
3253 version: "0.2.0".to_string(),
3254 manifest_path: PathBuf::from("crates/my-cli/Cargo.toml"),
3255 regime: None,
3256 },
3257 ],
3258 dependencies: BTreeMap::from([(
3259 "my-cli".to_string(),
3260 vec!["core-lib".to_string()],
3261 )]),
3262 };
3263 insta::assert_yaml_snapshot!(plan);
3264 }
3265
3266 #[test]
3267 fn package_state_all_variants() {
3268 let variants: Vec<(&str, PackageState)> = vec![
3269 ("pending", PackageState::Pending),
3270 ("uploaded", PackageState::Uploaded),
3271 ("published", PackageState::Published),
3272 (
3273 "skipped",
3274 PackageState::Skipped {
3275 reason: "already published".to_string(),
3276 },
3277 ),
3278 (
3279 "failed",
3280 PackageState::Failed {
3281 class: ErrorClass::Retryable,
3282 message: "network timeout".to_string(),
3283 },
3284 ),
3285 (
3286 "ambiguous",
3287 PackageState::Ambiguous {
3288 message: "unclear outcome".to_string(),
3289 },
3290 ),
3291 ];
3292 for (label, state) in variants {
3293 insta::assert_yaml_snapshot!(format!("package_state_{label}"), state);
3294 }
3295 }
3296
3297 #[test]
3298 fn receipt_full_snapshot() {
3299 let t = fixed_time();
3300 let receipt = Receipt {
3301 receipt_version: "shipper.receipt.v1".to_string(),
3302 plan_id: "plan-42".to_string(),
3303 registry: Registry::crates_io(),
3304 started_at: t,
3305 finished_at: t,
3306 packages: vec![PackageReceipt {
3307 name: "demo".to_string(),
3308 version: "1.0.0".to_string(),
3309 attempts: 1,
3310 state: PackageState::Published,
3311 started_at: t,
3312 finished_at: t,
3313 duration_ms: 4500,
3314 evidence: PackageEvidence {
3315 attempts: vec![AttemptEvidence {
3316 attempt_number: 1,
3317 command: "cargo publish -p demo".to_string(),
3318 exit_code: 0,
3319 stdout_tail: "Uploading demo v1.0.0".to_string(),
3320 stderr_tail: String::new(),
3321 timestamp: t,
3322 duration: Duration::from_millis(4200),
3323 }],
3324 readiness_checks: vec![ReadinessEvidence {
3325 attempt: 1,
3326 visible: true,
3327 timestamp: t,
3328 delay_before: Duration::from_secs(2),
3329 }],
3330 },
3331 compromised_at: None,
3332 compromised_by: None,
3333 superseded_by: None,
3334 }],
3335 event_log_path: PathBuf::from(".shipper/events.jsonl"),
3336 git_context: Some(GitContext {
3337 commit: Some("abcdef1234567890".to_string()),
3338 branch: Some("main".to_string()),
3339 tag: Some("v1.0.0".to_string()),
3340 dirty: Some(false),
3341 }),
3342 environment: EnvironmentFingerprint {
3343 shipper_version: "0.2.0".to_string(),
3344 cargo_version: Some("1.82.0".to_string()),
3345 rust_version: Some("1.82.0".to_string()),
3346 os: "linux".to_string(),
3347 arch: "x86_64".to_string(),
3348 },
3349 auth_evidence: None,
3350 };
3351 insta::assert_yaml_snapshot!(receipt);
3352 }
3353
3354 #[test]
3355 fn execution_state_snapshot() {
3356 let t = fixed_time();
3357 let mut packages = BTreeMap::new();
3358 packages.insert(
3359 "core-lib@0.1.0".to_string(),
3360 PackageProgress {
3361 name: "core-lib".to_string(),
3362 version: "0.1.0".to_string(),
3363 attempts: 1,
3364 state: PackageState::Published,
3365 last_updated_at: t,
3366 },
3367 );
3368 packages.insert(
3369 "my-cli@0.2.0".to_string(),
3370 PackageProgress {
3371 name: "my-cli".to_string(),
3372 version: "0.2.0".to_string(),
3373 attempts: 0,
3374 state: PackageState::Pending,
3375 last_updated_at: t,
3376 },
3377 );
3378 let state = ExecutionState {
3379 state_version: "shipper.state.v1".to_string(),
3380 plan_id: "plan-42".to_string(),
3381 registry: Registry::crates_io(),
3382 created_at: t,
3383 updated_at: t,
3384 attempt_history: Vec::new(),
3385 packages,
3386 };
3387 insta::assert_yaml_snapshot!(state);
3388 }
3389
3390 #[test]
3391 fn preflight_report_snapshot() {
3392 let report = PreflightReport {
3393 plan_id: "plan-42".to_string(),
3394 token_detected: true,
3395 finishability: Finishability::Proven,
3396 packages: vec![
3397 PreflightPackage {
3398 name: "core-lib".to_string(),
3399 version: "0.1.0".to_string(),
3400 already_published: false,
3401 is_new_crate: true,
3402 auth_type: Some(AuthType::Token),
3403 ownership_verified: true,
3404 dry_run_passed: true,
3405 dry_run_output: None,
3406 },
3407 PreflightPackage {
3408 name: "my-cli".to_string(),
3409 version: "0.2.0".to_string(),
3410 already_published: false,
3411 is_new_crate: false,
3412 auth_type: Some(AuthType::TrustedPublishing),
3413 ownership_verified: true,
3414 dry_run_passed: true,
3415 dry_run_output: Some("dry-run ok".to_string()),
3416 },
3417 ],
3418 timestamp: fixed_time(),
3419 estimated_publish_duration: None,
3420 dry_run_output: Some("workspace dry-run passed".to_string()),
3421 };
3422 insta::assert_yaml_snapshot!(report);
3423 }
3424
3425 #[test]
3428 fn release_plan_single_package() {
3429 let plan = ReleasePlan {
3430 plan_version: "shipper.plan.v1".to_string(),
3431 plan_id: "single-pkg-001".to_string(),
3432 created_at: fixed_time(),
3433 registry: Registry::crates_io(),
3434 packages: vec![PlannedPackage {
3435 name: "solo-crate".to_string(),
3436 version: "1.0.0".to_string(),
3437 manifest_path: PathBuf::from("Cargo.toml"),
3438 regime: None,
3439 }],
3440 dependencies: BTreeMap::new(),
3441 };
3442 insta::assert_yaml_snapshot!(plan);
3443 }
3444
3445 #[test]
3446 fn release_plan_custom_registry() {
3447 let plan = ReleasePlan {
3448 plan_version: "shipper.plan.v1".to_string(),
3449 plan_id: "custom-reg-001".to_string(),
3450 created_at: fixed_time(),
3451 registry: Registry {
3452 name: "my-private-registry".to_string(),
3453 api_base: "https://registry.example.com".to_string(),
3454 index_base: Some("https://index.registry.example.com".to_string()),
3455 },
3456 packages: vec![
3457 PlannedPackage {
3458 name: "internal-utils".to_string(),
3459 version: "2.1.0".to_string(),
3460 manifest_path: PathBuf::from("crates/internal-utils/Cargo.toml"),
3461 regime: None,
3462 },
3463 PlannedPackage {
3464 name: "internal-api".to_string(),
3465 version: "3.0.0".to_string(),
3466 manifest_path: PathBuf::from("crates/internal-api/Cargo.toml"),
3467 regime: None,
3468 },
3469 ],
3470 dependencies: BTreeMap::from([(
3471 "internal-api".to_string(),
3472 vec!["internal-utils".to_string()],
3473 )]),
3474 };
3475 insta::assert_yaml_snapshot!(plan);
3476 }
3477
3478 #[test]
3479 fn release_plan_deep_dependency_chain() {
3480 let plan = ReleasePlan {
3481 plan_version: "shipper.plan.v1".to_string(),
3482 plan_id: "deep-deps-001".to_string(),
3483 created_at: fixed_time(),
3484 registry: Registry::crates_io(),
3485 packages: vec![
3486 PlannedPackage {
3487 name: "foundation".to_string(),
3488 version: "0.1.0".to_string(),
3489 manifest_path: PathBuf::from("crates/foundation/Cargo.toml"),
3490 regime: None,
3491 },
3492 PlannedPackage {
3493 name: "middleware".to_string(),
3494 version: "0.2.0".to_string(),
3495 manifest_path: PathBuf::from("crates/middleware/Cargo.toml"),
3496 regime: None,
3497 },
3498 PlannedPackage {
3499 name: "service".to_string(),
3500 version: "0.3.0".to_string(),
3501 manifest_path: PathBuf::from("crates/service/Cargo.toml"),
3502 regime: None,
3503 },
3504 PlannedPackage {
3505 name: "gateway".to_string(),
3506 version: "1.0.0".to_string(),
3507 manifest_path: PathBuf::from("crates/gateway/Cargo.toml"),
3508 regime: None,
3509 },
3510 ],
3511 dependencies: BTreeMap::from([
3512 ("foundation".to_string(), Vec::new()),
3513 ("middleware".to_string(), vec!["foundation".to_string()]),
3514 (
3515 "service".to_string(),
3516 vec!["foundation".to_string(), "middleware".to_string()],
3517 ),
3518 ("gateway".to_string(), vec!["service".to_string()]),
3519 ]),
3520 };
3521 insta::assert_yaml_snapshot!(plan);
3522 }
3523
3524 #[test]
3527 fn receipt_partial_failure() {
3528 let t = fixed_time();
3529 let receipt = Receipt {
3530 receipt_version: "shipper.receipt.v1".to_string(),
3531 plan_id: "plan-partial".to_string(),
3532 registry: Registry::crates_io(),
3533 started_at: t,
3534 finished_at: t,
3535 packages: vec![
3536 PackageReceipt {
3537 name: "core-lib".to_string(),
3538 version: "0.1.0".to_string(),
3539 attempts: 1,
3540 state: PackageState::Published,
3541 started_at: t,
3542 finished_at: t,
3543 duration_ms: 3200,
3544 evidence: PackageEvidence {
3545 attempts: vec![AttemptEvidence {
3546 attempt_number: 1,
3547 command: "cargo publish -p core-lib".to_string(),
3548 exit_code: 0,
3549 stdout_tail: "Uploading core-lib v0.1.0".to_string(),
3550 stderr_tail: String::new(),
3551 timestamp: t,
3552 duration: Duration::from_millis(3000),
3553 }],
3554 readiness_checks: vec![ReadinessEvidence {
3555 attempt: 1,
3556 visible: true,
3557 timestamp: t,
3558 delay_before: Duration::from_secs(1),
3559 }],
3560 },
3561 compromised_at: None,
3562 compromised_by: None,
3563 superseded_by: None,
3564 },
3565 PackageReceipt {
3566 name: "api-server".to_string(),
3567 version: "0.2.0".to_string(),
3568 attempts: 3,
3569 state: PackageState::Failed {
3570 class: ErrorClass::Retryable,
3571 message: "rate limited by registry".to_string(),
3572 },
3573 started_at: t,
3574 finished_at: t,
3575 duration_ms: 15000,
3576 evidence: PackageEvidence {
3577 attempts: vec![
3578 AttemptEvidence {
3579 attempt_number: 1,
3580 command: "cargo publish -p api-server".to_string(),
3581 exit_code: 1,
3582 stdout_tail: String::new(),
3583 stderr_tail: "error: rate limit exceeded".to_string(),
3584 timestamp: t,
3585 duration: Duration::from_millis(500),
3586 },
3587 AttemptEvidence {
3588 attempt_number: 2,
3589 command: "cargo publish -p api-server".to_string(),
3590 exit_code: 1,
3591 stdout_tail: String::new(),
3592 stderr_tail: "error: rate limit exceeded".to_string(),
3593 timestamp: t,
3594 duration: Duration::from_millis(600),
3595 },
3596 AttemptEvidence {
3597 attempt_number: 3,
3598 command: "cargo publish -p api-server".to_string(),
3599 exit_code: 1,
3600 stdout_tail: String::new(),
3601 stderr_tail: "error: rate limit exceeded".to_string(),
3602 timestamp: t,
3603 duration: Duration::from_millis(700),
3604 },
3605 ],
3606 readiness_checks: vec![],
3607 },
3608 compromised_at: None,
3609 compromised_by: None,
3610 superseded_by: None,
3611 },
3612 PackageReceipt {
3613 name: "old-compat".to_string(),
3614 version: "0.1.0".to_string(),
3615 attempts: 0,
3616 state: PackageState::Skipped {
3617 reason: "version already exists on registry".to_string(),
3618 },
3619 started_at: t,
3620 finished_at: t,
3621 duration_ms: 50,
3622 evidence: PackageEvidence {
3623 attempts: vec![],
3624 readiness_checks: vec![],
3625 },
3626 compromised_at: None,
3627 compromised_by: None,
3628 superseded_by: None,
3629 },
3630 ],
3631 event_log_path: PathBuf::from(".shipper/events.jsonl"),
3632 git_context: Some(GitContext {
3633 commit: Some("deadbeef12345678".to_string()),
3634 branch: Some("release/v0.2".to_string()),
3635 tag: None,
3636 dirty: Some(true),
3637 }),
3638 environment: EnvironmentFingerprint {
3639 shipper_version: "0.3.0".to_string(),
3640 cargo_version: Some("1.82.0".to_string()),
3641 rust_version: Some("1.82.0".to_string()),
3642 os: "linux".to_string(),
3643 arch: "x86_64".to_string(),
3644 },
3645 auth_evidence: None,
3646 };
3647 insta::assert_yaml_snapshot!(receipt);
3648 }
3649
3650 #[test]
3651 fn receipt_no_git_context() {
3652 let t = fixed_time();
3653 let receipt = Receipt {
3654 receipt_version: "shipper.receipt.v1".to_string(),
3655 plan_id: "plan-nogit".to_string(),
3656 registry: Registry::crates_io(),
3657 started_at: t,
3658 finished_at: t,
3659 packages: vec![PackageReceipt {
3660 name: "headless-lib".to_string(),
3661 version: "0.5.0".to_string(),
3662 attempts: 1,
3663 state: PackageState::Published,
3664 started_at: t,
3665 finished_at: t,
3666 duration_ms: 2000,
3667 evidence: PackageEvidence {
3668 attempts: vec![],
3669 readiness_checks: vec![],
3670 },
3671 compromised_at: None,
3672 compromised_by: None,
3673 superseded_by: None,
3674 }],
3675 event_log_path: PathBuf::from(".shipper/events.jsonl"),
3676 git_context: None,
3677 environment: EnvironmentFingerprint {
3678 shipper_version: "0.3.0".to_string(),
3679 cargo_version: None,
3680 rust_version: None,
3681 os: "windows".to_string(),
3682 arch: "aarch64".to_string(),
3683 },
3684 auth_evidence: None,
3685 };
3686 insta::assert_yaml_snapshot!(receipt);
3687 }
3688
3689 #[test]
3690 fn receipt_complete_failure() {
3691 let t = fixed_time();
3692 let receipt = Receipt {
3693 receipt_version: "shipper.receipt.v1".to_string(),
3694 plan_id: "plan-allfail".to_string(),
3695 registry: Registry::crates_io(),
3696 started_at: t,
3697 finished_at: t,
3698 packages: vec![
3699 PackageReceipt {
3700 name: "broken-crate".to_string(),
3701 version: "0.1.0".to_string(),
3702 attempts: 3,
3703 state: PackageState::Failed {
3704 class: ErrorClass::Permanent,
3705 message: "invalid credentials".to_string(),
3706 },
3707 started_at: t,
3708 finished_at: t,
3709 duration_ms: 800,
3710 evidence: PackageEvidence {
3711 attempts: vec![AttemptEvidence {
3712 attempt_number: 1,
3713 command: "cargo publish -p broken-crate".to_string(),
3714 exit_code: 1,
3715 stdout_tail: String::new(),
3716 stderr_tail: "error: 403 Forbidden".to_string(),
3717 timestamp: t,
3718 duration: Duration::from_millis(200),
3719 }],
3720 readiness_checks: vec![],
3721 },
3722 compromised_at: None,
3723 compromised_by: None,
3724 superseded_by: None,
3725 },
3726 PackageReceipt {
3727 name: "dependent-crate".to_string(),
3728 version: "0.2.0".to_string(),
3729 attempts: 0,
3730 state: PackageState::Skipped {
3731 reason: "dependency broken-crate failed".to_string(),
3732 },
3733 started_at: t,
3734 finished_at: t,
3735 duration_ms: 0,
3736 evidence: PackageEvidence {
3737 attempts: vec![],
3738 readiness_checks: vec![],
3739 },
3740 compromised_at: None,
3741 compromised_by: None,
3742 superseded_by: None,
3743 },
3744 ],
3745 event_log_path: PathBuf::from(".shipper/events.jsonl"),
3746 git_context: Some(GitContext {
3747 commit: Some("abcdef0123456789".to_string()),
3748 branch: Some("main".to_string()),
3749 tag: Some("v0.1.0".to_string()),
3750 dirty: Some(false),
3751 }),
3752 environment: EnvironmentFingerprint {
3753 shipper_version: "0.3.0".to_string(),
3754 cargo_version: Some("1.82.0".to_string()),
3755 rust_version: Some("1.82.0".to_string()),
3756 os: "macos".to_string(),
3757 arch: "aarch64".to_string(),
3758 },
3759 auth_evidence: None,
3760 };
3761 insta::assert_yaml_snapshot!(receipt);
3762 }
3763
3764 #[test]
3767 fn execution_state_all_pending() {
3768 let t = fixed_time();
3769 let mut packages = BTreeMap::new();
3770 for (name, ver) in [("alpha", "0.1.0"), ("beta", "0.2.0"), ("gamma", "0.3.0")] {
3771 packages.insert(
3772 format!("{name}@{ver}"),
3773 PackageProgress {
3774 name: name.to_string(),
3775 version: ver.to_string(),
3776 attempts: 0,
3777 state: PackageState::Pending,
3778 last_updated_at: t,
3779 },
3780 );
3781 }
3782 let state = ExecutionState {
3783 state_version: "shipper.state.v1".to_string(),
3784 plan_id: "plan-fresh".to_string(),
3785 registry: Registry::crates_io(),
3786 created_at: t,
3787 updated_at: t,
3788 attempt_history: Vec::new(),
3789 packages,
3790 };
3791 insta::assert_yaml_snapshot!(state);
3792 }
3793
3794 #[test]
3795 fn execution_state_completed() {
3796 let t = fixed_time();
3797 let mut packages = BTreeMap::new();
3798 for (name, ver) in [("alpha", "0.1.0"), ("beta", "0.2.0")] {
3799 packages.insert(
3800 format!("{name}@{ver}"),
3801 PackageProgress {
3802 name: name.to_string(),
3803 version: ver.to_string(),
3804 attempts: 1,
3805 state: PackageState::Published,
3806 last_updated_at: t,
3807 },
3808 );
3809 }
3810 let state = ExecutionState {
3811 state_version: "shipper.state.v1".to_string(),
3812 plan_id: "plan-done".to_string(),
3813 registry: Registry::crates_io(),
3814 created_at: t,
3815 updated_at: t,
3816 attempt_history: Vec::new(),
3817 packages,
3818 };
3819 insta::assert_yaml_snapshot!(state);
3820 }
3821
3822 #[test]
3823 fn execution_state_mixed_with_failures() {
3824 let t = fixed_time();
3825 let mut packages = BTreeMap::new();
3826 packages.insert(
3827 "core@0.1.0".to_string(),
3828 PackageProgress {
3829 name: "core".to_string(),
3830 version: "0.1.0".to_string(),
3831 attempts: 1,
3832 state: PackageState::Published,
3833 last_updated_at: t,
3834 },
3835 );
3836 packages.insert(
3837 "net@0.2.0".to_string(),
3838 PackageProgress {
3839 name: "net".to_string(),
3840 version: "0.2.0".to_string(),
3841 attempts: 3,
3842 state: PackageState::Failed {
3843 class: ErrorClass::Retryable,
3844 message: "connection reset".to_string(),
3845 },
3846 last_updated_at: t,
3847 },
3848 );
3849 packages.insert(
3850 "cli@0.3.0".to_string(),
3851 PackageProgress {
3852 name: "cli".to_string(),
3853 version: "0.3.0".to_string(),
3854 attempts: 1,
3855 state: PackageState::Ambiguous {
3856 message: "upload succeeded but readiness timed out".to_string(),
3857 },
3858 last_updated_at: t,
3859 },
3860 );
3861 packages.insert(
3862 "compat@0.1.0".to_string(),
3863 PackageProgress {
3864 name: "compat".to_string(),
3865 version: "0.1.0".to_string(),
3866 attempts: 0,
3867 state: PackageState::Skipped {
3868 reason: "version already on registry".to_string(),
3869 },
3870 last_updated_at: t,
3871 },
3872 );
3873 let state = ExecutionState {
3874 state_version: "shipper.state.v1".to_string(),
3875 plan_id: "plan-mixed".to_string(),
3876 registry: Registry::crates_io(),
3877 created_at: t,
3878 updated_at: t,
3879 attempt_history: Vec::new(),
3880 packages,
3881 };
3882 insta::assert_yaml_snapshot!(state);
3883 }
3884
3885 #[test]
3888 fn readiness_config_default_snapshot() {
3889 let config = ReadinessConfig::default();
3890 insta::assert_yaml_snapshot!(config);
3891 }
3892
3893 #[test]
3894 fn readiness_config_custom_snapshot() {
3895 let config = ReadinessConfig {
3896 enabled: false,
3897 method: ReadinessMethod::Both,
3898 initial_delay: Duration::from_millis(500),
3899 max_delay: Duration::from_secs(120),
3900 max_total_wait: Duration::from_secs(900),
3901 poll_interval: Duration::from_secs(10),
3902 jitter_factor: 0.25,
3903 index_path: Some(PathBuf::from("/tmp/test-index")),
3904 prefer_index: true,
3905 };
3906 insta::assert_yaml_snapshot!(config);
3907 }
3908
3909 #[test]
3910 fn parallel_config_default_snapshot() {
3911 let config = ParallelConfig::default();
3912 insta::assert_yaml_snapshot!(config);
3913 }
3914
3915 #[test]
3916 fn parallel_config_enabled_snapshot() {
3917 let config = ParallelConfig {
3918 enabled: true,
3919 max_concurrent: 8,
3920 per_package_timeout: Duration::from_secs(600),
3921 };
3922 insta::assert_yaml_snapshot!(config);
3923 }
3924
3925 #[test]
3928 fn environment_fingerprint_snapshot() {
3929 let fp = EnvironmentFingerprint {
3930 shipper_version: "0.3.0".to_string(),
3931 cargo_version: Some("1.82.0".to_string()),
3932 rust_version: Some("1.82.0".to_string()),
3933 os: "linux".to_string(),
3934 arch: "x86_64".to_string(),
3935 };
3936 insta::assert_yaml_snapshot!(fp);
3937 }
3938
3939 #[test]
3940 fn git_context_full_snapshot() {
3941 let ctx = GitContext {
3942 commit: Some("a1b2c3d4e5f6".to_string()),
3943 branch: Some("release/v2.0".to_string()),
3944 tag: Some("v2.0.0".to_string()),
3945 dirty: Some(false),
3946 };
3947 insta::assert_yaml_snapshot!(ctx);
3948 }
3949
3950 #[test]
3951 fn git_context_minimal_snapshot() {
3952 let ctx = GitContext {
3953 commit: None,
3954 branch: None,
3955 tag: None,
3956 dirty: None,
3957 };
3958 insta::assert_yaml_snapshot!(ctx);
3959 }
3960
3961 #[test]
3962 fn publish_event_lifecycle_snapshot() {
3963 let t = fixed_time();
3964 let events = vec![
3965 PublishEvent {
3966 timestamp: t,
3967 event_type: EventType::PlanCreated {
3968 plan_id: "plan-99".to_string(),
3969 package_count: 3,
3970 },
3971 package: String::new(),
3972 },
3973 PublishEvent {
3974 timestamp: t,
3975 event_type: EventType::ExecutionStarted,
3976 package: String::new(),
3977 },
3978 PublishEvent {
3979 timestamp: t,
3980 event_type: EventType::ExecutionFinished {
3981 result: ExecutionResult::PartialFailure,
3982 },
3983 package: String::new(),
3984 },
3985 ];
3986 insta::assert_yaml_snapshot!(events);
3987 }
3988
3989 #[test]
3990 fn publish_event_package_flow_snapshot() {
3991 let t = fixed_time();
3992 let events = vec![
3993 PublishEvent {
3994 timestamp: t,
3995 event_type: EventType::PackageStarted {
3996 name: "my-crate".to_string(),
3997 version: "1.0.0".to_string(),
3998 },
3999 package: "my-crate@1.0.0".to_string(),
4000 },
4001 PublishEvent {
4002 timestamp: t,
4003 event_type: EventType::PackageAttempted {
4004 attempt: 1,
4005 command: "cargo publish -p my-crate".to_string(),
4006 },
4007 package: "my-crate@1.0.0".to_string(),
4008 },
4009 PublishEvent {
4010 timestamp: t,
4011 event_type: EventType::PackageOutput {
4012 stdout_tail: "Uploading my-crate v1.0.0".to_string(),
4013 stderr_tail: String::new(),
4014 },
4015 package: "my-crate@1.0.0".to_string(),
4016 },
4017 PublishEvent {
4018 timestamp: t,
4019 event_type: EventType::PackagePublished { duration_ms: 4500 },
4020 package: "my-crate@1.0.0".to_string(),
4021 },
4022 ];
4023 insta::assert_yaml_snapshot!(events);
4024 }
4025
4026 #[test]
4027 fn error_class_all_variants_snapshot() {
4028 let variants: Vec<(&str, ErrorClass)> = vec![
4029 ("retryable", ErrorClass::Retryable),
4030 ("permanent", ErrorClass::Permanent),
4031 ("ambiguous", ErrorClass::Ambiguous),
4032 ];
4033 for (label, class) in variants {
4034 insta::assert_yaml_snapshot!(format!("error_class_{label}"), class);
4035 }
4036 }
4037
4038 #[test]
4039 fn execution_result_all_variants_snapshot() {
4040 let variants: Vec<(&str, ExecutionResult)> = vec![
4041 ("success", ExecutionResult::Success),
4042 ("partial_failure", ExecutionResult::PartialFailure),
4043 ("complete_failure", ExecutionResult::CompleteFailure),
4044 ];
4045 for (label, result) in variants {
4046 insta::assert_yaml_snapshot!(format!("execution_result_{label}"), result);
4047 }
4048 }
4049
4050 #[test]
4051 fn finishability_all_variants_snapshot() {
4052 let variants: Vec<(&str, Finishability)> = vec![
4053 ("proven", Finishability::Proven),
4054 ("not_proven", Finishability::NotProven),
4055 ("failed", Finishability::Failed),
4056 ];
4057 for (label, fin) in variants {
4058 insta::assert_yaml_snapshot!(format!("finishability_{label}"), fin);
4059 }
4060 }
4061
4062 #[test]
4063 fn preflight_report_failed_snapshot() {
4064 let report = PreflightReport {
4065 plan_id: "plan-fail-preflight".to_string(),
4066 token_detected: false,
4067 finishability: Finishability::Failed,
4068 packages: vec![PreflightPackage {
4069 name: "broken".to_string(),
4070 version: "0.1.0".to_string(),
4071 already_published: false,
4072 is_new_crate: true,
4073 auth_type: None,
4074 ownership_verified: false,
4075 dry_run_passed: false,
4076 dry_run_output: Some("error: could not compile".to_string()),
4077 }],
4078 timestamp: fixed_time(),
4079 estimated_publish_duration: None,
4080 dry_run_output: Some("workspace dry-run failed".to_string()),
4081 };
4082 insta::assert_yaml_snapshot!(report);
4083 }
4084 }
4085
4086 #[cfg(test)]
4089 mod proptests {
4090 use super::*;
4091 use proptest::prelude::*;
4092
4093 proptest! {
4094 #[test]
4096 fn preflight_report_roundtrip(
4097 plan_id in "[a-z0-9-]+",
4098 token_detected in any::<bool>(),
4099 finishability_variant in 0u8..3,
4100 package_count in 0usize..10,
4101 ) {
4102 let finishability = match finishability_variant {
4103 0 => Finishability::Proven,
4104 1 => Finishability::NotProven,
4105 _ => Finishability::Failed,
4106 };
4107
4108 let packages: Vec<PreflightPackage> = (0..package_count)
4109 .map(|i| PreflightPackage {
4110 name: format!("crate-{}", i),
4111 version: format!("0.{}.0", i),
4112 already_published: i % 2 == 0,
4113 is_new_crate: i % 3 == 0,
4114 auth_type: if i % 2 == 0 { Some(AuthType::Token) } else { None },
4115 ownership_verified: i % 3 != 0,
4116 dry_run_passed: i % 5 != 0,
4117 dry_run_output: if i % 5 == 0 { Some("failed".to_string()) } else { None },
4118 })
4119 .collect();
4120
4121 let report = PreflightReport {
4122 plan_id: plan_id.clone(),
4123 token_detected,
4124 finishability,
4125 packages: packages.clone(),
4126 timestamp: Utc::now(),
4127 estimated_publish_duration: None,
4128 dry_run_output: Some("workspace dry-run output".to_string()),
4129 };
4130
4131 let json = serde_json::to_string(&report).unwrap();
4133 let parsed: PreflightReport = serde_json::from_str(&json).unwrap();
4134
4135 assert_eq!(parsed.plan_id, report.plan_id);
4137 assert_eq!(parsed.token_detected, report.token_detected);
4138 assert_eq!(parsed.finishability, report.finishability);
4139 assert_eq!(parsed.packages.len(), report.packages.len());
4140 assert_eq!(parsed.dry_run_output, report.dry_run_output);
4141 for (orig, parsed_pkg) in report.packages.iter().zip(parsed.packages.iter()) {
4142 assert_eq!(parsed_pkg.name, orig.name);
4143 assert_eq!(parsed_pkg.version, orig.version);
4144 assert_eq!(parsed_pkg.already_published, orig.already_published);
4145 assert_eq!(parsed_pkg.is_new_crate, orig.is_new_crate);
4146 assert_eq!(parsed_pkg.auth_type, orig.auth_type);
4147 assert_eq!(parsed_pkg.ownership_verified, orig.ownership_verified);
4148 assert_eq!(parsed_pkg.dry_run_passed, orig.dry_run_passed);
4149 assert_eq!(parsed_pkg.dry_run_output, orig.dry_run_output);
4150 }
4151 }
4152
4153 #[test]
4155 fn preflight_package_roundtrip(
4156 name in "[a-z][a-z0-9-]*",
4157 version in "[0-9]+\\.[0-9]+\\.[0-9]+",
4158 already_published in any::<bool>(),
4159 is_new_crate in any::<bool>(),
4160 auth_type_variant in 0u8..4,
4161 ownership_verified in any::<bool>(),
4162 dry_run_passed in any::<bool>(),
4163 dry_run_output in proptest::option::of(".*"),
4164 ) {
4165 let auth_type = match auth_type_variant {
4166 0 => Some(AuthType::Token),
4167 1 => Some(AuthType::TrustedPublishing),
4168 2 => Some(AuthType::Unknown),
4169 _ => None,
4170 };
4171
4172 let pkg = PreflightPackage {
4173 name: name.clone(),
4174 version: version.clone(),
4175 already_published,
4176 is_new_crate,
4177 auth_type: auth_type.clone(),
4178 ownership_verified,
4179 dry_run_passed,
4180 dry_run_output: dry_run_output.clone(),
4181 };
4182
4183 let json = serde_json::to_string(&pkg).unwrap();
4185 let parsed: PreflightPackage = serde_json::from_str(&json).unwrap();
4186
4187 assert_eq!(parsed.name, pkg.name);
4189 assert_eq!(parsed.version, pkg.version);
4190 assert_eq!(parsed.already_published, pkg.already_published);
4191 assert_eq!(parsed.is_new_crate, pkg.is_new_crate);
4192 assert_eq!(parsed.auth_type, pkg.auth_type);
4193 assert_eq!(parsed.ownership_verified, pkg.ownership_verified);
4194 assert_eq!(parsed.dry_run_passed, pkg.dry_run_passed);
4195 assert_eq!(parsed.dry_run_output, pkg.dry_run_output);
4196 }
4197
4198 #[test]
4200 fn auth_type_roundtrip(auth_type_variant in 0u8..3) {
4201 let auth_type = match auth_type_variant {
4202 0 => AuthType::Token,
4203 1 => AuthType::TrustedPublishing,
4204 _ => AuthType::Unknown,
4205 };
4206
4207 let json = serde_json::to_string(&auth_type).unwrap();
4208 let parsed: AuthType = serde_json::from_str(&json).unwrap();
4209
4210 assert_eq!(parsed, auth_type);
4211 }
4212
4213 #[test]
4215 fn finishability_roundtrip(finishability_variant in 0u8..3) {
4216 let finishability = match finishability_variant {
4217 0 => Finishability::Proven,
4218 1 => Finishability::NotProven,
4219 _ => Finishability::Failed,
4220 };
4221
4222 let json = serde_json::to_string(&finishability).unwrap();
4223 let parsed: Finishability = serde_json::from_str(&json).unwrap();
4224
4225 assert_eq!(parsed, finishability);
4226 }
4227
4228 #[test]
4230 fn environment_fingerprint_roundtrip(
4231 shipper_version in "[0-9]+\\.[0-9]+\\.[0-9]+",
4232 cargo_version in prop::option::of("[0-9]+\\.[0-9]+\\.[0-9]+"),
4233 rust_version in prop::option::of("[0-9]+\\.[0-9]+\\.[0-9]+"),
4234 os in "[a-z]+",
4235 arch in "[a-z0-9_]+",
4236 ) {
4237 let fingerprint = EnvironmentFingerprint {
4238 shipper_version: shipper_version.clone(),
4239 cargo_version: cargo_version.clone(),
4240 rust_version: rust_version.clone(),
4241 os: os.clone(),
4242 arch: arch.clone(),
4243 };
4244
4245 let json = serde_json::to_string(&fingerprint).unwrap();
4247 let parsed: EnvironmentFingerprint = serde_json::from_str(&json).unwrap();
4248
4249 assert_eq!(parsed.shipper_version, fingerprint.shipper_version);
4251 assert_eq!(parsed.cargo_version, fingerprint.cargo_version);
4252 assert_eq!(parsed.rust_version, fingerprint.rust_version);
4253 assert_eq!(parsed.os, fingerprint.os);
4254 assert_eq!(parsed.arch, fingerprint.arch);
4255 }
4256
4257 #[test]
4259 fn git_context_roundtrip(
4260 commit in prop::option::of("[a-f0-9]+"),
4261 branch in prop::option::of("[a-z0-9-]+"),
4262 tag in prop::option::of("[a-z0-9-\\.]+"),
4263 dirty in prop::option::of(any::<bool>()),
4264 ) {
4265 let git_context = GitContext {
4266 commit: commit.clone(),
4267 branch: branch.clone(),
4268 tag: tag.clone(),
4269 dirty,
4270 };
4271
4272 let json = serde_json::to_string(&git_context).unwrap();
4274 let parsed: GitContext = serde_json::from_str(&json).unwrap();
4275
4276 assert_eq!(parsed.commit, git_context.commit);
4278 assert_eq!(parsed.branch, git_context.branch);
4279 assert_eq!(parsed.tag, git_context.tag);
4280 assert_eq!(parsed.dirty, git_context.dirty);
4281 }
4282
4283 #[test]
4285 fn registry_roundtrip(
4286 name in "[a-z0-9-]+",
4287 api_base in "https?://[a-z0-9.-]+",
4288 index_base in prop::option::of("https?://[a-z0-9.-]+"),
4289 ) {
4290 let registry = Registry {
4291 name: name.clone(),
4292 api_base: api_base.clone(),
4293 index_base: index_base.clone(),
4294 };
4295
4296 let json = serde_json::to_string(®istry).unwrap();
4298 let parsed: Registry = serde_json::from_str(&json).unwrap();
4299
4300 assert_eq!(parsed.name, registry.name);
4302 assert_eq!(parsed.api_base, registry.api_base);
4303 assert_eq!(parsed.index_base, registry.index_base);
4304 }
4305
4306 #[test]
4308 fn readiness_config_roundtrip(
4309 enabled in any::<bool>(),
4310 method_variant in 0u8..3,
4311 initial_delay_ms in 0u64..10000,
4312 max_delay_ms in 0u64..100000,
4313 max_total_wait_ms in 0u64..1000000,
4314 poll_interval_ms in 0u64..10000,
4315 jitter_factor in 0.0f64..1.0,
4316 prefer_index in any::<bool>(),
4317 ) {
4318 let method = match method_variant {
4319 0 => ReadinessMethod::Api,
4320 1 => ReadinessMethod::Index,
4321 _ => ReadinessMethod::Both,
4322 };
4323
4324 let config = ReadinessConfig {
4325 enabled,
4326 method,
4327 initial_delay: Duration::from_millis(initial_delay_ms),
4328 max_delay: Duration::from_millis(max_delay_ms),
4329 max_total_wait: Duration::from_millis(max_total_wait_ms),
4330 poll_interval: Duration::from_millis(poll_interval_ms),
4331 jitter_factor,
4332 index_path: None,
4333 prefer_index,
4334 };
4335
4336 let json = serde_json::to_string(&config).unwrap();
4338 let parsed: ReadinessConfig = serde_json::from_str(&json).unwrap();
4339
4340 assert_eq!(parsed.enabled, config.enabled);
4342 assert_eq!(parsed.method, config.method);
4343 assert_eq!(parsed.initial_delay, config.initial_delay);
4344 assert_eq!(parsed.max_delay, config.max_delay);
4345 assert_eq!(parsed.max_total_wait, config.max_total_wait);
4346 assert_eq!(parsed.poll_interval, config.poll_interval);
4347 assert!((parsed.jitter_factor - config.jitter_factor).abs() < 1e-10,
4348 "jitter_factor mismatch: {} vs {}", parsed.jitter_factor, config.jitter_factor);
4349 assert_eq!(parsed.prefer_index, config.prefer_index);
4350 }
4351
4352 #[test]
4354 fn index_path_deterministic(crate_name in "[a-z0-9-]+") {
4355 let first = calculate_index_path_for_crate(&crate_name);
4357 let second = calculate_index_path_for_crate(&crate_name);
4358 assert_eq!(first, second, "Index path calculation should be deterministic");
4359 }
4360
4361 #[test]
4363 fn index_path_follows_pattern(crate_name in "[a-z0-9-]{3,20}") {
4364 let path = calculate_index_path_for_crate(&crate_name);
4365 let lower = crate_name.to_lowercase();
4366 let parts: Vec<&str> = path.split('/').collect();
4367
4368 match lower.len() {
4369 3 => {
4370 assert_eq!(parts.len(), 3, "3-char crate should have 3 parts");
4371 assert_eq!(parts[0], "3");
4372 assert_eq!(parts[1], &lower[..1]);
4373 assert_eq!(parts[2], lower);
4374 }
4375 n if n >= 4 => {
4376 assert_eq!(parts.len(), 3, "4+ char crate should have 3 parts");
4377 assert_eq!(parts[0], &lower[..2]);
4378 assert_eq!(parts[1], &lower[2..4]);
4379 assert_eq!(parts[2], lower);
4380 }
4381 _ => unreachable!("regex guarantees at least 3 chars"),
4382 }
4383 }
4384
4385 #[test]
4387 fn schema_version_parsing_deterministic(
4388 middle in "[a-z]+",
4389 version_num in 1u32..1000,
4390 ) {
4391 let version_str = format!("shipper.{}.v{}", middle, version_num);
4392
4393 let first = parse_schema_version_for_test(&version_str);
4394 let second = parse_schema_version_for_test(&version_str);
4395
4396 assert_eq!(first, second, "Schema version parsing should be deterministic");
4397 assert_eq!(first, Ok(version_num));
4398 }
4399 }
4400
4401 proptest! {
4403 #[test]
4404 fn package_state_pending_roundtrip(_dummy in 0u8..1) {
4405 let state = PackageState::Pending;
4406 let json = serde_json::to_string(&state).unwrap();
4407 let parsed: PackageState = serde_json::from_str(&json).unwrap();
4408 assert_eq!(parsed, state);
4409 }
4410
4411 #[test]
4412 fn package_state_uploaded_roundtrip(_dummy in 0u8..1) {
4413 let state = PackageState::Uploaded;
4414 let json = serde_json::to_string(&state).unwrap();
4415 let parsed: PackageState = serde_json::from_str(&json).unwrap();
4416 assert_eq!(parsed, state);
4417 }
4418
4419 #[test]
4420 fn package_state_published_roundtrip(_dummy in 0u8..1) {
4421 let state = PackageState::Published;
4422 let json = serde_json::to_string(&state).unwrap();
4423 let parsed: PackageState = serde_json::from_str(&json).unwrap();
4424 assert_eq!(parsed, state);
4425 }
4426
4427 #[test]
4428 fn package_state_skipped_roundtrip(reason in "\\PC{0,50}") {
4429 let state = PackageState::Skipped { reason };
4430 let json = serde_json::to_string(&state).unwrap();
4431 let parsed: PackageState = serde_json::from_str(&json).unwrap();
4432 assert_eq!(parsed, state);
4433 }
4434
4435 #[test]
4436 fn package_state_failed_roundtrip(
4437 class_variant in 0u8..3,
4438 message in "\\PC{0,80}",
4439 ) {
4440 let class = match class_variant {
4441 0 => ErrorClass::Retryable,
4442 1 => ErrorClass::Permanent,
4443 _ => ErrorClass::Ambiguous,
4444 };
4445 let state = PackageState::Failed { class, message };
4446 let json = serde_json::to_string(&state).unwrap();
4447 let parsed: PackageState = serde_json::from_str(&json).unwrap();
4448 assert_eq!(parsed, state);
4449 }
4450
4451 #[test]
4452 fn package_state_ambiguous_roundtrip(message in "\\PC{0,80}") {
4453 let state = PackageState::Ambiguous { message };
4454 let json = serde_json::to_string(&state).unwrap();
4455 let parsed: PackageState = serde_json::from_str(&json).unwrap();
4456 assert_eq!(parsed, state);
4457 }
4458
4459 #[test]
4461 fn error_class_roundtrip(variant in 0u8..3) {
4462 let class = match variant {
4463 0 => ErrorClass::Retryable,
4464 1 => ErrorClass::Permanent,
4465 _ => ErrorClass::Ambiguous,
4466 };
4467 let json = serde_json::to_string(&class).unwrap();
4468 let parsed: ErrorClass = serde_json::from_str(&json).unwrap();
4469 assert_eq!(parsed, class);
4470 }
4471
4472 #[test]
4474 fn execution_result_roundtrip(variant in 0u8..3) {
4475 let result = match variant {
4476 0 => ExecutionResult::Success,
4477 1 => ExecutionResult::PartialFailure,
4478 _ => ExecutionResult::CompleteFailure,
4479 };
4480 let json = serde_json::to_string(&result).unwrap();
4481 let parsed: ExecutionResult = serde_json::from_str(&json).unwrap();
4482 assert_eq!(parsed, result);
4483 }
4484
4485 #[test]
4487 fn publish_policy_roundtrip(variant in 0u8..3) {
4488 let policy = match variant {
4489 0 => PublishPolicy::Safe,
4490 1 => PublishPolicy::Balanced,
4491 _ => PublishPolicy::Fast,
4492 };
4493 let json = serde_json::to_string(&policy).unwrap();
4494 let parsed: PublishPolicy = serde_json::from_str(&json).unwrap();
4495 assert_eq!(parsed, policy);
4496 }
4497
4498 #[test]
4500 fn verify_mode_roundtrip(variant in 0u8..3) {
4501 let mode = match variant {
4502 0 => VerifyMode::Workspace,
4503 1 => VerifyMode::Package,
4504 _ => VerifyMode::None,
4505 };
4506 let json = serde_json::to_string(&mode).unwrap();
4507 let parsed: VerifyMode = serde_json::from_str(&json).unwrap();
4508 assert_eq!(parsed, mode);
4509 }
4510
4511 #[test]
4513 fn readiness_method_roundtrip(variant in 0u8..3) {
4514 let method = match variant {
4515 0 => ReadinessMethod::Api,
4516 1 => ReadinessMethod::Index,
4517 _ => ReadinessMethod::Both,
4518 };
4519 let json = serde_json::to_string(&method).unwrap();
4520 let parsed: ReadinessMethod = serde_json::from_str(&json).unwrap();
4521 assert_eq!(parsed, method);
4522 }
4523
4524 #[test]
4526 fn planned_package_roundtrip(
4527 name in "[a-z][a-z0-9-]{0,20}",
4528 version in "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}",
4529 ) {
4530 let pkg = PlannedPackage {
4531 name,
4532 version,
4533 manifest_path: PathBuf::from("crates/test/Cargo.toml"),
4534 regime: None,
4535 };
4536 let json = serde_json::to_string(&pkg).unwrap();
4537 let parsed: PlannedPackage = serde_json::from_str(&json).unwrap();
4538 assert_eq!(parsed.name, pkg.name);
4539 assert_eq!(parsed.version, pkg.version);
4540 assert_eq!(parsed.manifest_path, pkg.manifest_path);
4541 }
4542
4543 #[test]
4545 fn publish_level_roundtrip(
4546 level in 0usize..10,
4547 pkg_count in 1usize..5,
4548 ) {
4549 let packages: Vec<PlannedPackage> = (0..pkg_count)
4550 .map(|i| PlannedPackage {
4551 name: format!("crate-{i}"),
4552 version: format!("{i}.0.0"),
4553 manifest_path: PathBuf::from(format!("crates/crate-{i}/Cargo.toml")),
4554 regime: None,
4555 })
4556 .collect();
4557 let lvl = PublishLevel { level, packages };
4558 let json = serde_json::to_string(&lvl).unwrap();
4559 let parsed: PublishLevel = serde_json::from_str(&json).unwrap();
4560 assert_eq!(parsed.level, lvl.level);
4561 assert_eq!(parsed.packages.len(), lvl.packages.len());
4562 }
4563
4564 #[test]
4566 fn release_plan_roundtrip(
4567 plan_id in "[a-f0-9]{8,64}",
4568 pkg_count in 1usize..5,
4569 ) {
4570 let packages: Vec<PlannedPackage> = (0..pkg_count)
4571 .map(|i| PlannedPackage {
4572 name: format!("crate-{i}"),
4573 version: format!("{i}.0.0"),
4574 manifest_path: PathBuf::from(format!("crates/crate-{i}/Cargo.toml")),
4575 regime: None,
4576 })
4577 .collect();
4578 let mut deps = BTreeMap::new();
4579 if pkg_count > 1 {
4580 deps.insert(
4581 "crate-1".to_string(),
4582 vec!["crate-0".to_string()],
4583 );
4584 }
4585 let plan = ReleasePlan {
4586 plan_version: "shipper.plan.v1".to_string(),
4587 plan_id,
4588 created_at: Utc::now(),
4589 registry: Registry::crates_io(),
4590 packages,
4591 dependencies: deps,
4592 };
4593 let json = serde_json::to_string(&plan).unwrap();
4594 let parsed: ReleasePlan = serde_json::from_str(&json).unwrap();
4595 assert_eq!(parsed.plan_id, plan.plan_id);
4596 assert_eq!(parsed.plan_version, plan.plan_version);
4597 assert_eq!(parsed.packages.len(), plan.packages.len());
4598 assert_eq!(parsed.dependencies, plan.dependencies);
4599 }
4600
4601 #[test]
4603 fn package_progress_roundtrip(
4604 name in "[a-z][a-z0-9-]{0,15}",
4605 version in "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}",
4606 attempts in 0u32..10,
4607 state_variant in 0u8..4,
4608 ) {
4609 let state = match state_variant {
4610 0 => PackageState::Pending,
4611 1 => PackageState::Uploaded,
4612 2 => PackageState::Published,
4613 _ => PackageState::Skipped { reason: "already exists".to_string() },
4614 };
4615 let progress = PackageProgress {
4616 name,
4617 version,
4618 attempts,
4619 state,
4620 last_updated_at: Utc::now(),
4621 };
4622 let json = serde_json::to_string(&progress).unwrap();
4623 let parsed: PackageProgress = serde_json::from_str(&json).unwrap();
4624 assert_eq!(parsed.name, progress.name);
4625 assert_eq!(parsed.version, progress.version);
4626 assert_eq!(parsed.attempts, progress.attempts);
4627 assert_eq!(parsed.state, progress.state);
4628 }
4629
4630 #[test]
4632 fn execution_state_roundtrip(
4633 plan_id in "[a-f0-9]{8,64}",
4634 pkg_count in 0usize..5,
4635 ) {
4636 let mut packages = BTreeMap::new();
4637 for i in 0..pkg_count {
4638 let key = format!("crate-{i}@{i}.0.0");
4639 packages.insert(key, PackageProgress {
4640 name: format!("crate-{i}"),
4641 version: format!("{i}.0.0"),
4642 attempts: i as u32,
4643 state: PackageState::Pending,
4644 last_updated_at: Utc::now(),
4645 });
4646 }
4647 let state = ExecutionState {
4648 state_version: "shipper.state.v1".to_string(),
4649 plan_id,
4650 registry: Registry::crates_io(),
4651 created_at: Utc::now(),
4652 updated_at: Utc::now(),
4653 attempt_history: Vec::new(),
4654 packages,
4655 };
4656 let json = serde_json::to_string(&state).unwrap();
4657 let parsed: ExecutionState = serde_json::from_str(&json).unwrap();
4658 assert_eq!(parsed.plan_id, state.plan_id);
4659 assert_eq!(parsed.packages.len(), state.packages.len());
4660 }
4661
4662 #[test]
4664 fn parallel_config_roundtrip(
4665 enabled in any::<bool>(),
4666 max_concurrent in 1usize..32,
4667 timeout_secs in 1u64..7200,
4668 ) {
4669 let config = ParallelConfig {
4670 enabled,
4671 max_concurrent,
4672 per_package_timeout: Duration::from_secs(timeout_secs),
4673 };
4674 let json = serde_json::to_string(&config).unwrap();
4675 let parsed: ParallelConfig = serde_json::from_str(&json).unwrap();
4676 assert_eq!(parsed.enabled, config.enabled);
4677 assert_eq!(parsed.max_concurrent, config.max_concurrent);
4678 assert_eq!(parsed.per_package_timeout, config.per_package_timeout);
4679 }
4680
4681 #[test]
4683 fn attempt_evidence_roundtrip(
4684 attempt_number in 1u32..10,
4685 exit_code in -1i32..256,
4686 duration_ms in 0u64..600_000,
4687 ) {
4688 let evidence = AttemptEvidence {
4689 attempt_number,
4690 command: "cargo publish -p test".to_string(),
4691 exit_code,
4692 stdout_tail: "Uploading test v1.0.0".to_string(),
4693 stderr_tail: String::new(),
4694 timestamp: Utc::now(),
4695 duration: Duration::from_millis(duration_ms),
4696 };
4697 let json = serde_json::to_string(&evidence).unwrap();
4698 let parsed: AttemptEvidence = serde_json::from_str(&json).unwrap();
4699 assert_eq!(parsed.attempt_number, evidence.attempt_number);
4700 assert_eq!(parsed.exit_code, evidence.exit_code);
4701 assert_eq!(parsed.duration, evidence.duration);
4702 }
4703
4704 #[test]
4706 fn readiness_evidence_roundtrip(
4707 attempt in 1u32..20,
4708 visible in any::<bool>(),
4709 delay_ms in 0u64..120_000,
4710 ) {
4711 let evidence = ReadinessEvidence {
4712 attempt,
4713 visible,
4714 timestamp: Utc::now(),
4715 delay_before: Duration::from_millis(delay_ms),
4716 };
4717 let json = serde_json::to_string(&evidence).unwrap();
4718 let parsed: ReadinessEvidence = serde_json::from_str(&json).unwrap();
4719 assert_eq!(parsed.attempt, evidence.attempt);
4720 assert_eq!(parsed.visible, evidence.visible);
4721 assert_eq!(parsed.delay_before, evidence.delay_before);
4722 }
4723
4724 #[test]
4726 fn package_evidence_roundtrip(attempt_count in 0usize..4) {
4727 let attempts: Vec<AttemptEvidence> = (0..attempt_count)
4728 .map(|i| AttemptEvidence {
4729 attempt_number: i as u32 + 1,
4730 command: format!("cargo publish attempt {i}"),
4731 exit_code: if i == attempt_count - 1 { 0 } else { 1 },
4732 stdout_tail: "output".to_string(),
4733 stderr_tail: String::new(),
4734 timestamp: Utc::now(),
4735 duration: Duration::from_secs(5),
4736 })
4737 .collect();
4738 let evidence = PackageEvidence {
4739 attempts,
4740 readiness_checks: vec![],
4741 };
4742 let json = serde_json::to_string(&evidence).unwrap();
4743 let parsed: PackageEvidence = serde_json::from_str(&json).unwrap();
4744 assert_eq!(parsed.attempts.len(), evidence.attempts.len());
4745 }
4746
4747 #[test]
4749 fn package_receipt_roundtrip(
4750 name in "[a-z][a-z0-9-]{0,15}",
4751 version in "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}",
4752 attempts in 1u32..5,
4753 duration_ms in 0u128..600_000,
4754 ) {
4755 let now = Utc::now();
4756 let receipt = PackageReceipt {
4757 name,
4758 version,
4759 attempts,
4760 state: PackageState::Published,
4761 started_at: now,
4762 finished_at: now,
4763 duration_ms,
4764 evidence: PackageEvidence {
4765 attempts: vec![],
4766 readiness_checks: vec![],
4767 },
4768 compromised_at: None,
4769 compromised_by: None,
4770 superseded_by: None,
4771 };
4772 let json = serde_json::to_string(&receipt).unwrap();
4773 let parsed: PackageReceipt = serde_json::from_str(&json).unwrap();
4774 assert_eq!(parsed.name, receipt.name);
4775 assert_eq!(parsed.version, receipt.version);
4776 assert_eq!(parsed.attempts, receipt.attempts);
4777 assert_eq!(parsed.state, receipt.state);
4778 assert_eq!(parsed.duration_ms, receipt.duration_ms);
4779 }
4780
4781 #[test]
4783 fn receipt_roundtrip(
4784 plan_id in "[a-f0-9]{8,64}",
4785 pkg_count in 0usize..3,
4786 ) {
4787 let now = Utc::now();
4788 let packages: Vec<PackageReceipt> = (0..pkg_count)
4789 .map(|i| PackageReceipt {
4790 name: format!("crate-{i}"),
4791 version: format!("{i}.0.0"),
4792 attempts: 1,
4793 state: PackageState::Published,
4794 started_at: now,
4795 finished_at: now,
4796 duration_ms: 1000,
4797 evidence: PackageEvidence {
4798 attempts: vec![],
4799 readiness_checks: vec![],
4800 },
4801 compromised_at: None,
4802 compromised_by: None,
4803 superseded_by: None,
4804 })
4805 .collect();
4806 let receipt = Receipt {
4807 receipt_version: "shipper.receipt.v1".to_string(),
4808 plan_id,
4809 registry: Registry::crates_io(),
4810 started_at: now,
4811 finished_at: now,
4812 packages,
4813 event_log_path: PathBuf::from(".shipper/events.jsonl"),
4814 git_context: Some(GitContext {
4815 commit: Some("abc123".to_string()),
4816 branch: Some("main".to_string()),
4817 tag: None,
4818 dirty: Some(false),
4819 }),
4820 environment: EnvironmentFingerprint {
4821 shipper_version: "0.3.0".to_string(),
4822 cargo_version: Some("1.80.0".to_string()),
4823 rust_version: Some("1.80.0".to_string()),
4824 os: "linux".to_string(),
4825 arch: "x86_64".to_string(),
4826 },
4827 auth_evidence: None,
4828 };
4829 let json = serde_json::to_string(&receipt).unwrap();
4830 let parsed: Receipt = serde_json::from_str(&json).unwrap();
4831 assert_eq!(parsed.plan_id, receipt.plan_id);
4832 assert_eq!(parsed.packages.len(), receipt.packages.len());
4833 assert_eq!(parsed.receipt_version, receipt.receipt_version);
4834 assert!(parsed.git_context.is_some());
4835 }
4836
4837 #[test]
4839 fn publish_event_roundtrip(variant in 0u8..5) {
4840 let event_type = match variant {
4841 0 => EventType::ExecutionStarted,
4842 1 => EventType::PlanCreated {
4843 plan_id: "abc".to_string(),
4844 package_count: 3,
4845 },
4846 2 => EventType::PackageStarted {
4847 name: "test".to_string(),
4848 version: "1.0.0".to_string(),
4849 },
4850 3 => EventType::PackageFailed {
4851 class: ErrorClass::Retryable,
4852 message: "timeout".to_string(),
4853 },
4854 _ => EventType::ExecutionFinished {
4855 result: ExecutionResult::Success,
4856 },
4857 };
4858 let event = PublishEvent {
4859 timestamp: Utc::now(),
4860 event_type,
4861 package: "test@1.0.0".to_string(),
4862 };
4863 let json = serde_json::to_string(&event).unwrap();
4864 let parsed: PublishEvent = serde_json::from_str(&json).unwrap();
4865 assert_eq!(parsed.package, event.package);
4866 }
4867
4868 #[test]
4870 fn event_type_all_variants_roundtrip(variant in 0u8..22) {
4871 let event_type = match variant {
4872 0 => EventType::PlanCreated { plan_id: "id1".to_string(), package_count: 5 },
4873 1 => EventType::ExecutionStarted,
4874 2 => EventType::ExecutionFinished { result: ExecutionResult::Success },
4875 3 => EventType::PackageStarted { name: "a".to_string(), version: "1.0.0".to_string() },
4876 4 => EventType::PackageAttempted { attempt: 1, command: "cargo publish".to_string() },
4877 5 => EventType::PackageOutput { stdout_tail: "ok".to_string(), stderr_tail: "".to_string() },
4878 6 => EventType::PackagePublished { duration_ms: 100 },
4879 7 => EventType::PackageFailed { class: ErrorClass::Retryable, message: "err".to_string() },
4880 8 => EventType::PackageSkipped { reason: "exists".to_string() },
4881 9 => EventType::PublishWaiting { reason: "retry backoff".to_string(), delay_ms: 1000, until: Utc::now() },
4882 10 => EventType::RateLimitObserved { is_new_crate: true, retry_after_ms: Some(30_000), message: "rate limited".to_string() },
4883 11 => EventType::ReadinessStarted { method: ReadinessMethod::Api },
4884 12 => EventType::ReadinessPoll { attempt: 1, visible: false },
4885 13 => EventType::ReadinessPollScheduled { attempt: 2, delay_ms: 1000, next_poll_at: Utc::now() },
4886 14 => EventType::ReadinessComplete { duration_ms: 500, attempts: 3 },
4887 15 => EventType::ReadinessTimeout { max_wait_ms: 60000 },
4888 16 => EventType::IndexReadinessStarted { crate_name: "a".to_string(), version: "1.0.0".to_string() },
4889 17 => EventType::IndexReadinessCheck { crate_name: "a".to_string(), version: "1.0.0".to_string(), found: true },
4890 18 => EventType::IndexReadinessComplete { crate_name: "a".to_string(), version: "1.0.0".to_string(), visible: true },
4891 19 => EventType::RetryScheduled { attempt: 1, max_attempts: 3, delay_ms: 1000, next_attempt_at: Utc::now(), reason: ErrorClass::Retryable, message: "retry".to_string() },
4892 20 => EventType::PreflightStarted,
4893 _ => EventType::PreflightComplete { finishability: Finishability::Proven },
4894 };
4895 let json = serde_json::to_string(&event_type).unwrap();
4896 let _parsed: EventType = serde_json::from_str(&json).unwrap();
4897 }
4898 }
4899
4900 fn valid_next_states(state: &PackageState) -> Vec<PackageState> {
4904 match state {
4905 PackageState::Pending => vec![
4906 PackageState::Uploaded,
4907 PackageState::Failed {
4908 class: ErrorClass::Retryable,
4909 message: "err".to_string(),
4910 },
4911 PackageState::Skipped {
4912 reason: "already published".to_string(),
4913 },
4914 ],
4915 PackageState::Uploaded => vec![
4916 PackageState::Published,
4917 PackageState::Failed {
4918 class: ErrorClass::Retryable,
4919 message: "readiness timeout".to_string(),
4920 },
4921 PackageState::Ambiguous {
4922 message: "unclear".to_string(),
4923 },
4924 ],
4925 PackageState::Failed { .. } => vec![
4926 PackageState::Pending, ],
4928 PackageState::Published => vec![],
4930 PackageState::Skipped { .. } => vec![],
4931 PackageState::Ambiguous { .. } => vec![],
4932 }
4933 }
4934
4935 fn is_terminal(state: &PackageState) -> bool {
4936 matches!(
4937 state,
4938 PackageState::Published
4939 | PackageState::Skipped { .. }
4940 | PackageState::Ambiguous { .. }
4941 )
4942 }
4943
4944 proptest! {
4945 #[test]
4946 fn package_state_transitions_are_valid(
4947 start_variant in 0u8..6,
4948 ) {
4949 let start = match start_variant {
4950 0 => PackageState::Pending,
4951 1 => PackageState::Uploaded,
4952 2 => PackageState::Published,
4953 3 => PackageState::Skipped { reason: "exists".to_string() },
4954 4 => PackageState::Failed { class: ErrorClass::Retryable, message: "err".to_string() },
4955 _ => PackageState::Ambiguous { message: "unclear".to_string() },
4956 };
4957
4958 let nexts = valid_next_states(&start);
4959 if is_terminal(&start) {
4960 assert!(nexts.is_empty(), "Terminal state {:?} should have no valid transitions", start);
4961 } else {
4962 assert!(!nexts.is_empty(), "Non-terminal state {:?} should have valid transitions", start);
4963 }
4964 }
4965
4966 #[test]
4968 fn failed_state_can_retry(
4969 class_variant in 0u8..3,
4970 message in "[a-z ]{1,30}",
4971 ) {
4972 let class = match class_variant {
4973 0 => ErrorClass::Retryable,
4974 1 => ErrorClass::Permanent,
4975 _ => ErrorClass::Ambiguous,
4976 };
4977 let failed = PackageState::Failed { class, message };
4978 let nexts = valid_next_states(&failed);
4979 assert!(nexts.contains(&PackageState::Pending),
4980 "Failed state should allow retry to Pending");
4981 }
4982
4983 #[test]
4985 fn pending_has_expected_transitions(_dummy in 0u8..1) {
4986 let nexts = valid_next_states(&PackageState::Pending);
4987 assert_eq!(nexts.len(), 3);
4988 assert!(matches!(nexts[0], PackageState::Uploaded));
4989 assert!(matches!(nexts[1], PackageState::Failed { .. }));
4990 assert!(matches!(nexts[2], PackageState::Skipped { .. }));
4991 }
4992 }
4993
4994 proptest! {
4997 #[test]
4999 fn plan_id_deterministic_for_same_inputs(
5000 pkg_count in 1usize..6,
5001 seed in 0u64..1000,
5002 ) {
5003 use std::collections::hash_map::DefaultHasher;
5004 use std::hash::{Hash, Hasher};
5005
5006 fn compute_plan_id(packages: &[PlannedPackage], registry_name: &str) -> String {
5008 let mut hasher = DefaultHasher::new();
5009 registry_name.hash(&mut hasher);
5010 for pkg in packages {
5011 pkg.name.hash(&mut hasher);
5012 pkg.version.hash(&mut hasher);
5013 }
5014 format!("{:016x}", hasher.finish())
5015 }
5016
5017 let packages: Vec<PlannedPackage> = (0..pkg_count)
5018 .map(|i| PlannedPackage {
5019 name: format!("crate-{}-{}", seed, i),
5020 version: format!("{}.0.0", i),
5021 manifest_path: PathBuf::from(format!("crates/crate-{i}/Cargo.toml")),
5022 regime: None,
5023 })
5024 .collect();
5025
5026 let id1 = compute_plan_id(&packages, "crates-io");
5027 let id2 = compute_plan_id(&packages, "crates-io");
5028 assert_eq!(id1, id2, "Same inputs must produce the same plan_id");
5029 }
5030
5031 #[test]
5033 fn plan_id_differs_for_different_inputs(
5034 seed in 0u64..1000,
5035 ) {
5036 use std::collections::hash_map::DefaultHasher;
5037 use std::hash::{Hash, Hasher};
5038
5039 fn compute_plan_id(packages: &[PlannedPackage], registry_name: &str) -> String {
5040 let mut hasher = DefaultHasher::new();
5041 registry_name.hash(&mut hasher);
5042 for pkg in packages {
5043 pkg.name.hash(&mut hasher);
5044 pkg.version.hash(&mut hasher);
5045 }
5046 format!("{:016x}", hasher.finish())
5047 }
5048
5049 let pkgs_a = vec![PlannedPackage {
5050 name: format!("crate-a-{seed}"),
5051 version: "1.0.0".to_string(),
5052 manifest_path: PathBuf::from("Cargo.toml"),
5053 regime: None,
5054 }];
5055 let pkgs_b = vec![PlannedPackage {
5056 name: format!("crate-b-{seed}"),
5057 version: "1.0.0".to_string(),
5058 manifest_path: PathBuf::from("Cargo.toml"),
5059 regime: None,
5060 }];
5061
5062 let id_a = compute_plan_id(&pkgs_a, "crates-io");
5063 let id_b = compute_plan_id(&pkgs_b, "crates-io");
5064 assert_ne!(id_a, id_b, "Different inputs must produce different plan_ids");
5065 }
5066 }
5067
5068 fn build_receipt_from_state(state: &ExecutionState) -> Receipt {
5071 let now = Utc::now();
5072 let packages: Vec<PackageReceipt> = state
5073 .packages
5074 .values()
5075 .map(|progress| PackageReceipt {
5076 name: progress.name.clone(),
5077 version: progress.version.clone(),
5078 attempts: progress.attempts,
5079 state: progress.state.clone(),
5080 started_at: state.created_at,
5081 finished_at: now,
5082 duration_ms: 0,
5083 evidence: PackageEvidence {
5084 attempts: vec![],
5085 readiness_checks: vec![],
5086 },
5087 compromised_at: None,
5088 compromised_by: None,
5089 superseded_by: None,
5090 })
5091 .collect();
5092
5093 Receipt {
5094 receipt_version: "shipper.receipt.v1".to_string(),
5095 plan_id: state.plan_id.clone(),
5096 registry: state.registry.clone(),
5097 started_at: state.created_at,
5098 finished_at: now,
5099 packages,
5100 event_log_path: PathBuf::from(".shipper/events.jsonl"),
5101 git_context: None,
5102 environment: EnvironmentFingerprint {
5103 shipper_version: "0.3.0".to_string(),
5104 cargo_version: None,
5105 rust_version: None,
5106 os: "test".to_string(),
5107 arch: "test".to_string(),
5108 },
5109 auth_evidence: None,
5110 }
5111 }
5112
5113 proptest! {
5114 #[test]
5115 fn receipt_from_state_preserves_plan_id(
5116 plan_id in "[a-f0-9]{8,32}",
5117 pkg_count in 0usize..5,
5118 ) {
5119 let mut packages = BTreeMap::new();
5120 for i in 0..pkg_count {
5121 packages.insert(
5122 format!("pkg-{i}@{i}.0.0"),
5123 PackageProgress {
5124 name: format!("pkg-{i}"),
5125 version: format!("{i}.0.0"),
5126 attempts: 1,
5127 state: PackageState::Published,
5128 last_updated_at: Utc::now(),
5129 },
5130 );
5131 }
5132 let state = ExecutionState {
5133 state_version: "shipper.state.v1".to_string(),
5134 plan_id: plan_id.clone(),
5135 registry: Registry::crates_io(),
5136 created_at: Utc::now(),
5137 updated_at: Utc::now(),
5138 attempt_history: Vec::new(),
5139 packages,
5140 };
5141
5142 let receipt = build_receipt_from_state(&state);
5143 assert_eq!(receipt.plan_id, plan_id);
5144 assert_eq!(receipt.packages.len(), pkg_count);
5145 for pkg_receipt in &receipt.packages {
5146 assert!(state.packages.values().any(|p| p.name == pkg_receipt.name));
5147 assert_eq!(pkg_receipt.state, PackageState::Published);
5148 }
5149 }
5150
5151 #[test]
5152 fn receipt_from_state_includes_all_packages(pkg_count in 1usize..8) {
5153 let mut packages = BTreeMap::new();
5154 for i in 0..pkg_count {
5155 let state_variant = match i % 3 {
5156 0 => PackageState::Published,
5157 1 => PackageState::Skipped { reason: "exists".to_string() },
5158 _ => PackageState::Failed {
5159 class: ErrorClass::Permanent,
5160 message: "auth failure".to_string(),
5161 },
5162 };
5163 packages.insert(
5164 format!("pkg-{i}@{i}.0.0"),
5165 PackageProgress {
5166 name: format!("pkg-{i}"),
5167 version: format!("{i}.0.0"),
5168 attempts: (i as u32) + 1,
5169 state: state_variant,
5170 last_updated_at: Utc::now(),
5171 },
5172 );
5173 }
5174 let state = ExecutionState {
5175 state_version: "shipper.state.v1".to_string(),
5176 plan_id: "test-plan".to_string(),
5177 registry: Registry::crates_io(),
5178 created_at: Utc::now(),
5179 updated_at: Utc::now(),
5180 attempt_history: Vec::new(),
5181 packages,
5182 };
5183
5184 let receipt = build_receipt_from_state(&state);
5185 assert_eq!(receipt.packages.len(), pkg_count);
5186 let json = serde_json::to_string(&receipt).unwrap();
5188 let parsed: Receipt = serde_json::from_str(&json).unwrap();
5189 assert_eq!(parsed.packages.len(), pkg_count);
5190 }
5191 }
5192
5193 fn parse_version(v: &str) -> Option<(u32, u32, u32, Option<String>)> {
5197 let (main, pre) = if let Some(idx) = v.find('-') {
5198 (&v[..idx], Some(v[idx + 1..].to_string()))
5199 } else {
5200 (v, None)
5201 };
5202 let parts: Vec<&str> = main.split('.').collect();
5203 if parts.len() != 3 {
5204 return None;
5205 }
5206 let major = parts[0].parse::<u32>().ok()?;
5207 let minor = parts[1].parse::<u32>().ok()?;
5208 let patch = parts[2].parse::<u32>().ok()?;
5209 Some((major, minor, patch, pre))
5210 }
5211
5212 fn format_version(major: u32, minor: u32, patch: u32, pre: Option<&str>) -> String {
5213 match pre {
5214 Some(p) => format!("{major}.{minor}.{patch}-{p}"),
5215 None => format!("{major}.{minor}.{patch}"),
5216 }
5217 }
5218
5219 proptest! {
5220 #[test]
5222 fn version_string_roundtrip(
5223 major in 0u32..100,
5224 minor in 0u32..100,
5225 patch in 0u32..100,
5226 ) {
5227 let version = format!("{major}.{minor}.{patch}");
5228 let (m, mi, p, pre) = parse_version(&version).unwrap();
5229 assert_eq!(m, major);
5230 assert_eq!(mi, minor);
5231 assert_eq!(p, patch);
5232 assert!(pre.is_none());
5233 let reconstructed = format_version(m, mi, p, pre.as_deref());
5234 assert_eq!(reconstructed, version);
5235 }
5236
5237 #[test]
5239 fn version_string_with_prerelease_roundtrip(
5240 major in 0u32..100,
5241 minor in 0u32..100,
5242 patch in 0u32..100,
5243 pre_tag in "[a-z]{1,5}\\.[0-9]{1,3}",
5244 ) {
5245 let version = format!("{major}.{minor}.{patch}-{pre_tag}");
5246 let (m, mi, p, pre) = parse_version(&version).unwrap();
5247 assert_eq!(m, major);
5248 assert_eq!(mi, minor);
5249 assert_eq!(p, patch);
5250 assert_eq!(pre.as_deref(), Some(pre_tag.as_str()));
5251 let reconstructed = format_version(m, mi, p, pre.as_deref());
5252 assert_eq!(reconstructed, version);
5253 }
5254
5255 #[test]
5257 fn version_in_planned_package_roundtrip(
5258 major in 0u32..100,
5259 minor in 0u32..100,
5260 patch in 0u32..100,
5261 ) {
5262 let version = format!("{major}.{minor}.{patch}");
5263 let pkg = PlannedPackage {
5264 name: "test-crate".to_string(),
5265 version: version.clone(),
5266 manifest_path: PathBuf::from("Cargo.toml"),
5267 regime: None,
5268 };
5269 let json = serde_json::to_string(&pkg).unwrap();
5270 let parsed: PlannedPackage = serde_json::from_str(&json).unwrap();
5271 let (m, mi, p, _) = parse_version(&parsed.version).unwrap();
5272 assert_eq!((m, mi, p), (major, minor, patch));
5273 }
5274 }
5275
5276 proptest! {
5279 #[test]
5280 fn release_plan_with_custom_registry_roundtrip(
5281 plan_id in "[a-f0-9]{8,64}",
5282 registry_name in "[a-z][a-z0-9-]{0,15}",
5283 api_base in "https://[a-z]{3,10}\\.[a-z]{2,5}",
5284 index_base in prop::option::of("https://index\\.[a-z]{3,10}\\.[a-z]{2,5}"),
5285 pkg_count in 1usize..6,
5286 dep_count in 0usize..3,
5287 ) {
5288 let packages: Vec<PlannedPackage> = (0..pkg_count)
5289 .map(|i| PlannedPackage {
5290 name: format!("crate-{i}"),
5291 version: format!("{}.0.0", i + 1),
5292 manifest_path: PathBuf::from(format!("crates/crate-{i}/Cargo.toml")),
5293 regime: None,
5294 })
5295 .collect();
5296 let mut deps = BTreeMap::new();
5297 for d in 0..dep_count.min(pkg_count.saturating_sub(1)) {
5298 deps.insert(
5299 format!("crate-{}", d + 1),
5300 vec![format!("crate-{d}")],
5301 );
5302 }
5303 let plan = ReleasePlan {
5304 plan_version: "shipper.plan.v1".to_string(),
5305 plan_id: plan_id.clone(),
5306 created_at: Utc::now(),
5307 registry: Registry {
5308 name: registry_name.clone(),
5309 api_base: api_base.clone(),
5310 index_base: index_base.clone(),
5311 },
5312 packages,
5313 dependencies: deps.clone(),
5314 };
5315 let json = serde_json::to_string(&plan).unwrap();
5316 let parsed: ReleasePlan = serde_json::from_str(&json).unwrap();
5317 assert_eq!(parsed.plan_id, plan_id);
5318 assert_eq!(parsed.registry.name, registry_name);
5319 assert_eq!(parsed.registry.api_base, api_base);
5320 assert_eq!(parsed.registry.index_base, index_base);
5321 assert_eq!(parsed.packages.len(), pkg_count);
5322 assert_eq!(parsed.dependencies, deps);
5323 }
5324 }
5325
5326 proptest! {
5329 #[test]
5330 fn runtime_options_durations_positive(
5331 base_delay_ms in 1u64..60_000,
5332 max_delay_ms in 1u64..600_000,
5333 verify_timeout_ms in 1u64..3_600_000,
5334 verify_poll_ms in 1u64..60_000,
5335 lock_timeout_ms in 1u64..86_400_000,
5336 pkg_timeout_ms in 1u64..7_200_000,
5337 readiness_initial_ms in 1u64..10_000,
5338 readiness_max_ms in 1u64..120_000,
5339 readiness_total_ms in 1u64..600_000,
5340 readiness_poll_ms in 1u64..10_000,
5341 ) {
5342 let opts = RuntimeOptions {
5343 allow_dirty: false,
5344 skip_ownership_check: false,
5345 strict_ownership: false,
5346 no_verify: false,
5347 max_attempts: 3,
5348 base_delay: Duration::from_millis(base_delay_ms),
5349 max_delay: Duration::from_millis(max_delay_ms),
5350 retry_strategy: shipper_retry::RetryStrategyType::Exponential,
5351 retry_jitter: 0.5,
5352 retry_per_error: shipper_retry::PerErrorConfig::default(),
5353 verify_timeout: Duration::from_millis(verify_timeout_ms),
5354 verify_poll_interval: Duration::from_millis(verify_poll_ms),
5355 state_dir: PathBuf::from(".shipper"),
5356 force_resume: false,
5357 policy: PublishPolicy::Safe,
5358 verify_mode: VerifyMode::Workspace,
5359 readiness: ReadinessConfig {
5360 enabled: true,
5361 method: ReadinessMethod::Api,
5362 initial_delay: Duration::from_millis(readiness_initial_ms),
5363 max_delay: Duration::from_millis(readiness_max_ms),
5364 max_total_wait: Duration::from_millis(readiness_total_ms),
5365 poll_interval: Duration::from_millis(readiness_poll_ms),
5366 jitter_factor: 0.5,
5367 index_path: None,
5368 prefer_index: false,
5369 },
5370 output_lines: 1000,
5371 force: false,
5372 lock_timeout: Duration::from_millis(lock_timeout_ms),
5373 parallel: ParallelConfig {
5374 enabled: false,
5375 max_concurrent: 4,
5376 per_package_timeout: Duration::from_millis(pkg_timeout_ms),
5377 },
5378 webhook: WebhookConfig::default(),
5379 encryption: EncryptionSettings::default(),
5380 registries: vec![],
5381 resume_from: None,
5382 rehearsal_registry: None,
5383 rehearsal_skip: false,
5384 rehearsal_smoke_install: None,
5385 };
5386
5387 assert!(opts.base_delay > Duration::ZERO);
5389 assert!(opts.max_delay > Duration::ZERO);
5390 assert!(opts.verify_timeout > Duration::ZERO);
5391 assert!(opts.verify_poll_interval > Duration::ZERO);
5392 assert!(opts.lock_timeout > Duration::ZERO);
5393 assert!(opts.parallel.per_package_timeout > Duration::ZERO);
5394 assert!(opts.readiness.initial_delay > Duration::ZERO);
5395 assert!(opts.readiness.max_delay > Duration::ZERO);
5396 assert!(opts.readiness.max_total_wait > Duration::ZERO);
5397 assert!(opts.readiness.poll_interval > Duration::ZERO);
5398 }
5399 }
5400
5401 proptest! {
5404 #[test]
5405 fn receipt_with_mixed_states_roundtrip(
5406 plan_id in "[a-f0-9]{8,32}",
5407 pkg_count in 1usize..6,
5408 git_commit in prop::option::of("[a-f0-9]{7,40}"),
5409 git_branch in prop::option::of("[a-z0-9/-]{1,20}"),
5410 shipper_ver in "[0-9]{1,2}\\.[0-9]{1,2}\\.[0-9]{1,2}",
5411 os_name in "[a-z]{3,10}",
5412 ) {
5413 let now = Utc::now();
5414 let packages: Vec<PackageReceipt> = (0..pkg_count)
5415 .map(|i| {
5416 let state = match i % 5 {
5417 0 => PackageState::Published,
5418 1 => PackageState::Skipped { reason: "already exists".to_string() },
5419 2 => PackageState::Failed {
5420 class: ErrorClass::Permanent,
5421 message: "auth error".to_string(),
5422 },
5423 3 => PackageState::Ambiguous { message: "timeout".to_string() },
5424 _ => PackageState::Uploaded,
5425 };
5426 PackageReceipt {
5427 name: format!("crate-{i}"),
5428 version: format!("{i}.1.0"),
5429 attempts: (i as u32) + 1,
5430 state,
5431 started_at: now,
5432 finished_at: now,
5433 duration_ms: (i as u128 + 1) * 500,
5434 evidence: PackageEvidence {
5435 attempts: vec![],
5436 readiness_checks: vec![],
5437 },
5438 compromised_at: None,
5439 compromised_by: None,
5440 superseded_by: None,
5441 }
5442 })
5443 .collect();
5444 let receipt = Receipt {
5445 receipt_version: "shipper.receipt.v1".to_string(),
5446 plan_id: plan_id.clone(),
5447 registry: Registry::crates_io(),
5448 started_at: now,
5449 finished_at: now,
5450 packages: packages.clone(),
5451 event_log_path: PathBuf::from(".shipper/events.jsonl"),
5452 git_context: Some(GitContext {
5453 commit: git_commit.clone(),
5454 branch: git_branch.clone(),
5455 tag: None,
5456 dirty: Some(false),
5457 }),
5458 environment: EnvironmentFingerprint {
5459 shipper_version: shipper_ver.clone(),
5460 cargo_version: None,
5461 rust_version: None,
5462 os: os_name.clone(),
5463 arch: "x86_64".to_string(),
5464 },
5465 auth_evidence: None,
5466 };
5467 let json = serde_json::to_string(&receipt).unwrap();
5468 let parsed: Receipt = serde_json::from_str(&json).unwrap();
5469 assert_eq!(parsed.plan_id, plan_id);
5470 assert_eq!(parsed.packages.len(), pkg_count);
5471 assert_eq!(parsed.environment.shipper_version, shipper_ver);
5472 assert_eq!(parsed.environment.os, os_name);
5473 let ctx = parsed.git_context.unwrap();
5474 assert_eq!(ctx.commit, git_commit);
5475 assert_eq!(ctx.branch, git_branch);
5476 for (orig, p) in packages.iter().zip(parsed.packages.iter()) {
5477 assert_eq!(p.name, orig.name);
5478 assert_eq!(p.state, orig.state);
5479 assert_eq!(p.duration_ms, orig.duration_ms);
5480 }
5481 }
5482 }
5483
5484 proptest! {
5487 #[test]
5488 fn execution_state_with_varied_states_roundtrip(
5489 plan_id in "[a-f0-9]{8,32}",
5490 pkg_count in 1usize..6,
5491 ) {
5492 let mut packages = BTreeMap::new();
5493 for i in 0..pkg_count {
5494 let state = match i % 5 {
5495 0 => PackageState::Pending,
5496 1 => PackageState::Uploaded,
5497 2 => PackageState::Published,
5498 3 => PackageState::Skipped { reason: "exists".to_string() },
5499 _ => PackageState::Failed {
5500 class: ErrorClass::Retryable,
5501 message: "timeout".to_string(),
5502 },
5503 };
5504 packages.insert(
5505 format!("crate-{i}@{i}.0.0"),
5506 PackageProgress {
5507 name: format!("crate-{i}"),
5508 version: format!("{i}.0.0"),
5509 attempts: (i as u32) + 1,
5510 state,
5511 last_updated_at: Utc::now(),
5512 },
5513 );
5514 }
5515 let exec_state = ExecutionState {
5516 state_version: "shipper.state.v1".to_string(),
5517 plan_id: plan_id.clone(),
5518 registry: Registry::crates_io(),
5519 created_at: Utc::now(),
5520 updated_at: Utc::now(),
5521 attempt_history: Vec::new(),
5522 packages: packages.clone(),
5523 };
5524 let json = serde_json::to_string(&exec_state).unwrap();
5525 let parsed: ExecutionState = serde_json::from_str(&json).unwrap();
5526 assert_eq!(parsed.plan_id, plan_id);
5527 assert_eq!(parsed.packages.len(), pkg_count);
5528 for (key, orig) in &packages {
5529 let p = parsed.packages.get(key).unwrap();
5530 assert_eq!(p.name, orig.name);
5531 assert_eq!(p.version, orig.version);
5532 assert_eq!(p.attempts, orig.attempts);
5533 assert_eq!(p.state, orig.state);
5534 }
5535 }
5536 }
5537
5538 fn state_ordinal(state: &PackageState) -> u8 {
5543 match state {
5544 PackageState::Pending => 0,
5545 PackageState::Uploaded => 1,
5546 PackageState::Published => 2,
5547 PackageState::Skipped { .. } => 2, PackageState::Failed { .. } => 1, PackageState::Ambiguous { .. } => 2, }
5551 }
5552
5553 proptest! {
5554 #[test]
5556 fn package_state_forward_transitions_monotonic(
5557 start_variant in 0u8..6,
5558 ) {
5559 let start = match start_variant {
5560 0 => PackageState::Pending,
5561 1 => PackageState::Uploaded,
5562 2 => PackageState::Published,
5563 3 => PackageState::Skipped { reason: "exists".to_string() },
5564 4 => PackageState::Failed {
5565 class: ErrorClass::Retryable,
5566 message: "err".to_string(),
5567 },
5568 _ => PackageState::Ambiguous { message: "unclear".to_string() },
5569 };
5570 let start_ord = state_ordinal(&start);
5571 let nexts = valid_next_states(&start);
5572 for next in &nexts {
5573 let is_retry = matches!(
5575 (&start, next),
5576 (PackageState::Failed { .. }, PackageState::Pending)
5577 );
5578 if !is_retry {
5579 assert!(
5580 state_ordinal(next) >= start_ord,
5581 "Non-retry transition {:?} -> {:?} must not decrease ordinal ({} -> {})",
5582 start, next, start_ord, state_ordinal(next)
5583 );
5584 }
5585 }
5586 }
5587
5588 #[test]
5590 fn happy_path_is_strictly_monotonic(_dummy in 0u8..1) {
5591 let path = [
5592 PackageState::Pending,
5593 PackageState::Uploaded,
5594 PackageState::Published,
5595 ];
5596 for w in path.windows(2) {
5597 assert!(
5598 state_ordinal(&w[1]) > state_ordinal(&w[0]),
5599 "Happy path must be strictly increasing: {:?} -> {:?}",
5600 w[0], w[1]
5601 );
5602 }
5603 }
5604
5605 #[test]
5607 fn terminal_states_have_no_transitions(variant in 0u8..3) {
5608 let state = match variant {
5609 0 => PackageState::Published,
5610 1 => PackageState::Skipped { reason: "exists".to_string() },
5611 _ => PackageState::Ambiguous { message: "unclear".to_string() },
5612 };
5613 let nexts = valid_next_states(&state);
5614 assert!(
5615 nexts.is_empty(),
5616 "Terminal state {:?} must have no transitions but has {:?}",
5617 state, nexts
5618 );
5619 }
5620 }
5621
5622 proptest! {
5625 #[test]
5626 fn package_state_debug_never_panics(
5627 variant in 0u8..6,
5628 message in "\\PC{0,200}",
5629 ) {
5630 let state = match variant {
5631 0 => PackageState::Pending,
5632 1 => PackageState::Uploaded,
5633 2 => PackageState::Published,
5634 3 => PackageState::Skipped { reason: message.clone() },
5635 4 => PackageState::Failed {
5636 class: ErrorClass::Retryable,
5637 message: message.clone(),
5638 },
5639 _ => PackageState::Ambiguous { message },
5640 };
5641 let debug = format!("{:?}", state);
5642 assert!(!debug.is_empty());
5643 }
5644
5645 #[test]
5646 fn error_class_debug_never_panics(variant in 0u8..3) {
5647 let class = match variant {
5648 0 => ErrorClass::Retryable,
5649 1 => ErrorClass::Permanent,
5650 _ => ErrorClass::Ambiguous,
5651 };
5652 let debug = format!("{:?}", class);
5653 assert!(!debug.is_empty());
5654 }
5655
5656 #[test]
5657 fn execution_result_debug_never_panics(variant in 0u8..3) {
5658 let result = match variant {
5659 0 => ExecutionResult::Success,
5660 1 => ExecutionResult::PartialFailure,
5661 _ => ExecutionResult::CompleteFailure,
5662 };
5663 let debug = format!("{:?}", result);
5664 assert!(!debug.is_empty());
5665 }
5666
5667 #[test]
5668 fn finishability_debug_never_panics(variant in 0u8..3) {
5669 let fin = match variant {
5670 0 => Finishability::Proven,
5671 1 => Finishability::NotProven,
5672 _ => Finishability::Failed,
5673 };
5674 let debug = format!("{:?}", fin);
5675 assert!(!debug.is_empty());
5676 }
5677
5678 #[test]
5679 fn event_type_debug_never_panics(
5680 variant in 0u8..22,
5681 msg in "\\PC{0,100}",
5682 ) {
5683 let event_type = match variant {
5684 0 => EventType::PlanCreated { plan_id: msg.clone(), package_count: 5 },
5685 1 => EventType::ExecutionStarted,
5686 2 => EventType::ExecutionFinished { result: ExecutionResult::Success },
5687 3 => EventType::PackageStarted { name: msg.clone(), version: "1.0.0".to_string() },
5688 4 => EventType::PackageAttempted { attempt: 1, command: msg.clone() },
5689 5 => EventType::PackageOutput { stdout_tail: msg.clone(), stderr_tail: String::new() },
5690 6 => EventType::PackagePublished { duration_ms: 100 },
5691 7 => EventType::PackageFailed { class: ErrorClass::Retryable, message: msg.clone() },
5692 8 => EventType::PackageSkipped { reason: msg.clone() },
5693 9 => EventType::PublishWaiting { reason: msg.clone(), delay_ms: 1000, until: Utc::now() },
5694 10 => EventType::RateLimitObserved { is_new_crate: true, retry_after_ms: Some(30_000), message: msg.clone() },
5695 11 => EventType::ReadinessStarted { method: ReadinessMethod::Api },
5696 12 => EventType::ReadinessPoll { attempt: 1, visible: false },
5697 13 => EventType::ReadinessPollScheduled { attempt: 2, delay_ms: 1000, next_poll_at: Utc::now() },
5698 14 => EventType::ReadinessComplete { duration_ms: 500, attempts: 3 },
5699 15 => EventType::ReadinessTimeout { max_wait_ms: 60000 },
5700 16 => EventType::IndexReadinessStarted { crate_name: msg.clone(), version: "1.0.0".to_string() },
5701 17 => EventType::IndexReadinessCheck { crate_name: msg.clone(), version: "1.0.0".to_string(), found: true },
5702 18 => EventType::IndexReadinessComplete { crate_name: msg.clone(), version: "1.0.0".to_string(), visible: true },
5703 19 => EventType::RetryScheduled { attempt: 1, max_attempts: 3, delay_ms: 1000, next_attempt_at: Utc::now(), reason: ErrorClass::Retryable, message: msg.clone() },
5704 20 => EventType::PreflightStarted,
5705 _ => EventType::PreflightComplete { finishability: Finishability::Proven },
5706 };
5707 let debug = format!("{:?}", event_type);
5708 assert!(!debug.is_empty());
5709 }
5710
5711 #[test]
5712 fn publish_event_debug_never_panics(
5713 pkg in "[a-z][a-z0-9-]{0,15}@[0-9]+\\.[0-9]+\\.[0-9]+",
5714 ) {
5715 let event = PublishEvent {
5716 timestamp: Utc::now(),
5717 event_type: EventType::ExecutionStarted,
5718 package: pkg,
5719 };
5720 let debug = format!("{:?}", event);
5721 assert!(!debug.is_empty());
5722 }
5723 }
5724
5725 proptest! {
5728 #[test]
5730 fn arbitrary_package_state_sequence(steps in 1usize..10) {
5731 let mut current = PackageState::Pending;
5732 for _ in 0..steps {
5733 let nexts = valid_next_states(¤t);
5734 if nexts.is_empty() {
5735 break; }
5737 current = nexts[0].clone();
5739 }
5740 let debug = format!("{:?}", current);
5742 assert!(!debug.is_empty());
5743 }
5744
5745 #[test]
5747 fn happy_path_always_reaches_published(_seed in 0u64..100) {
5748 let mut state = PackageState::Pending;
5749 let nexts = valid_next_states(&state);
5751 assert!(nexts.iter().any(|s| matches!(s, PackageState::Uploaded)));
5752 state = PackageState::Uploaded;
5753 let nexts = valid_next_states(&state);
5755 assert!(nexts.iter().any(|s| matches!(s, PackageState::Published)));
5756 state = PackageState::Published;
5757 assert!(valid_next_states(&state).is_empty());
5759 }
5760
5761 #[test]
5763 fn receipt_evidence_attempt_counts_preserved(
5764 attempt_count in 0usize..5,
5765 readiness_count in 0usize..5,
5766 ) {
5767 let now = Utc::now();
5768 let attempts: Vec<AttemptEvidence> = (0..attempt_count)
5769 .map(|i| AttemptEvidence {
5770 attempt_number: i as u32 + 1,
5771 command: format!("cargo publish attempt {i}"),
5772 exit_code: 0,
5773 stdout_tail: "ok".to_string(),
5774 stderr_tail: String::new(),
5775 timestamp: now,
5776 duration: Duration::from_secs(1),
5777 })
5778 .collect();
5779 let checks: Vec<ReadinessEvidence> = (0..readiness_count)
5780 .map(|i| ReadinessEvidence {
5781 attempt: i as u32 + 1,
5782 visible: i == readiness_count - 1,
5783 timestamp: now,
5784 delay_before: Duration::from_secs(2),
5785 })
5786 .collect();
5787 let evidence = PackageEvidence {
5788 attempts: attempts.clone(),
5789 readiness_checks: checks.clone(),
5790 };
5791 let json = serde_json::to_string(&evidence).unwrap();
5792 let parsed: PackageEvidence = serde_json::from_str(&json).unwrap();
5793 assert_eq!(parsed.attempts.len(), attempt_count);
5794 assert_eq!(parsed.readiness_checks.len(), readiness_count);
5795 for (orig, p) in attempts.iter().zip(parsed.attempts.iter()) {
5796 assert_eq!(orig.attempt_number, p.attempt_number);
5797 assert_eq!(orig.exit_code, p.exit_code);
5798 }
5799 }
5800 }
5801
5802 fn calculate_index_path_for_crate(crate_name: &str) -> String {
5805 let lower = crate_name.to_lowercase();
5806 match lower.len() {
5807 1 => format!("1/{}", lower),
5808 2 => format!("2/{}", lower),
5809 3 => format!("3/{}/{}", &lower[..1], lower),
5810 _ => format!("{}/{}/{}", &lower[..2], &lower[2..4], lower),
5811 }
5812 }
5813
5814 fn parse_schema_version_for_test(version: &str) -> Result<u32, String> {
5815 let parts: Vec<&str> = version.split('.').collect();
5816 if parts.len() != 3 || !parts[0].starts_with("shipper") || !parts[2].starts_with('v') {
5817 return Err("invalid format".to_string());
5818 }
5819
5820 let version_part = &parts[2][1..];
5821 version_part.parse::<u32>().map_err(|e| e.to_string())
5822 }
5823
5824 proptest! {
5827 #[test]
5829 fn release_plan_with_deps_roundtrip(
5830 pkg_count in 0usize..8,
5831 plan_id in "[a-f0-9]{8}",
5832 ) {
5833 let packages: Vec<PlannedPackage> = (0..pkg_count)
5834 .map(|i| PlannedPackage {
5835 name: format!("crate-{i}"),
5836 version: format!("0.{i}.0"),
5837 manifest_path: PathBuf::from(format!("crates/crate-{i}/Cargo.toml")),
5838 regime: None,
5839 })
5840 .collect();
5841
5842 let mut deps = BTreeMap::new();
5843 for i in 1..pkg_count {
5844 deps.insert(
5845 format!("crate-{i}"),
5846 vec![format!("crate-{}", i - 1)],
5847 );
5848 }
5849
5850 let plan = ReleasePlan {
5851 plan_version: "shipper.plan.v1".to_string(),
5852 plan_id: plan_id.clone(),
5853 created_at: Utc::now(),
5854 registry: Registry::crates_io(),
5855 packages: packages.clone(),
5856 dependencies: deps.clone(),
5857 };
5858
5859 let json = serde_json::to_string(&plan).unwrap();
5860 let parsed: ReleasePlan = serde_json::from_str(&json).unwrap();
5861
5862 prop_assert_eq!(parsed.plan_id, plan.plan_id);
5863 prop_assert_eq!(parsed.packages.len(), pkg_count);
5864 prop_assert_eq!(parsed.dependencies.len(), deps.len());
5865 for (orig, p) in plan.packages.iter().zip(parsed.packages.iter()) {
5866 prop_assert_eq!(&p.name, &orig.name);
5867 prop_assert_eq!(&p.version, &orig.version);
5868 }
5869 }
5870
5871 #[test]
5873 fn plan_levels_respect_dependency_ordering(
5874 pkg_count in 1usize..10,
5875 ) {
5876 let packages: Vec<PlannedPackage> = (0..pkg_count)
5877 .map(|i| PlannedPackage {
5878 name: format!("crate-{i}"),
5879 version: format!("0.{i}.0"),
5880 manifest_path: PathBuf::from(format!("crates/crate-{i}/Cargo.toml")),
5881 regime: None,
5882 })
5883 .collect();
5884
5885 let mut deps = BTreeMap::new();
5887 for i in 1..pkg_count {
5888 deps.insert(
5889 format!("crate-{i}"),
5890 vec![format!("crate-{}", i - 1)],
5891 );
5892 }
5893
5894 let plan = ReleasePlan {
5895 plan_version: "shipper.plan.v1".to_string(),
5896 plan_id: "test-plan".to_string(),
5897 created_at: Utc::now(),
5898 registry: Registry::crates_io(),
5899 packages,
5900 dependencies: deps.clone(),
5901 };
5902
5903 let levels = plan.group_by_levels();
5904
5905 let mut pkg_level: BTreeMap<String, usize> = BTreeMap::new();
5907 for level in &levels {
5908 for pkg in &level.packages {
5909 pkg_level.insert(pkg.name.clone(), level.level);
5910 }
5911 }
5912
5913 for (name, dep_list) in &deps {
5915 if let Some(&my_level) = pkg_level.get(name.as_str()) {
5916 for dep in dep_list {
5917 if let Some(&dep_level) = pkg_level.get(dep.as_str()) {
5918 prop_assert!(
5919 dep_level < my_level,
5920 "{name} (level {my_level}) depends on {dep} (level {dep_level})"
5921 );
5922 }
5923 }
5924 }
5925 }
5926 }
5927
5928 #[test]
5930 fn receipt_contains_all_plan_packages(
5931 pkg_count in 1usize..8,
5932 ) {
5933 let now = Utc::now();
5934 let packages: Vec<PlannedPackage> = (0..pkg_count)
5935 .map(|i| PlannedPackage {
5936 name: format!("crate-{i}"),
5937 version: format!("0.{i}.0"),
5938 manifest_path: PathBuf::from(format!("crates/crate-{i}/Cargo.toml")),
5939 regime: None,
5940 })
5941 .collect();
5942
5943 let receipts: Vec<PackageReceipt> = packages
5944 .iter()
5945 .map(|pkg| PackageReceipt {
5946 name: pkg.name.clone(),
5947 version: pkg.version.clone(),
5948 attempts: 1,
5949 state: PackageState::Published,
5950 started_at: now,
5951 finished_at: now,
5952 duration_ms: 100,
5953 evidence: PackageEvidence {
5954 attempts: vec![],
5955 readiness_checks: vec![],
5956 },
5957 compromised_at: None,
5958 compromised_by: None,
5959 superseded_by: None,
5960 })
5961 .collect();
5962
5963 let receipt = Receipt {
5964 receipt_version: "shipper.receipt.v1".to_string(),
5965 plan_id: "plan-test".to_string(),
5966 registry: Registry::crates_io(),
5967 started_at: now,
5968 finished_at: now,
5969 packages: receipts.clone(),
5970 event_log_path: PathBuf::from(".shipper/events.jsonl"),
5971 git_context: None,
5972 environment: EnvironmentFingerprint {
5973 shipper_version: "0.1.0".to_string(),
5974 cargo_version: None,
5975 rust_version: None,
5976 os: "linux".to_string(),
5977 arch: "x86_64".to_string(),
5978 },
5979 auth_evidence: None,
5980 };
5981
5982 for pkg in &packages {
5984 let found = receipt.packages.iter().any(|r| r.name == pkg.name && r.version == pkg.version);
5985 prop_assert!(found, "package {}@{} missing from receipt", pkg.name, pkg.version);
5986 }
5987 prop_assert_eq!(receipt.packages.len(), packages.len());
5988
5989 let json = serde_json::to_string(&receipt).unwrap();
5991 let parsed: Receipt = serde_json::from_str(&json).unwrap();
5992 prop_assert_eq!(parsed.packages.len(), receipt.packages.len());
5993 }
5994 }
5995 }
5996
5997 #[test]
6000 fn state_event_drift_default_is_consistent() {
6001 let drift = StateEventDrift::default();
6002 assert!(drift.is_consistent());
6003 }
6004
6005 #[test]
6006 fn state_event_drift_in_events_only_is_inconsistent() {
6007 let drift = StateEventDrift {
6008 in_events_only: vec!["pkg-a@1.0.0".to_string()],
6009 in_state_only: vec![],
6010 };
6011 assert!(!drift.is_consistent());
6012 }
6013
6014 #[test]
6015 fn state_event_drift_in_state_only_is_inconsistent() {
6016 let drift = StateEventDrift {
6017 in_events_only: vec![],
6018 in_state_only: vec!["pkg-b@2.0.0".to_string()],
6019 };
6020 assert!(!drift.is_consistent());
6021 }
6022
6023 #[test]
6024 fn state_event_drift_both_sides_drift_is_inconsistent() {
6025 let drift = StateEventDrift {
6026 in_events_only: vec!["pkg-a@1.0.0".to_string()],
6027 in_state_only: vec!["pkg-b@2.0.0".to_string()],
6028 };
6029 assert!(!drift.is_consistent());
6030 }
6031
6032 #[test]
6033 fn state_event_drift_serde_roundtrip_preserves_consistency_check() {
6034 let drift = StateEventDrift {
6035 in_events_only: vec!["x@0.1.0".to_string(), "y@0.2.0".to_string()],
6036 in_state_only: vec![],
6037 };
6038
6039 let json = serde_json::to_string(&drift).expect("serialize");
6040 let parsed: StateEventDrift = serde_json::from_str(&json).expect("deserialize");
6041
6042 assert_eq!(parsed, drift);
6043 assert_eq!(parsed.is_consistent(), drift.is_consistent());
6044 }
6045
6046 #[test]
6049 fn git_context_new_returns_empty_default() {
6050 let ctx = GitContext::new();
6051 assert!(ctx.commit.is_none());
6052 assert!(ctx.branch.is_none());
6053 assert!(ctx.tag.is_none());
6054 assert!(ctx.dirty.is_none());
6055 }
6056
6057 #[test]
6058 fn git_context_has_commit_returns_true_when_commit_set() {
6059 let ctx = GitContext {
6060 commit: Some("deadbeefdeadbeef".to_string()),
6061 ..GitContext::default()
6062 };
6063 assert!(ctx.has_commit());
6064 }
6065
6066 #[test]
6067 fn git_context_has_commit_returns_false_when_commit_absent() {
6068 let ctx = GitContext::new();
6069 assert!(!ctx.has_commit());
6070 }
6071
6072 #[test]
6073 fn git_context_is_dirty_defaults_to_true_when_unknown() {
6074 let ctx = GitContext::new();
6077 assert!(ctx.is_dirty());
6078 }
6079
6080 #[test]
6081 fn git_context_is_dirty_true_when_explicitly_dirty() {
6082 let ctx = GitContext {
6083 dirty: Some(true),
6084 ..GitContext::default()
6085 };
6086 assert!(ctx.is_dirty());
6087 }
6088
6089 #[test]
6090 fn git_context_is_dirty_false_when_explicitly_clean() {
6091 let ctx = GitContext {
6092 dirty: Some(false),
6093 ..GitContext::default()
6094 };
6095 assert!(!ctx.is_dirty());
6096 }
6097
6098 #[test]
6099 fn git_context_short_commit_truncates_to_seven_chars() {
6100 let ctx = GitContext {
6101 commit: Some("0123456789abcdef".to_string()),
6102 ..GitContext::default()
6103 };
6104 assert_eq!(ctx.short_commit(), Some("0123456"));
6105 }
6106
6107 #[test]
6108 fn git_context_short_commit_returns_full_when_seven_or_shorter() {
6109 let ctx_seven = GitContext {
6110 commit: Some("abcdef0".to_string()),
6111 ..GitContext::default()
6112 };
6113 assert_eq!(ctx_seven.short_commit(), Some("abcdef0"));
6114
6115 let ctx_three = GitContext {
6116 commit: Some("abc".to_string()),
6117 ..GitContext::default()
6118 };
6119 assert_eq!(ctx_three.short_commit(), Some("abc"));
6120 }
6121
6122 #[test]
6123 fn git_context_short_commit_empty_string_returns_empty() {
6124 let ctx = GitContext {
6125 commit: Some(String::new()),
6126 ..GitContext::default()
6127 };
6128 assert_eq!(ctx.short_commit(), Some(""));
6129 }
6130
6131 #[test]
6132 fn git_context_short_commit_returns_none_when_no_commit() {
6133 let ctx = GitContext::new();
6134 assert_eq!(ctx.short_commit(), None);
6135 }
6136
6137 fn pkg(name: &str) -> String {
6140 name.to_string()
6141 }
6142
6143 #[test]
6144 fn group_packages_by_levels_empty_input_returns_empty() {
6145 let levels: Vec<GenericPublishLevel<String>> =
6146 group_packages_by_levels(&[], |s: &String| s.as_str(), &BTreeMap::new());
6147 assert!(levels.is_empty());
6148 }
6149
6150 #[test]
6151 fn group_packages_by_levels_dedupes_duplicate_package_names() {
6152 let pkgs = vec![pkg("a"), pkg("a"), pkg("b")];
6153 let levels = group_packages_by_levels(&pkgs, |s: &String| s.as_str(), &BTreeMap::new());
6154
6155 assert_eq!(levels.len(), 1);
6156 assert_eq!(levels[0].packages.len(), 2);
6157 assert_eq!(levels[0].packages, vec!["a".to_string(), "b".to_string()]);
6158 }
6159
6160 #[test]
6161 fn group_packages_by_levels_ignores_dependencies_outside_ordered_set() {
6162 let pkgs = vec![pkg("a")];
6165 let deps = BTreeMap::from([(
6166 "a".to_string(),
6167 vec!["external".to_string(), "another-external".to_string()],
6168 )]);
6169
6170 let levels = group_packages_by_levels(&pkgs, |s: &String| s.as_str(), &deps);
6171
6172 assert_eq!(levels.len(), 1);
6173 assert_eq!(levels[0].level, 0);
6174 assert_eq!(levels[0].packages, vec!["a".to_string()]);
6175 }
6176
6177 #[test]
6178 fn group_packages_by_levels_cycle_falls_back_to_singletons() {
6179 let pkgs = vec![pkg("a"), pkg("b")];
6182 let deps = BTreeMap::from([
6183 ("a".to_string(), vec!["b".to_string()]),
6184 ("b".to_string(), vec!["a".to_string()]),
6185 ]);
6186
6187 let levels = group_packages_by_levels(&pkgs, |s: &String| s.as_str(), &deps);
6188
6189 let all: Vec<String> = levels.iter().flat_map(|l| l.packages.clone()).collect();
6190
6191 assert!(all.contains(&"a".to_string()));
6192 assert!(all.contains(&"b".to_string()));
6193 assert_eq!(all.len(), 2);
6194 }
6195
6196 #[test]
6197 fn group_packages_by_levels_diamond_dependency() {
6198 let pkgs = vec![pkg("top"), pkg("mid_l"), pkg("mid_r"), pkg("bottom")];
6201 let deps = BTreeMap::from([
6202 ("mid_l".to_string(), vec!["top".to_string()]),
6203 ("mid_r".to_string(), vec!["top".to_string()]),
6204 (
6205 "bottom".to_string(),
6206 vec!["mid_l".to_string(), "mid_r".to_string()],
6207 ),
6208 ]);
6209
6210 let levels = group_packages_by_levels(&pkgs, |s: &String| s.as_str(), &deps);
6211
6212 assert_eq!(levels.len(), 3);
6213 assert_eq!(levels[0].packages, vec!["top".to_string()]);
6214 assert_eq!(levels[1].packages.len(), 2);
6215 assert!(levels[1].packages.contains(&"mid_l".to_string()));
6216 assert!(levels[1].packages.contains(&"mid_r".to_string()));
6217 assert_eq!(levels[2].packages, vec!["bottom".to_string()]);
6218 }
6219
6220 #[test]
6221 fn group_packages_by_levels_preserves_input_order_within_level() {
6222 let pkgs = vec![pkg("zebra"), pkg("apple"), pkg("mango")];
6224
6225 let levels = group_packages_by_levels(&pkgs, |s: &String| s.as_str(), &BTreeMap::new());
6226
6227 assert_eq!(levels.len(), 1);
6228 assert_eq!(
6229 levels[0].packages,
6230 vec![
6231 "zebra".to_string(),
6232 "apple".to_string(),
6233 "mango".to_string()
6234 ]
6235 );
6236 }
6237
6238 #[test]
6241 fn publish_regime_is_new_crate_true_for_first_publish() {
6242 assert!(PublishRegime::FirstPublish.is_new_crate());
6243 }
6244
6245 #[test]
6246 fn publish_regime_is_new_crate_false_for_update() {
6247 assert!(!PublishRegime::Update.is_new_crate());
6248 }
6249}