Skip to main content

shipper_types/
lib.rs

1//! # Types
2//!
3//! Core domain types for Shipper, including specs, plans, options, receipts, and errors.
4//!
5//! This module defines the fundamental data structures used throughout Shipper:
6//! - [`ReleaseSpec`] - Input specification for a publish operation
7//! - [`ReleasePlan`] - Deterministic, SHA256-identified publish plan  
8//! - [`RuntimeOptions`] - All runtime configuration options
9//! - [`Receipt`] - Audit receipt with evidence for each published crate
10//! - [`PreflightReport`] - Preflight assessment with finishability verdict
11//! - [`PublishPolicy`] - Policy presets for safety vs. speed tradeoffs
12//!
13//! ## Serialization
14//!
15//! Most types implement `Serialize` and `Deserialize` from `serde` for
16//! persistence to disk. Durations are serialized as milliseconds for
17//! cross-platform compatibility.
18//!
19//! ## Stability
20//!
21//! These types are considered stable unless otherwise noted. Breaking
22//! changes will be documented in the changelog.
23
24use 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
38/// Schema version parsing and compatibility validation for shipper state files.
39///
40/// This module was folded in from the former `shipper-schema` crate in Phase 6
41/// of the decrating effort (see `docs/decrating-plan.md`).
42pub mod schema;
43
44/// Represents a Cargo registry for publishing crates.
45///
46/// A registry is identified by its name (used with `cargo publish --registry <name>`)
47/// and its API/base URLs. The default registry is crates.io, which can be created
48/// using [`Registry::crates_io()`].
49///
50/// # Example
51///
52/// ```ignore
53/// use shipper::types::Registry;
54///
55/// // Use crates.io (default)
56/// let crates_io = Registry::crates_io();
57///
58/// // Custom registry
59/// let my_registry = Registry {
60///     name: "my-registry".to_string(),
61///     api_base: "https://my-registry.example.com".to_string(),
62///     index_base: Some("https://index.my-registry.example.com".to_string()),
63/// };
64/// ```
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct Registry {
67    /// Cargo registry name (for `cargo publish --registry <name>`). For crates.io this is typically `crates-io`.
68    pub name: String,
69    /// Base URL for registry web API, e.g. `https://crates.io`.
70    pub api_base: String,
71    /// Base URL for the sparse index, e.g. `https://index.crates.io`.
72    /// If not specified, will be derived from the API base.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub index_base: Option<String>,
75}
76
77impl Registry {
78    /// Creates a new [`Registry`] configured for crates.io.
79    ///
80    /// This is the default registry used by Cargo and is the most common
81    /// target for publishing Rust crates.
82    ///
83    /// # Returns
84    ///
85    /// A [`Registry`] with:
86    /// - name: `"crates-io"`
87    /// - api_base: `"https://crates.io"`
88    /// - index_base: `Some("https://index.crates.io")`
89    ///
90    /// # Example
91    ///
92    /// ```ignore
93    /// use shipper::types::Registry;
94    ///
95    /// let registry = Registry::crates_io();
96    /// assert_eq!(registry.name, "crates-io");
97    /// assert_eq!(registry.api_base, "https://crates.io");
98    /// ```
99    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    /// Get the index base URL, deriving it from the API base if not explicitly set.
108    /// Strips the `sparse+` prefix if present (used by Cargo's sparse index config).
109    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            // Default: derive from API base (e.g., https://crates.io -> https://index.crates.io)
117            self.api_base
118                .replace("https://", "https://index.")
119                .replace("http://", "http://index.")
120        }
121    }
122}
123
124/// Input specification for a crate publish operation.
125///
126/// This is the primary entry point for configuring a Shipper publish operation.
127/// It defines what to publish, where to publish it, and which packages to include.
128///
129/// # Example
130///
131/// ```ignore
132/// use std::path::PathBuf;
133/// use shipper::types::{ReleaseSpec, Registry};
134///
135/// let spec = ReleaseSpec {
136///     manifest_path: PathBuf::from("Cargo.toml"),
137///     registry: Registry::crates_io(),
138///     selected_packages: None, // Publish all packages
139/// };
140///
141/// // Or with specific packages
142/// let specific_spec = ReleaseSpec {
143///     manifest_path: PathBuf::from("Cargo.toml"),
144///     registry: Registry::crates_io(),
145///     selected_packages: Some(vec!["my-crate".to_string()]),
146/// };
147/// ```
148///
149/// # Fields
150///
151/// - `manifest_path`: Path to the workspace's `Cargo.toml`
152/// - `registry`: Target [`Registry`] for publishing
153/// - `selected_packages`: Optional list of package names to publish (None = all)
154#[derive(Debug, Clone)]
155pub struct ReleaseSpec {
156    /// Path to the workspace's `Cargo.toml` manifest.
157    pub manifest_path: PathBuf,
158    /// Target registry for publishing.
159    pub registry: Registry,
160    /// Optional list of package names to publish. If `None`, all publishable
161    /// packages in the workspace will be published.
162    pub selected_packages: Option<Vec<String>>,
163}
164
165/// Policy presets that control the balance between safety and speed in publishing.
166///
167/// These policies determine which preflight checks and readiness verifications
168/// are performed during the publish process. Choosing a more conservative policy
169/// increases reliability at the cost of longer execution time.
170///
171/// # Example
172///
173/// ```ignore
174/// use shipper::types::PublishPolicy;
175///
176/// // Default: maximum safety
177/// let safe = PublishPolicy::Safe;
178///
179/// // Balanced: skip some checks for known-good scenarios
180/// let balanced = PublishPolicy::Balanced;
181///
182/// // Fast: minimal verification, maximum risk
183/// let fast = PublishPolicy::Fast;
184/// ```
185///
186/// # Variants
187///
188/// - [`PublishPolicy::Safe`] - Full preflight verification and readiness checks (default)
189/// - [`PublishPolicy::Balanced`] - Verify only when needed for experienced users
190/// - [`PublishPolicy::Fast`] - Skip all verification, assume the user knows what they're doing
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
192#[serde(rename_all = "snake_case")]
193pub enum PublishPolicy {
194    /// Verify + strict checks (default)
195    ///
196    /// This is the default policy. It performs:
197    /// - Full preflight verification (git cleanliness, dry-run, version existence)
198    /// - Readiness checks after publishing
199    /// - Ownership verification if applicable
200    #[default]
201    Safe,
202    /// Verify only when needed
203    ///
204    /// Skips some checks that are redundant in well-tested workflows.
205    /// Suitable for CI/CD pipelines with established release processes.
206    Balanced,
207    /// No verify; explicit risk
208    ///
209    /// Disables all verification. Use only when you understand the risks
210    /// and have verified the publish process manually. Faster but dangerous.
211    Fast,
212}
213
214/// Controls when and how `cargo verify` is run before publishing.
215///
216/// Verification compiles the crate to ensure it builds correctly before
217/// attempting to publish. This adds safety but increases publish time.
218///
219/// # Example
220///
221/// ```ignore
222/// use shipper::types::VerifyMode;
223///
224/// // Verify the entire workspace at once (most efficient)
225/// let workspace = VerifyMode::Workspace;
226///
227/// // Verify each crate individually (more thorough)
228/// let package = VerifyMode::Package;
229///
230/// // Skip verification entirely
231/// let none = VerifyMode::None;
232/// ```
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
234#[serde(rename_all = "snake_case")]
235pub enum VerifyMode {
236    /// Default, safest - run workspace dry-run
237    ///
238    /// Runs `cargo verify` on the entire workspace once. This is the
239    /// default and most efficient option.
240    #[default]
241    Workspace,
242    /// Per-crate verify
243    ///
244    /// Runs `cargo verify` for each crate individually before publishing.
245    /// More thorough but slower than workspace mode.
246    Package,
247    /// No verify
248    ///
249    /// Skips verification entirely. Use with caution.
250    None,
251}
252
253/// Method for verifying crate visibility after publishing.
254///
255/// After a crate is published, Shipper can verify it becomes visible on
256/// the registry before proceeding. This catches issues like propagation
257/// delays or rejected publishes that Cargo might not report immediately.
258///
259/// # Example
260///
261/// ```ignore
262/// use shipper::types::ReadinessMethod;
263///
264/// // Fast: check the registry HTTP API
265/// let api = ReadinessMethod::Api;
266///
267/// // Accurate: check the sparse index directly
268/// let index = ReadinessMethod::Index;
269///
270/// // Reliable: check both (slowest)
271/// let both = ReadinessMethod::Both;
272/// ```
273///
274/// # Performance
275///
276/// - `Api`: ~1-2 requests per crate (fastest)
277/// - `Index`: ~10-50 requests per crate (slower, most accurate)
278/// - `Both`: Combines both methods (slowest, most reliable)
279#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
280#[serde(rename_all = "snake_case")]
281pub enum ReadinessMethod {
282    /// Check crates.io HTTP API (default, fast)
283    ///
284    /// Makes HTTP requests to the registry's API to check if the
285    /// version is visible. Fast but may not catch all edge cases.
286    #[default]
287    Api,
288    /// Check sparse index (slower, more accurate)
289    ///
290    /// Downloads and checks the sparse index for the crate.
291    /// More accurate than API but requires more requests.
292    Index,
293    /// Check both (slowest, most reliable)
294    ///
295    /// Uses both API and index methods, only passing if both
296    /// confirm visibility. Most reliable but slowest.
297    Both,
298}
299
300/// Configuration for readiness verification after publishing.
301///
302/// Readiness verification confirms that a published crate is visible on
303/// the registry before Shipper considers the publish successful. This
304/// catches propagation delays and failed publishes early.
305///
306/// # Example
307///
308/// ```ignore
309/// use std::time::Duration;
310/// use shipper::types::{ReadinessConfig, ReadinessMethod};
311///
312/// // Default configuration
313/// let config = ReadinessConfig::default();
314///
315/// // Custom configuration
316/// let custom = ReadinessConfig {
317///     enabled: true,
318///     method: ReadinessMethod::Both,
319///     initial_delay: Duration::from_secs(2),
320///     max_delay: Duration::from_secs(120),
321///     max_total_wait: Duration::from_secs(600), // 10 minutes
322///     poll_interval: Duration::from_secs(5),
323///     jitter_factor: 0.3,
324///     index_path: None,
325///     prefer_index: false,
326/// };
327/// ```
328///
329/// # Defaults
330///
331/// - `enabled`: `true`
332/// - `method`: [`ReadinessMethod::Api`]
333/// - `initial_delay`: 1 second
334/// - `max_delay`: 60 seconds
335/// - `max_total_wait`: 300 seconds (5 minutes)
336/// - `poll_interval`: 2 seconds
337/// - `jitter_factor`: 0.5 (±50%)
338#[serde_as]
339#[derive(Debug, Clone, Serialize, Deserialize)]
340#[serde(default)]
341pub struct ReadinessConfig {
342    /// Enable readiness checks
343    ///
344    /// When disabled, Shipper will not verify crate visibility after
345    /// publishing. This speeds up publishing but may miss failures.
346    pub enabled: bool,
347    /// Method for checking version visibility
348    pub method: ReadinessMethod,
349    /// Initial delay before first poll
350    ///
351    /// Most registries need a few seconds to propagate new versions.
352    /// This delay allows the initial propagation to complete before
353    /// starting to poll.
354    #[serde(
355        deserialize_with = "deserialize_duration",
356        serialize_with = "serialize_duration"
357    )]
358    pub initial_delay: Duration,
359    /// Maximum delay between polls (capped)
360    ///
361    /// The poll interval starts at the initial_delay value and increases
362    /// exponentially up to this maximum.
363    #[serde(
364        deserialize_with = "deserialize_duration",
365        serialize_with = "serialize_duration"
366    )]
367    pub max_delay: Duration,
368    /// Maximum total time to wait for visibility
369    ///
370    /// If the crate is not visible within this time, the publish is
371    /// considered failed. This prevents waiting indefinitely.
372    #[serde(
373        deserialize_with = "deserialize_duration",
374        serialize_with = "serialize_duration"
375    )]
376    pub max_total_wait: Duration,
377    /// Base poll interval
378    ///
379    /// The interval between readiness checks. This is the starting
380    /// interval before jitter and exponential backoff are applied.
381    #[serde(
382        deserialize_with = "deserialize_duration",
383        serialize_with = "serialize_duration"
384    )]
385    pub poll_interval: Duration,
386    /// Jitter factor (±50% means 0.5)
387    ///
388    /// Adds randomness to poll intervals to reduce thundering herd
389    /// when many clients are checking simultaneously. A value of 0.5
390    /// means the actual interval varies by ±50%.
391    pub jitter_factor: f64,
392    /// Custom index path for testing (optional)
393    ///
394    /// When set, uses this local path instead of downloading from
395    /// the remote index. Useful for testing with mock registries.
396    #[serde(skip_serializing_if = "Option::is_none")]
397    pub index_path: Option<PathBuf>,
398    /// Use index as primary method when Both is selected
399    ///
400    /// When [`ReadinessMethod::Both`] is used, this determines which
401    /// method is checked first. If `true`, the index is checked first.
402    #[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), // 5 minutes
414            poll_interval: Duration::from_secs(2),
415            jitter_factor: 0.5,
416            index_path: None,
417            prefer_index: false,
418        }
419    }
420}
421
422/// Configuration for parallel publishing.
423///
424/// Parallel publishing allows independent crates in a workspace to be
425/// published concurrently, significantly reducing total publish time
426/// for large workspaces with many independent crates.
427///
428/// # Example
429///
430/// ```ignore
431/// use std::time::Duration;
432/// use shipper::types::ParallelConfig;
433///
434/// // Default: sequential publishing
435/// let sequential = ParallelConfig::default();
436///
437/// // Enable parallel publishing
438/// let parallel = ParallelConfig {
439///     enabled: true,
440///     max_concurrent: 4,
441///     per_package_timeout: Duration::from_secs(1800), // 30 minutes
442/// };
443/// ```
444///
445/// # How It Works
446///
447/// Shipper analyzes the dependency graph and groups crates into "levels".
448/// Crates at the same level have no dependencies on each other and can
449/// be published in parallel. Crates at higher levels must wait for all
450/// crates at lower levels to complete.
451///
452/// # Defaults
453///
454/// - `enabled`: `false` (sequential by default)
455/// - `max_concurrent`: 4
456/// - `per_package_timeout`: 1800 seconds (30 minutes)
457#[derive(Debug, Clone, Serialize, Deserialize)]
458#[serde(default)]
459pub struct ParallelConfig {
460    /// Enable parallel publishing (default: false for sequential)
461    ///
462    /// When disabled (the default), crates are published one at a time
463    /// in dependency order. When enabled, independent crates are
464    /// published concurrently.
465    pub enabled: bool,
466    /// Maximum number of concurrent publish operations (default: 4)
467    ///
468    /// The maximum number of crates that can be publishing simultaneously.
469    /// This limits resource usage and API rate limiting impact.
470    pub max_concurrent: usize,
471    /// Timeout per package publish operation (default: 30 minutes)
472    ///
473    /// If a single package publish takes longer than this duration,
474    /// it will be aborted and retried. This prevents a slow publish
475    /// from blocking the entire operation.
476    #[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), // 30 minutes
489        }
490    }
491}
492
493/// Runtime configuration options for a Shipper publish operation.
494///
495/// This struct contains all the tunable parameters that control how
496/// Shipper executes a publish operation, including retry behavior,
497/// verification settings, and output preferences.
498///
499/// # Example
500///
501/// ```ignore
502/// use std::path::PathBuf;
503/// use shipper::types::{RuntimeOptions, PublishPolicy, ParallelConfig};
504///
505/// let options = RuntimeOptions {
506///     allow_dirty: false,
507///     skip_ownership_check: false,
508///     strict_ownership: true,
509///     no_verify: false,
510///     max_attempts: 3,
511///     base_delay: std::time::Duration::from_secs(1),
512///     max_delay: std::time::Duration::from_secs(60),
513///     retry_strategy: shipper::retry::RetryStrategyType::Exponential,
514///     retry_jitter: 0.3,
515///     retry_per_error: shipper::retry::PerErrorConfig::default(),
516///     verify_timeout: std::time::Duration::from_secs(600),
517///     verify_poll_interval: std::time::Duration::from_secs(10),
518///     state_dir: PathBuf::from(".shipper"),
519///     force_resume: false,
520///     policy: PublishPolicy::Safe,
521///     verify_mode: shipper::types::VerifyMode::Workspace,
522///     readiness: shipper::types::ReadinessConfig::default(),
523///     output_lines: 1000,
524///     force: false,
525///     lock_timeout: std::time::Duration::from_secs(3600),
526///     parallel: ParallelConfig::default(),
527///     webhook: shipper::webhook::WebhookConfig::default(),
528///     encryption: shipper::encryption::EncryptionConfig::default(),
529///     registries: vec![],
530/// };
531/// ```
532#[derive(Debug, Clone)]
533pub struct RuntimeOptions {
534    /// Allow publishing from a dirty git working tree.
535    pub allow_dirty: bool,
536    /// Skip crate-ownership preflight checks.
537    pub skip_ownership_check: bool,
538    /// Fail preflight if ownership verification fails.
539    pub strict_ownership: bool,
540    /// Pass `--no-verify` to `cargo publish` (skip pre-publish build).
541    pub no_verify: bool,
542    /// Maximum number of publish attempts per crate.
543    pub max_attempts: u32,
544    /// Initial backoff delay between retries.
545    pub base_delay: Duration,
546    /// Upper bound on backoff delay.
547    pub max_delay: Duration,
548    /// Retry strategy type: immediate, exponential, linear, constant
549    pub retry_strategy: shipper_retry::RetryStrategyType,
550    /// Jitter factor for retry delays
551    pub retry_jitter: f64,
552    /// Per-error-type retry configuration
553    pub retry_per_error: shipper_retry::PerErrorConfig,
554    /// Timeout for the workspace-level dry-run verification step.
555    pub verify_timeout: Duration,
556    /// Poll interval for the dry-run verification step.
557    pub verify_poll_interval: Duration,
558    /// Directory for persisted state, receipts, and event logs.
559    pub state_dir: PathBuf,
560    /// Force resume even when the plan ID has changed.
561    pub force_resume: bool,
562    /// Publishing policy preset (safe / balanced / fast).
563    pub policy: PublishPolicy,
564    /// Dry-run verification mode (workspace / package / none).
565    pub verify_mode: VerifyMode,
566    /// Readiness (post-publish visibility) configuration.
567    pub readiness: ReadinessConfig,
568    /// Number of stdout/stderr lines to capture as evidence.
569    pub output_lines: usize,
570    /// Force override of existing locks
571    pub force: bool,
572    /// Lock timeout duration (after which locks are considered stale)
573    pub lock_timeout: Duration,
574    /// Parallel publishing configuration
575    pub parallel: ParallelConfig,
576    /// Webhook configuration for publish notifications
577    pub webhook: WebhookConfig,
578    /// Encryption configuration for state files
579    pub encryption: EncryptionSettings,
580    /// Target registries for multi-registry publishing
581    pub registries: Vec<Registry>,
582    /// Optional package name to resume from (skips all packages before this one)
583    pub resume_from: Option<String>,
584    /// Rehearsal registry name (#97) — if `Some`, `shipper rehearse` publishes
585    /// to this registry as phase-2 proof before live dispatch. `None` means
586    /// rehearsal is disabled (the default; opt-in until phase-2 stabilizes).
587    ///
588    /// The name must resolve against the configured [`Self::registries`] at
589    /// runtime; `engine::run_rehearsal` errors clean otherwise.
590    pub rehearsal_registry: Option<String>,
591    /// Operator override — explicitly skip rehearsal even if a
592    /// [`Self::rehearsal_registry`] is configured (#97). Default `false`.
593    /// When the hard gate lands (#97 PR 3), live publish will refuse to run
594    /// without this flag if rehearsal has not passed for the current `plan_id`.
595    pub rehearsal_skip: bool,
596    /// Crate name to install via `cargo install --registry <rehearsal>`
597    /// after all rehearsal publishes succeed (#97 PR 4). This is the
598    /// install/smoke check that proves end-to-end registry-index
599    /// resolution — the scenario that killed the rc.1 first-publish.
600    /// `None` means no smoke install (opt-in). The named crate must
601    /// exist in the plan AND have a `[[bin]]` target.
602    pub rehearsal_smoke_install: Option<String>,
603}
604
605/// Classification of a publish operation, used by the runtime to make
606/// registry-aware decisions (backoff windows, duration estimation,
607/// per-regime telemetry).
608///
609/// Preflight already determines whether a crate has ever been published
610/// by querying the registry; that answer is captured here and propagated
611/// through the [`ReleasePlan`] so the publish retry loop does not have
612/// to re-query the registry to recover it.
613///
614/// # Variants
615///
616/// - [`PublishRegime::FirstPublish`]: the crate has never been published.
617///   Triggers the documented crates.io new-crate rate-limit window.
618/// - [`PublishRegime::Update`]: the crate already exists; this is a new
619///   version upload.
620///
621/// # Example
622///
623/// ```ignore
624/// use shipper::types::PublishRegime;
625///
626/// let regime = PublishRegime::FirstPublish;
627/// assert_eq!(regime.is_new_crate(), true);
628/// ```
629#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
630#[serde(rename_all = "snake_case")]
631pub enum PublishRegime {
632    /// Crate has never been published to this registry.
633    FirstPublish,
634    /// Crate exists; publishing a new version.
635    Update,
636}
637
638impl PublishRegime {
639    /// Convenience: `true` iff this is a first-publish regime.
640    ///
641    /// Equivalent to the preflight `is_new_crate` boolean, but carried
642    /// through the plan so later phases do not have to re-query.
643    pub fn is_new_crate(self) -> bool {
644        matches!(self, PublishRegime::FirstPublish)
645    }
646}
647
648/// A package in the publish plan.
649///
650/// This represents a single crate that will be published as part of
651/// a [`ReleasePlan`]. It contains the minimal information needed to
652/// identify and publish the crate.
653///
654/// # Example
655///
656/// ```ignore
657/// use std::path::PathBuf;
658/// use shipper::types::PlannedPackage;
659///
660/// let pkg = PlannedPackage {
661///     name: "my-crate".to_string(),
662///     version: "1.2.3".to_string(),
663///     manifest_path: PathBuf::from("crates/my-crate/Cargo.toml"),
664///     regime: None,
665/// };
666/// ```
667#[derive(Debug, Clone, Serialize, Deserialize)]
668pub struct PlannedPackage {
669    pub name: String,
670    pub version: String,
671    pub manifest_path: PathBuf,
672    /// Publish-regime classification produced by preflight (#106).
673    ///
674    /// Optional for backward compatibility with state.json / plan files
675    /// written by earlier versions that predate this field. When
676    /// `None`, the publish retry loop falls back to re-querying the
677    /// registry on rate-limit errors; when `Some`, no re-query is
678    /// performed.
679    #[serde(default, skip_serializing_if = "Option::is_none")]
680    pub regime: Option<PublishRegime>,
681}
682
683/// A group of packages that can be published in parallel.
684///
685/// Packages at the same level have no dependencies on each other within
686/// the workspace, meaning they can be published concurrently without
687/// violating dependency order.
688///
689/// # Example
690///
691/// ```ignore
692/// use std::path::PathBuf;
693/// use shipper::types::{PublishLevel, PlannedPackage};
694///
695/// let level = PublishLevel {
696///     level: 0,
697///     packages: vec![
698///         PlannedPackage {
699///             name: "utils".to_string(),
700///             version: "1.0.0".to_string(),
701///             manifest_path: PathBuf::from("crates/utils/Cargo.toml"),
702///         },
703///         PlannedPackage {
704///             name: "common".to_string(),
705///             version: "2.0.0".to_string(),
706///             manifest_path: PathBuf::from("crates/common/Cargo.toml"),
707///         },
708///     ],
709/// };
710/// ```
711///
712/// # Level Numbering
713///
714/// Level 0 contains packages with no workspace dependencies.
715/// Level N contains packages that depend only on packages in levels 0..N.
716#[derive(Debug, Clone, Serialize, Deserialize)]
717pub struct PublishLevel {
718    /// The level number (0 = no dependencies, 1 = depends on level 0, etc.)
719    pub level: usize,
720    /// Packages that can be published in parallel at this level
721    pub packages: Vec<PlannedPackage>,
722}
723
724/// A deterministic, identified plan for publishing a workspace.
725///
726/// The release plan is generated by `shipper::plan::build_plan` and contains
727/// all information needed to execute the publish operation. It includes:
728/// - A unique plan ID (SHA256 hash of relevant content)
729/// - Ordered list of packages to publish
730/// - Dependency information for parallel publishing
731/// - Registry configuration
732///
733/// # Example
734///
735/// ```ignore
736/// let plan = plan::build_plan(&spec)?;
737/// println!("Publishing {} packages:", plan.plan.packages.len());
738/// for pkg in &plan.plan.packages {
739///     println!("  {} {}", pkg.name, pkg.version);
740/// }
741/// ```
742///
743/// # Resumability
744///
745/// The plan ID is stable across runs if the workspace metadata doesn't
746/// change. This allows Shipper to detect when a resumed operation is
747/// using the same plan.
748#[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    /// Packages in publish order (dependencies first).
755    pub packages: Vec<PlannedPackage>,
756    /// Map of package name -> set of package names it depends on (within the plan).
757    /// This is used for level-based parallel publishing.
758    #[serde(default)]
759    pub dependencies: BTreeMap<String, Vec<String>>,
760}
761
762/// A workspace package that was excluded from the publish plan.
763///
764/// Packages are skipped when their `publish` field in `Cargo.toml`
765/// is `false` or does not include the target registry.
766#[derive(Debug, Clone, Serialize, Deserialize)]
767pub struct SkippedPackage {
768    /// Crate name as declared in `Cargo.toml`.
769    pub name: String,
770    /// Crate version string.
771    pub version: String,
772    /// Human-readable reason the package was excluded.
773    pub reason: String,
774}
775
776/// The output of `shipper::plan::build_plan`: a publish plan plus context.
777///
778/// Contains the workspace root path, the deterministic [`ReleasePlan`],
779/// and a list of packages that were skipped (with reasons).
780#[derive(Debug, Clone)]
781pub struct PlannedWorkspace {
782    /// Absolute path to the workspace root directory.
783    pub workspace_root: PathBuf,
784    /// The deterministic, SHA256-identified publish plan.
785    pub plan: ReleasePlan,
786    /// Packages that were excluded from the plan.
787    pub skipped: Vec<SkippedPackage>,
788}
789
790impl ReleasePlan {
791    /// Group packages by dependency level for parallel publishing.
792    ///
793    /// Packages at the same level have no dependencies on each other and can
794    /// be published concurrently.
795    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/// A group of packages that can be processed in parallel.
807///
808/// Generic counterpart of [`PublishLevel`] used by [`group_packages_by_levels`].
809#[derive(Debug, Clone, PartialEq, Eq)]
810pub struct GenericPublishLevel<T> {
811    /// Zero-based level number.
812    pub level: usize,
813    /// Packages assigned to this level.
814    pub packages: Vec<T>,
815}
816
817/// Group packages into dependency levels.
818///
819/// `ordered_packages` should be deterministic. Dependencies that are not part
820/// of `ordered_packages` are ignored. If cyclic/inconsistent dependencies are
821/// encountered, the function falls back to deterministic singleton progress so
822/// every package still appears exactly once.
823pub 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/// The state of a package in the publish pipeline.
927///
928/// Each package in a release plan progresses through these states during
929/// publishing. The state is persisted to enable resumability after
930/// interruption.
931///
932/// # State Transitions
933///
934/// ```text
935/// Pending → Uploaded → Published
936///              ↓
937///            Failed
938///              ↓
939///           Pending (retry)
940/// ```
941///
942/// # Example
943///
944/// ```ignore
945/// use shipper::types::PackageState;
946///
947/// // Initial state
948/// let pending = PackageState::Pending;
949///
950/// // After successful upload
951/// let uploaded = PackageState::Uploaded;
952///
953/// // After visibility verification
954/// let published = PackageState::Published;
955///
956/// // When skipped (e.g., already published)
957/// let skipped = PackageState::Skipped {
958///     reason: "version already exists".to_string()
959/// };
960///
961/// // On failure
962/// let failed = PackageState::Failed {
963///     class: shipper::types::ErrorClass::Retryable,
964///     message: "network timeout".to_string(),
965/// };
966/// ```
967#[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/// Classification of errors encountered during publishing.
979///
980/// Error classification determines whether a publish attempt should be
981/// retried. Some errors are permanent (retrying won't help) while others
982/// are transient (likely to succeed on retry).
983///
984/// # Example
985///
986/// ```ignore
987/// use shipper::types::ErrorClass;
988///
989/// // Network issues, rate limiting - worth retrying
990/// let retryable = ErrorClass::Retryable;
991///
992/// // Invalid credentials, version conflict - won't succeed on retry
993/// let permanent = ErrorClass::Permanent;
994///
995/// // Unclear - may or may not be retryable
996/// let ambiguous = ErrorClass::Ambiguous;
997/// ```
998///
999/// # Classification is a hint, not truth
1000///
1001/// This enum is produced by parsing cargo's stdout/stderr — a human-facing
1002/// log that is explicitly not a stable machine protocol. Pattern-matching on
1003/// cargo text gives Shipper a fast first-pass signal, but **it must never be
1004/// treated as the final word** on what actually happened:
1005///
1006/// - [`ErrorClass::Permanent`] and [`ErrorClass::Retryable`] are still
1007///   hints — they drive retry scheduling, but every retry attempt re-checks
1008///   the registry before and after the next `cargo publish`.
1009/// - [`ErrorClass::Ambiguous`] is the dangerous case. Cargo's publish flow
1010///   uploads to the registry first and polls the index afterwards; the poll
1011///   can time out without affecting the upload. So a non-zero cargo exit
1012///   can coexist with a successful upload. Ambiguous outcomes MUST be
1013///   reconciled against registry truth before any further action — never
1014///   blind-retry. See [`ReconciliationOutcome`] and the reconciliation flow
1015///   in `shipper::engine::parallel::reconcile`.
1016///
1017/// The authoritative classification for `Ambiguous` outcomes comes from
1018/// **querying the registry** (sparse index + API) after the fact. Cargo
1019/// stderr is a signal; the registry is the source of truth.
1020///
1021/// # Classification Heuristics (hints)
1022///
1023/// Shipper uses various heuristics to classify errors:
1024/// - HTTP 429 (Too Many Requests) → Retryable
1025/// - HTTP 401/403 (Auth errors) → Permanent
1026/// - HTTP 409 (Version conflict) → Permanent
1027/// - Network timeouts → Retryable
1028/// - Unknown errors → Ambiguous (triggers registry reconciliation)
1029#[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/// Report of drift between the authoritative event log and the projected state.
1038///
1039/// Per [`docs/INVARIANTS.md`](https://github.com/EffortlessMetrics/shipper/blob/main/docs/INVARIANTS.md),
1040/// `events.jsonl` is the authoritative source of truth and `state.json` is a
1041/// projection derived from it. They should always agree about which packages
1042/// were published. A drift is a bug — this struct captures which side claims
1043/// what, so the end-of-run consistency check can surface it loudly rather
1044/// than silently corrupting resume.
1045///
1046/// A drift with both lists empty means the projection matches the truth and
1047/// [`StateEventDrift::is_consistent`] returns `true`.
1048///
1049/// Labels use the `name@version` format consistent with the rest of the
1050/// event stream (e.g., `shipper-types@0.3.0-rc.1`).
1051#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1052pub struct StateEventDrift {
1053    /// Packages that have a `PackagePublished` event in `events.jsonl` but
1054    /// are NOT marked `PackageState::Published` in `state.json`.
1055    ///
1056    /// This is the dangerous direction: resume would re-attempt publishing
1057    /// packages that already uploaded successfully.
1058    pub in_events_only: Vec<String>,
1059    /// Packages that are marked `PackageState::Published` in `state.json`
1060    /// but have NO `PackagePublished` event in `events.jsonl`.
1061    ///
1062    /// This shouldn't happen if events are appended before state is
1063    /// written; if it does, something bypassed the event log.
1064    pub in_state_only: Vec<String>,
1065}
1066
1067impl StateEventDrift {
1068    /// Returns `true` iff no drift was detected (both sides agree).
1069    pub fn is_consistent(&self) -> bool {
1070        self.in_events_only.is_empty() && self.in_state_only.is_empty()
1071    }
1072}
1073
1074/// Outcome of reconciling an ambiguous publish attempt against registry truth.
1075///
1076/// When `cargo publish` exits with an ambiguous class (e.g., upload succeeded
1077/// but index poll timed out, or stdout did not parse into a known failure
1078/// pattern), Shipper refuses to blind-retry. Instead it polls the registry
1079/// within a bounded window and resolves one of three outcomes.
1080///
1081/// See also: [`ErrorClass::Ambiguous`] and [`PackageState::Ambiguous`].
1082#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1083#[serde(tag = "outcome", rename_all = "snake_case")]
1084pub enum ReconciliationOutcome {
1085    /// Registry confirms the crate+version is published. Safe to mark as
1086    /// Published and advance; no further retry should occur for this crate.
1087    Published { attempts: u32, elapsed_ms: u64 },
1088    /// Registry confirms the crate+version is NOT visible after the bounded
1089    /// polling window. Caller may safely enter the normal Retryable path
1090    /// (retry `cargo publish`) knowing there is no side-effect to duplicate.
1091    NotPublished { attempts: u32, elapsed_ms: u64 },
1092    /// Polling itself failed (repeated registry-query errors, or exceeded
1093    /// the operator's patience budget without a clear signal). Caller MUST
1094    /// NOT retry cargo publish; mark the package [`PackageState::Ambiguous`]
1095    /// and halt for operator decision.
1096    StillUnknown {
1097        attempts: u32,
1098        elapsed_ms: u64,
1099        reason: String,
1100    },
1101}
1102
1103/// Persisted report of registry-truth reconciliation outcomes for a release run.
1104///
1105/// This artifact is written to `.shipper/reconciliation.json` when a publish or
1106/// resume run emits at least one [`EventType::PublishReconciled`] event. It is
1107/// derived from the authoritative event log so humans, CI, and agents can
1108/// inspect the ambiguity-resolution record without replaying every event.
1109#[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/// File or artifact referenced by a reconciliation report.
1120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1121pub struct ReconciliationEvidenceSource {
1122    pub kind: ReconciliationEvidenceKind,
1123    pub path: String,
1124}
1125
1126/// Kind of artifact referenced by [`ReconciliationEvidenceSource`].
1127#[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/// One package-level reconciliation outcome.
1136#[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/// Why reconciliation was attempted.
1149#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1150#[serde(rename_all = "snake_case")]
1151pub enum ReconciliationTrigger {
1152    CargoAmbiguousExit,
1153    ResumeAmbiguousState,
1154}
1155
1156/// Machine-readable operator action implied by a reconciliation outcome.
1157#[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/// Progress tracking for a single package in an execution.
1166///
1167/// This struct is persisted to disk during publishing to enable
1168/// resuming after interruption. It tracks the current state and
1169/// attempt count for each package.
1170///
1171/// # Example
1172///
1173/// ```ignore
1174/// use chrono::Utc;
1175/// use shipper::types::{PackageProgress, PackageState};
1176///
1177/// let progress = PackageProgress {
1178///     name: "my-crate".to_string(),
1179///     version: "1.2.3".to_string(),
1180///     attempts: 2,
1181///     state: PackageState::Pending,
1182///     last_updated_at: Utc::now(),
1183/// };
1184/// ```
1185#[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/// Durable state record for one `cargo publish` attempt.
1195///
1196/// `PackageProgress::attempts` is the fast counter used by resume. This
1197/// detail log preserves operator-facing facts needed by `status`, resume
1198/// explainers, and release evidence before the final receipt exists.
1199#[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/// The complete state of an in-progress publish operation.
1216///
1217/// This is the root structure persisted to disk during publishing.
1218/// It contains the plan ID, registry info, and progress for all packages.
1219///
1220/// # Example
1221///
1222/// ```ignore
1223/// use chrono::Utc;
1224/// use shipper::types::{ExecutionState, PackageProgress, Registry};
1225///
1226/// let state = ExecutionState {
1227///     state_version: "shipper.state.v1".to_string(),
1228///     plan_id: "abc123".to_string(),
1229///     registry: Registry::crates_io(),
1230///     created_at: Utc::now(),
1231///     updated_at: Utc::now(),
1232///     attempt_history: Vec::new(),
1233///     packages: std::collections::BTreeMap::new(),
1234/// };
1235///
1236/// // Save to disk for resumability
1237/// # Ok::<(), anyhow::Error>(())
1238/// ```
1239///
1240/// # Persistence
1241///
1242/// The execution state is saved to `state.json` in the state directory
1243/// after each package completes. This allows Shipper to resume
1244/// interrupted operations.
1245#[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    /// Per-attempt timeline written during publish, before final receipt
1253    /// construction. Defaults empty so older state files remain readable.
1254    #[serde(default)]
1255    pub attempt_history: Vec<AttemptDetail>,
1256    pub packages: BTreeMap<String, PackageProgress>,
1257}
1258
1259/// Receipt for a successfully published package.
1260///
1261/// This contains all evidence and metadata for a published crate,
1262/// useful for auditing and debugging. It's part of the final
1263/// [`Receipt`] document.
1264///
1265/// # Example
1266///
1267/// ```ignore
1268/// use chrono::Utc;
1269/// use shipper::types::{PackageReceipt, PackageState, PackageEvidence};
1270///
1271/// let receipt = PackageReceipt {
1272///     name: "my-crate".to_string(),
1273///     version: "1.2.3".to_string(),
1274///     attempts: 1,
1275///     state: PackageState::Published,
1276///     started_at: Utc::now(),
1277///     finished_at: Utc::now(),
1278///     duration_ms: 5000,
1279///     evidence: PackageEvidence {
1280///         attempts: vec![],
1281///         readiness_checks: vec![],
1282///     },
1283/// ///     compromised_at: None,
1284///     compromised_by: None,
1285///     superseded_by: None,
1286/// };
1287/// ```
1288#[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    // ── Remediate pillar (#98) — compromised-release tracking ──
1300    // All three fields are additive Options; existing receipts read back
1301    // cleanly without migration. Shipper populates them via `shipper yank`
1302    // / `shipper plan-yank --mark-compromised` (PR 2) and
1303    // `shipper fix-forward` (PR 3). Tooling can read them to construct
1304    // containment and fix-forward plans.
1305    /// When this specific package+version was marked compromised. `None`
1306    /// if the package is healthy (the default for every published receipt).
1307    #[serde(default, skip_serializing_if = "Option::is_none")]
1308    pub compromised_at: Option<DateTime<Utc>>,
1309    /// Operator-supplied reason / attribution for the compromise marker.
1310    /// Often a CVE ID, an incident ticket, or a short free-form tag.
1311    /// Example: `"CVE-2026-0001: token leak via debug impl"`.
1312    #[serde(default, skip_serializing_if = "Option::is_none")]
1313    pub compromised_by: Option<String>,
1314    /// If a fix-forward release superseded this version, the successor
1315    /// version string (e.g., `"0.3.1"`). Populated by `shipper fix-forward`
1316    /// (PR 3); `None` before that PR lands OR when no fix release exists.
1317    #[serde(default, skip_serializing_if = "Option::is_none")]
1318    pub superseded_by: Option<String>,
1319}
1320
1321/// Evidence collected during package publishing.
1322///
1323/// This includes detailed information about each publish attempt and
1324/// readiness verification checks. It's used for debugging and auditing.
1325///
1326/// # Contents
1327///
1328/// - `attempts`: Details of each publish attempt (command, output, timing)
1329/// - `readiness_checks`: Results of visibility verification checks
1330#[derive(Debug, Clone, Serialize, Deserialize)]
1331pub struct PackageEvidence {
1332    pub attempts: Vec<AttemptEvidence>,
1333    pub readiness_checks: Vec<ReadinessEvidence>,
1334}
1335
1336/// Evidence for a single publish attempt.
1337///
1338/// Contains the command that was run, its output, and timing information.
1339/// This is useful for debugging failed publishes.
1340///
1341/// # Example
1342///
1343/// ```ignore
1344/// use chrono::Utc;
1345/// use std::time::Duration;
1346/// use shipper::types::AttemptEvidence;
1347///
1348/// let evidence = AttemptEvidence {
1349///     attempt_number: 1,
1350///     command: "cargo publish --registry crates-io".to_string(),
1351///     exit_code: 0,
1352///     stdout_tail: "Uploading my-crate v1.2.3".to_string(),
1353///     stderr_tail: "".to_string(),
1354///     timestamp: Utc::now(),
1355///     duration: Duration::from_secs(5),
1356/// };
1357/// ```
1358#[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/// Evidence for a single readiness check.
1372///
1373/// Records the result of checking crate visibility after publishing.
1374///
1375/// # Example
1376///
1377/// ```ignore
1378/// use chrono::Utc;
1379/// use std::time::Duration;
1380/// use shipper::types::ReadinessEvidence;
1381///
1382/// let evidence = ReadinessEvidence {
1383///     attempt: 1,
1384///     visible: true,
1385///     timestamp: Utc::now(),
1386///     delay_before: Duration::from_secs(2),
1387/// };
1388/// ```
1389#[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/// Fingerprint of the environment where publishing occurred.
1400///
1401/// Captures version information about Shipper, Cargo, Rust, and the
1402/// operating system. This helps reproduce and debug issues.
1403///
1404/// # Example
1405///
1406/// ```ignore
1407/// use shipper::types::EnvironmentFingerprint;
1408///
1409/// let fp = EnvironmentFingerprint {
1410///     shipper_version: "0.2.0".to_string(),
1411///     cargo_version: Some("1.75.0".to_string()),
1412///     rust_version: Some("1.75.0".to_string()),
1413///     os: "linux".to_string(),
1414///     arch: "x86_64".to_string(),
1415/// };
1416/// ```
1417#[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/// Git context at the time of publishing.
1427///
1428/// Captures the current git state, including commit hash, branch,
1429/// tag, and whether the working directory is dirty.
1430///
1431/// # Example
1432///
1433/// ```ignore
1434/// use shipper::types::GitContext;
1435///
1436/// let ctx = GitContext {
1437///     commit: Some("abc123def".to_string()),
1438///     branch: Some("main".to_string()),
1439///     tag: Some("v1.0.0".to_string()),
1440///     dirty: Some(false),
1441/// };
1442/// ```
1443#[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    /// Create a new empty git context.
1453    pub fn new() -> Self {
1454        Self::default()
1455    }
1456
1457    /// Whether the context has commit information.
1458    pub fn has_commit(&self) -> bool {
1459        self.commit.is_some()
1460    }
1461
1462    /// Whether the working tree is dirty.
1463    ///
1464    /// When `dirty` is `None`, this defaults to `true` (treat unknown as dirty) to
1465    /// preserve the safe-by-default semantics of the original `shipper-git` crate.
1466    pub fn is_dirty(&self) -> bool {
1467        self.dirty.unwrap_or(true)
1468    }
1469
1470    /// Get a short commit hash (first 7 bytes).
1471    ///
1472    /// Returns `None` if the context has no commit. The original `shipper-git`
1473    /// implementation sliced by byte index assuming ASCII hex; we preserve the
1474    /// same behavior here (input shorter than 7 bytes is returned verbatim).
1475    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/// Complete receipt for a publish operation.
1483///
1484/// This is the final audit document containing all evidence and
1485/// metadata for a complete publish operation. It's saved to disk
1486/// after all packages are published.
1487///
1488/// # Example
1489///
1490/// ```ignore
1491/// use chrono::Utc;
1492/// use std::path::PathBuf;
1493/// use shipper::types::{Receipt, Registry, EnvironmentFingerprint};
1494///
1495/// let receipt = Receipt {
1496///     receipt_version: "shipper.receipt.v1".to_string(),
1497///     plan_id: "abc123".to_string(),
1498///     registry: Registry::crates_io(),
1499///     started_at: Utc::now(),
1500///     finished_at: Utc::now(),
1501///     packages: vec![],
1502///     event_log_path: PathBuf::from(".shipper/events.jsonl"),
1503///     git_context: None,
1504///     environment: EnvironmentFingerprint {
1505///         shipper_version: env!("CARGO_PKG_VERSION").to_string(),
1506///         cargo_version: None,
1507///         rust_version: None,
1508///         os: std::env::consts::OS.to_string(),
1509///         arch: std::env::consts::ARCH.to_string(),
1510///     },
1511/// };
1512/// # Ok::<(), anyhow::Error>(())
1513/// ```
1514///
1515/// # Storage
1516///
1517/// Receipts are stored in the state directory and can be used for:
1518/// - Auditing past publishes
1519/// - Debugging failed publishes
1520/// - Evidence for compliance requirements
1521#[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// Event types for evidence-first receipts
1538
1539/// An event in the publish event log.
1540///
1541/// Events are written to an append-only JSONL file during publishing.
1542/// This provides a detailed timeline for debugging and auditing.
1543///
1544/// # Example
1545///
1546/// ```ignore
1547/// use chrono::Utc;
1548/// use shipper::types::{PublishEvent, EventType};
1549///
1550/// let event = PublishEvent {
1551///     timestamp: Utc::now(),
1552///     event_type: EventType::ExecutionStarted,
1553///     package: "".to_string(),
1554/// };
1555/// ```
1556#[derive(Debug, Clone, Serialize, Deserialize)]
1557pub struct PublishEvent {
1558    pub timestamp: DateTime<Utc>,
1559    pub event_type: EventType,
1560    pub package: String, // "name@version"
1561}
1562
1563/// Types of events that can occur during publishing.
1564///
1565/// These events are logged to provide a complete audit trail of the
1566/// publish operation. Each variant carries relevant data.
1567///
1568/// # Categories
1569///
1570/// - **Lifecycle events**: Plan created, execution started/finished
1571/// - **Package events**: Started, attempted, output, published, failed, skipped
1572/// - **Readiness events**: Started, polled, completed, timeout
1573/// - **Preflight events**: Started, verified, ownership checked, completed
1574///
1575/// # Example
1576///
1577/// ```ignore
1578/// use shipper::types::{EventType, ExecutionResult, ErrorClass, ReadinessMethod, Finishability};
1579///
1580/// // Lifecycle events
1581/// let plan_created = EventType::PlanCreated {
1582///     plan_id: "abc123".to_string(),
1583///     package_count: 5,
1584/// };
1585/// let started = EventType::ExecutionStarted;
1586/// let finished = EventType::ExecutionFinished {
1587///     result: ExecutionResult::Success
1588/// };
1589///
1590/// // Package events
1591/// let pkg_started = EventType::PackageStarted {
1592///     name: "my-crate".to_string(),
1593///     version: "1.0.0".to_string(),
1594/// };
1595/// let pkg_failed = EventType::PackageFailed {
1596///     class: ErrorClass::Retryable,
1597///     message: "rate limited".to_string(),
1598/// };
1599///
1600/// // Readiness events
1601/// let ready = EventType::ReadinessStarted {
1602///     method: ReadinessMethod::Api,
1603/// };
1604///
1605/// // Preflight events
1606/// let preflight = EventType::PreflightComplete {
1607///     finishability: Finishability::Proven,
1608/// };
1609/// ```
1610#[derive(Debug, Clone, Serialize, Deserialize)]
1611#[serde(tag = "type", rename_all = "snake_case")]
1612pub enum EventType {
1613    // Lifecycle events
1614    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    // Package events
1627    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    // Operator-wait visibility. Emitted before Shipper deliberately sleeps so
1651    // status/watch consumers can tell the difference between "stuck" and
1652    // "waiting on a known release-control condition".
1653    PublishWaiting {
1654        reason: String,
1655        delay_ms: u64,
1656        until: DateTime<Utc>,
1657    },
1658
1659    // Registry pacing signal captured before retry scheduling. This is separate
1660    // from RetryBackoffStarted because it records why the registry profile path
1661    // was selected.
1662    RateLimitObserved {
1663        is_new_crate: bool,
1664        retry_after_ms: Option<u64>,
1665        message: String,
1666    },
1667
1668    // Reconciliation events (for `ErrorClass::Ambiguous` outcomes)
1669    PublishReconciling {
1670        method: ReadinessMethod,
1671    },
1672    PublishReconciled {
1673        outcome: ReconciliationOutcome,
1674    },
1675
1676    // End-of-run consistency check (events-as-truth invariant enforcement; #93)
1677    StateEventDriftDetected {
1678        drift: StateEventDrift,
1679    },
1680
1681    // Remediation / containment (#98). Emitted when `shipper yank` executes
1682    // a cargo yank against a specific crate+version. Reason is operator-
1683    // supplied (e.g., "CVE-2026-0001 disclosed"); plan_id ties the yank
1684    // to the remediation run that issued it.
1685    PackageYanked {
1686        crate_name: String,
1687        version: String,
1688        reason: String,
1689        exit_code: i32,
1690    },
1691
1692    // Rehearsal-registry proof (#97 PR 2). A rehearsal is phase-2 preflight:
1693    // publish every crate to a non-crates.io registry, verify visibility,
1694    // and (in a later PR) install-smoke it. The events below are emitted by
1695    // `shipper rehearse` so an auditor can replay the rehearsal from the
1696    // event log without re-running it.
1697    //
1698    // `plan_id` is NOT carried in the event payload — the enclosing
1699    // `PublishEvent.package` field already disambiguates per-package events,
1700    // and the end-of-run `RehearsalComplete` is sufficient for plan-level
1701    // correlation since events.jsonl is append-only scoped to one state dir.
1702    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 the rehearsal ran against. The hard gate (#97 PR 3)
1722        /// consults this: a subsequent `shipper publish` for the same
1723        /// plan_id can rely on this rehearsal; if the workspace changes
1724        /// (plan_id shifts), the rehearsal is stale and the gate fires.
1725        plan_id: String,
1726        summary: String,
1727    },
1728
1729    // #97 PR 4 — install/smoke check. Opt-in post-publish step that runs
1730    // `cargo install --registry <rehearsal> <crate>` to prove end-to-end
1731    // registry-index resolution — the scenario that killed the rc.1
1732    // first-publish. Events bracket the check so an auditor replaying
1733    // events.jsonl can see the proof (or failure) inline with publishes.
1734    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    // Retry visibility (#91) — emitted immediately before Shipper sleeps on a
1751    // retry backoff. `attempt` is the just-failed attempt number (1-indexed),
1752    // so the next attempt will be `attempt + 1` of `max_attempts`. `reason`
1753    // classifies why the retry is happening; `message` is the one-line
1754    // human-facing description (typically from cargo-failure classification).
1755    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    // Readiness events
1773    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    // Index readiness events
1793    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    // Preflight events
1809    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/// The result of a publish execution.
1827///
1828/// This summarizes the overall outcome of attempting to publish
1829/// all packages in a release plan.
1830///
1831/// # Example
1832///
1833/// ```ignore
1834/// use shipper::types::ExecutionResult;
1835///
1836/// let success = ExecutionResult::Success;
1837/// let partial = ExecutionResult::PartialFailure;
1838/// let complete = ExecutionResult::CompleteFailure;
1839/// ```
1840///
1841/// # Meaning
1842///
1843/// - `Success`: All packages published successfully
1844/// - `PartialFailure`: Some packages failed but others succeeded
1845/// - `CompleteFailure`: All packages failed (or no packages to publish)
1846#[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/// Authentication method used for publishing.
1855///
1856/// Shipper supports multiple authentication mechanisms, and this
1857/// enum tracks which one was used for a particular publish.
1858///
1859/// # Example
1860///
1861/// ```ignore
1862/// use shipper::types::AuthType;
1863///
1864/// let token = AuthType::Token;
1865/// let trusted = AuthType::TrustedPublishing;
1866/// let unknown = AuthType::Unknown;
1867/// ```
1868///
1869/// # Authentication Methods
1870///
1871/// - `Token`: Traditional Cargo token (CARGO_REGISTRY_TOKEN)
1872/// - `TrustedPublishing`: GitHub OIDC token from CI/CD
1873/// - `Unknown`: Could not determine the auth method
1874#[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/// Release-run authentication evidence observed by Shipper.
1883///
1884/// This record deliberately captures only non-secret runtime facts. In
1885/// particular, `Cargo` receives a `CARGO_REGISTRY_TOKEN` for both a
1886/// long-lived token fallback and a token minted by a Trusted Publishing
1887/// workflow action, so Shipper reports the observed auth context without
1888/// claiming token provenance it cannot prove from environment state alone.
1889#[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/// Non-secret authentication mode observed for a publish or resume run.
1900#[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/// Whether a preflight-verified publish is guaranteed to succeed.
1912///
1913/// This is determined during preflight checks based on various
1914/// factors like whether the crate is new, if ownership is verified, etc.
1915///
1916/// # Example
1917///
1918/// ```ignore
1919/// use shipper::types::Finishability;
1920///
1921/// let proven = Finishability::Proven;       // Should succeed
1922/// let not_proven = Finishability::NotProven; // Might succeed
1923/// let failed = Finishability::Failed;        // Won't succeed
1924/// ```
1925///
1926/// # Determination
1927///
1928/// - `Proven`: All preflight checks passed strongly (new crate, owned, etc.)
1929/// - `NotProven`: Some uncertainty (already published version, etc.)
1930/// - `Failed`: Preflight checks failed (auth issues, dry-run failed, etc.)
1931#[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/// Report from preflight verification checks.
1940///
1941/// Before publishing, Shipper runs various preflight checks to catch
1942/// issues early. This report summarizes the findings.
1943///
1944/// # Example
1945///
1946/// ```ignore
1947/// use chrono::Utc;
1948/// use shipper::types::{PreflightReport, Finishability, PreflightPackage, Registry};
1949///
1950/// let report = PreflightReport {
1951///     plan_id: "abc123".to_string(),
1952///     token_detected: true,
1953///     finishability: Finishability::Proven,
1954///     packages: vec![
1955///         PreflightPackage {
1956///             name: "my-crate".to_string(),
1957///             version: "1.0.0".to_string(),
1958///             already_published: false,
1959///             is_new_crate: true,
1960///             auth_type: Some(shipper::types::AuthType::Token),
1961///             ownership_verified: true,
1962///             dry_run_passed: true,
1963///         },
1964///     ],
1965///     timestamp: Utc::now(),
1966/// };
1967/// # Ok::<(), anyhow::Error>(())
1968/// ```
1969///
1970/// # Usage
1971///
1972/// The preflight report is used to:
1973/// - Determine if publishing should proceed
1974/// - Provide transparency about potential issues
1975/// - Support debugging if publish fails
1976#[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    /// Minimum registry pacing estimate derived from package regimes.
1984    #[serde(default, skip_serializing_if = "Option::is_none")]
1985    pub estimated_publish_duration: Option<PreflightDurationEstimate>,
1986    /// Detailed output from workspace-level dry-run verification
1987    pub dry_run_output: Option<String>,
1988}
1989
1990/// Registry pacing estimate derived during preflight.
1991///
1992/// This is deliberately a lower-bound pacing estimate, not a full wall-clock
1993/// release prediction. It excludes build time, upload time, readiness polling,
1994/// network failures, human pauses, and retry jitter.
1995#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1996pub struct PreflightDurationEstimate {
1997    /// Registry profile that produced the estimate.
1998    pub registry_profile: String,
1999    /// Packages that appear to be first publishes for this registry.
2000    pub first_publish_count: usize,
2001    /// Packages that appear to be version updates for this registry.
2002    pub update_count: usize,
2003    /// Minimum registry-imposed pacing time expected before all publishes can
2004    /// be accepted.
2005    #[serde(
2006        deserialize_with = "deserialize_duration",
2007        serialize_with = "serialize_duration"
2008    )]
2009    pub minimum_registry_pacing: Duration,
2010    /// Human/agent-readable caveats for what this estimate excludes.
2011    pub notes: Vec<String>,
2012}
2013
2014/// Preflight status for a single package.
2015///
2016/// Contains the results of preflight checks for one crate in the
2017/// workspace.
2018///
2019/// # Example
2020///
2021/// ```ignore
2022/// use shipper::types::{PreflightPackage, AuthType};
2023///
2024/// let pkg = PreflightPackage {
2025///     name: "my-crate".to_string(),
2026///     version: "1.0.0".to_string(),
2027///     already_published: false,
2028///     is_new_crate: true,
2029///     auth_type: Some(AuthType::Token),
2030///     ownership_verified: true,
2031///     dry_run_passed: true,
2032///     dry_run_output: None,
2033/// };
2034/// ```
2035#[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    /// Detailed output from package-level dry-run verification
2045    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    // ===== PackageState transition tests =====
2165
2166    #[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        // Pending can transition to Uploaded
2173        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    // ===== PublishRegime / PlannedPackage backward compatibility (#106 PR 1) =====
2266
2267    /// A plan serialized before the `regime` field existed must still
2268    /// deserialize cleanly. The field is `Option`, `serde(default)`, and
2269    /// `skip_serializing_if = Option::is_none`, which together guarantee
2270    /// forward and backward compatibility with existing state.json and
2271    /// plan files.
2272    #[test]
2273    fn planned_package_backward_compat_no_regime_field() {
2274        // Simulate a plan written by an older version of shipper that
2275        // did not emit the `regime` field at all.
2276        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    /// When `regime` is `None`, serialization must omit the field
2291    /// entirely (not emit `"regime": null`). This preserves byte-for-byte
2292    /// compatibility of plan / state snapshots for readers that do not
2293    /// know about the field.
2294    #[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    /// When `regime` is `Some(...)`, it must round-trip cleanly and use
2310    /// the documented snake_case wire format.
2311    #[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    // ===== ReleasePlan determinism =====
2337
2338    #[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    // ===== Receipt serialization roundtrips =====
2492
2493    #[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    // ===== PublishPolicy / VerifyMode =====
2642
2643    #[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    // ===== PackageProgress =====
2698
2699    #[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(); // 2100-01-01
2717        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    // ===== RuntimeOptions =====
2761
2762    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    // ===== Registry =====
2861
2862    #[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(&reg).unwrap();
2906        assert!(!json.contains("index_base"));
2907    }
2908
2909    // ===== ErrorClass =====
2910
2911    #[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    // ===== ExecutionResult =====
2933
2934    #[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    // ===== AuthType =====
2953
2954    #[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    // ===== Finishability =====
2969
2970    #[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    // ===== ParallelConfig =====
2985
2986    #[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    // ===== PackageState serde =====
3009
3010    #[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    // ===== EventType serde =====
3043
3044    #[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    // ===== GitContext =====
3072
3073    #[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    // ===== EnvironmentFingerprint =====
3104
3105    #[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    // ===== AttemptEvidence / ReadinessEvidence =====
3122
3123    #[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    // ===== ReadinessConfig serde =====
3151
3152    #[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    // ===== Debug output snapshot tests =====
3182
3183    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        // --- ReleasePlan variations ---
3426
3427        #[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        // --- Receipt variations ---
3525
3526        #[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        // --- ExecutionState variations ---
3765
3766        #[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        // --- Config snapshots ---
3886
3887        #[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        // --- Ancillary type snapshots ---
3926
3927        #[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    // Property-based tests using proptest
4087
4088    #[cfg(test)]
4089    mod proptests {
4090        use super::*;
4091        use proptest::prelude::*;
4092
4093        proptest! {
4094            // Preflight report serialization/deserialization roundtrip
4095            #[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                // Serialize and deserialize
4132                let json = serde_json::to_string(&report).unwrap();
4133                let parsed: PreflightReport = serde_json::from_str(&json).unwrap();
4134
4135                // Verify roundtrip
4136                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            // Preflight package serialization roundtrip
4154            #[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                // Serialize and deserialize
4184                let json = serde_json::to_string(&pkg).unwrap();
4185                let parsed: PreflightPackage = serde_json::from_str(&json).unwrap();
4186
4187                // Verify roundtrip
4188                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            // AuthType serialization roundtrip
4199            #[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            // Finishability serialization roundtrip
4214            #[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            // EnvironmentFingerprint serialization roundtrip
4229            #[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                // Serialize and deserialize
4246                let json = serde_json::to_string(&fingerprint).unwrap();
4247                let parsed: EnvironmentFingerprint = serde_json::from_str(&json).unwrap();
4248
4249                // Verify roundtrip
4250                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            // GitContext serialization roundtrip
4258            #[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                // Serialize and deserialize
4273                let json = serde_json::to_string(&git_context).unwrap();
4274                let parsed: GitContext = serde_json::from_str(&json).unwrap();
4275
4276                // Verify roundtrip
4277                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            // Registry serialization roundtrip
4284            #[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                // Serialize and deserialize
4297                let json = serde_json::to_string(&registry).unwrap();
4298                let parsed: Registry = serde_json::from_str(&json).unwrap();
4299
4300                // Verify roundtrip
4301                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            // ReadinessConfig serialization roundtrip
4307            #[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                // Serialize and deserialize
4337                let json = serde_json::to_string(&config).unwrap();
4338                let parsed: ReadinessConfig = serde_json::from_str(&json).unwrap();
4339
4340                // Verify roundtrip
4341                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            // Index path calculation is deterministic
4353            #[test]
4354            fn index_path_deterministic(crate_name in "[a-z0-9-]+") {
4355                // Calculate the index path twice and verify it's the same
4356                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            // Index path follows Cargo's sparse index scheme
4362            #[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            // Schema version parsing is deterministic
4386            #[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        // --- PackageState roundtrip for all variants ---
4402        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            // --- ErrorClass roundtrip ---
4460            #[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            // --- ExecutionResult roundtrip ---
4473            #[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            // --- PublishPolicy roundtrip ---
4486            #[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            // --- VerifyMode roundtrip ---
4499            #[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            // --- ReadinessMethod roundtrip ---
4512            #[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            // --- PlannedPackage roundtrip ---
4525            #[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            // --- PublishLevel roundtrip ---
4544            #[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            // --- ReleasePlan roundtrip ---
4565            #[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            // --- PackageProgress roundtrip ---
4602            #[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            // --- ExecutionState roundtrip ---
4631            #[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            // --- ParallelConfig roundtrip ---
4663            #[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            // --- AttemptEvidence roundtrip ---
4682            #[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            // --- ReadinessEvidence roundtrip ---
4705            #[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            // --- PackageEvidence roundtrip ---
4725            #[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            // --- PackageReceipt roundtrip ---
4748            #[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            // --- Receipt roundtrip ---
4782            #[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            // --- PublishEvent roundtrip ---
4838            #[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            // --- EventType all variants roundtrip ---
4869            #[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        // ===== PackageState transition validity =====
4901
4902        /// Valid transitions from each PackageState
4903        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, // retry resets to Pending
4927                ],
4928                // Terminal states
4929                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            /// Failed states can always retry back to Pending
4967            #[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            /// Pending always leads to Uploaded, Failed, or Skipped
4984            #[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        // ===== Plan determinism =====
4995
4996        proptest! {
4997            /// Same inputs produce the same plan_id (SHA256 determinism)
4998            #[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                // Generate a deterministic "plan_id" from the same inputs
5007                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            /// Different package lists produce different plan_ids
5032            #[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        // ===== Receipt generation from ExecutionState =====
5069
5070        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                // Receipt should be serializable
5187                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        // ===== Version string parsing roundtrips =====
5194
5195        /// Parse a semver-like version string and reconstruct it
5196        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            /// Parsing a version string and reformatting yields the original
5221            #[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            /// Version with prerelease tag roundtrips
5238            #[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            /// Version fields stored in PlannedPackage survive JSON roundtrip
5256            #[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        // ===== ReleasePlan roundtrip with varied registry =====
5277
5278        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        // ===== RuntimeOptions duration validation =====
5327
5328        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                // All duration fields must be positive
5388                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        // ===== Receipt with mixed package states roundtrip =====
5402
5403        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        // ===== ExecutionState with varied package states roundtrip =====
5485
5486        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        // ===== PackageState transition monotonicity =====
5539
5540        /// Ordinal value for PackageState in the forward progress direction.
5541        /// Higher values represent more progress toward completion.
5542        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,   // terminal
5548                PackageState::Failed { .. } => 1,    // same level as Uploaded
5549                PackageState::Ambiguous { .. } => 2, // terminal
5550            }
5551        }
5552
5553        proptest! {
5554            /// Forward transitions (non-retry) never decrease the ordinal
5555            #[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                    // The only allowed "backwards" transition is Failed -> Pending (retry)
5574                    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            /// The happy path Pending -> Uploaded -> Published is strictly increasing
5589            #[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            /// Terminal states have no forward transitions (can't go backwards)
5606            #[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        // ===== Error/type Debug formatting never panics =====
5623
5624        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        // ===== Arbitrary PackageState sequences =====
5726
5727        proptest! {
5728            /// Random sequences of PackageState transitions follow valid_next_states
5729            #[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(&current);
5734                    if nexts.is_empty() {
5735                        break; // terminal state
5736                    }
5737                    // Always pick the first valid transition for determinism
5738                    current = nexts[0].clone();
5739                }
5740                // We should end in a well-known state
5741                let debug = format!("{:?}", current);
5742                assert!(!debug.is_empty());
5743            }
5744
5745            /// The happy path Pending→Uploaded→Published always completes in 2 transitions
5746            #[test]
5747            fn happy_path_always_reaches_published(_seed in 0u64..100) {
5748                let mut state = PackageState::Pending;
5749                // Pending -> Uploaded
5750                let nexts = valid_next_states(&state);
5751                assert!(nexts.iter().any(|s| matches!(s, PackageState::Uploaded)));
5752                state = PackageState::Uploaded;
5753                // Uploaded -> Published
5754                let nexts = valid_next_states(&state);
5755                assert!(nexts.iter().any(|s| matches!(s, PackageState::Published)));
5756                state = PackageState::Published;
5757                // Published is terminal
5758                assert!(valid_next_states(&state).is_empty());
5759            }
5760
5761            /// Full receipt with evidence roundtrips preserve attempt counts
5762            #[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        // Helper functions for property-based tests
5803
5804        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        // --- Additional invariant proptests ---
5825
5826        proptest! {
5827            /// ReleasePlan JSON roundtrip with deps: serialize then deserialize preserves all fields.
5828            #[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            /// Plan ordering: group_by_levels always places dependencies before dependents.
5872            #[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                // Linear dependency chain: crate-1 depends on crate-0, crate-2 on crate-1, etc.
5886                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                // Build a map of package name -> level number
5906                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                // Every dependency must be at a strictly earlier level
5914                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            /// Receipt completeness: every package in the plan appears in the receipt.
5929            #[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                // Every planned package appears in the receipt
5983                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                // Roundtrip the receipt
5990                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    // ===== StateEventDrift::is_consistent =====
5998
5999    #[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    // ===== GitContext methods =====
6047
6048    #[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        // Safe-by-default semantics: unknown dirtiness is treated as dirty
6075        // so we never claim a clean tree we cannot confirm.
6076        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    // ===== group_packages_by_levels generic edge cases =====
6138
6139    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        // "a" depends on "external", which is not in the plan - should be ignored
6163        // and "a" should sit at level 0.
6164        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        // Cycle: a -> b, b -> a. Standard Kahn would stall; the function falls
6180        // back to deterministic singleton progress so every package still appears.
6181        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        // Diamond: top -> mid_l, top -> mid_r, mid_l -> bottom, mid_r -> bottom.
6199        // Expected order: top at level 0, mid_l + mid_r at level 1, bottom at level 2.
6200        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        // No deps: all 3 at level 0, order should match input order.
6223        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    // ===== PublishRegime::is_new_crate =====
6239
6240    #[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}