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/// A package in the publish plan.
606///
607/// This represents a single crate that will be published as part of
608/// a [`ReleasePlan`]. It contains the minimal information needed to
609/// identify and publish the crate.
610///
611/// # Example
612///
613/// ```ignore
614/// use std::path::PathBuf;
615/// use shipper::types::PlannedPackage;
616///
617/// let pkg = PlannedPackage {
618///     name: "my-crate".to_string(),
619///     version: "1.2.3".to_string(),
620///     manifest_path: PathBuf::from("crates/my-crate/Cargo.toml"),
621/// };
622/// ```
623#[derive(Debug, Clone, Serialize, Deserialize)]
624pub struct PlannedPackage {
625    pub name: String,
626    pub version: String,
627    pub manifest_path: PathBuf,
628}
629
630/// A group of packages that can be published in parallel.
631///
632/// Packages at the same level have no dependencies on each other within
633/// the workspace, meaning they can be published concurrently without
634/// violating dependency order.
635///
636/// # Example
637///
638/// ```ignore
639/// use std::path::PathBuf;
640/// use shipper::types::{PublishLevel, PlannedPackage};
641///
642/// let level = PublishLevel {
643///     level: 0,
644///     packages: vec![
645///         PlannedPackage {
646///             name: "utils".to_string(),
647///             version: "1.0.0".to_string(),
648///             manifest_path: PathBuf::from("crates/utils/Cargo.toml"),
649///         },
650///         PlannedPackage {
651///             name: "common".to_string(),
652///             version: "2.0.0".to_string(),
653///             manifest_path: PathBuf::from("crates/common/Cargo.toml"),
654///         },
655///     ],
656/// };
657/// ```
658///
659/// # Level Numbering
660///
661/// Level 0 contains packages with no workspace dependencies.
662/// Level N contains packages that depend only on packages in levels 0..N.
663#[derive(Debug, Clone, Serialize, Deserialize)]
664pub struct PublishLevel {
665    /// The level number (0 = no dependencies, 1 = depends on level 0, etc.)
666    pub level: usize,
667    /// Packages that can be published in parallel at this level
668    pub packages: Vec<PlannedPackage>,
669}
670
671/// A deterministic, identified plan for publishing a workspace.
672///
673/// The release plan is generated by `shipper::plan::build_plan` and contains
674/// all information needed to execute the publish operation. It includes:
675/// - A unique plan ID (SHA256 hash of relevant content)
676/// - Ordered list of packages to publish
677/// - Dependency information for parallel publishing
678/// - Registry configuration
679///
680/// # Example
681///
682/// ```ignore
683/// let plan = plan::build_plan(&spec)?;
684/// println!("Publishing {} packages:", plan.plan.packages.len());
685/// for pkg in &plan.plan.packages {
686///     println!("  {} {}", pkg.name, pkg.version);
687/// }
688/// ```
689///
690/// # Resumability
691///
692/// The plan ID is stable across runs if the workspace metadata doesn't
693/// change. This allows Shipper to detect when a resumed operation is
694/// using the same plan.
695#[derive(Debug, Clone, Serialize, Deserialize)]
696pub struct ReleasePlan {
697    pub plan_version: String,
698    pub plan_id: String,
699    pub created_at: DateTime<Utc>,
700    pub registry: Registry,
701    /// Packages in publish order (dependencies first).
702    pub packages: Vec<PlannedPackage>,
703    /// Map of package name -> set of package names it depends on (within the plan).
704    /// This is used for level-based parallel publishing.
705    #[serde(default)]
706    pub dependencies: BTreeMap<String, Vec<String>>,
707}
708
709/// A workspace package that was excluded from the publish plan.
710///
711/// Packages are skipped when their `publish` field in `Cargo.toml`
712/// is `false` or does not include the target registry.
713#[derive(Debug, Clone, Serialize, Deserialize)]
714pub struct SkippedPackage {
715    /// Crate name as declared in `Cargo.toml`.
716    pub name: String,
717    /// Crate version string.
718    pub version: String,
719    /// Human-readable reason the package was excluded.
720    pub reason: String,
721}
722
723/// The output of `shipper::plan::build_plan`: a publish plan plus context.
724///
725/// Contains the workspace root path, the deterministic [`ReleasePlan`],
726/// and a list of packages that were skipped (with reasons).
727#[derive(Debug, Clone)]
728pub struct PlannedWorkspace {
729    /// Absolute path to the workspace root directory.
730    pub workspace_root: PathBuf,
731    /// The deterministic, SHA256-identified publish plan.
732    pub plan: ReleasePlan,
733    /// Packages that were excluded from the plan.
734    pub skipped: Vec<SkippedPackage>,
735}
736
737impl ReleasePlan {
738    /// Group packages by dependency level for parallel publishing.
739    ///
740    /// Packages at the same level have no dependencies on each other and can
741    /// be published concurrently.
742    pub fn group_by_levels(&self) -> Vec<PublishLevel> {
743        group_packages_by_levels(&self.packages, |pkg| pkg.name.as_str(), &self.dependencies)
744            .into_iter()
745            .map(|l| PublishLevel {
746                level: l.level,
747                packages: l.packages,
748            })
749            .collect()
750    }
751}
752
753/// A group of packages that can be processed in parallel.
754///
755/// Generic counterpart of [`PublishLevel`] used by [`group_packages_by_levels`].
756#[derive(Debug, Clone, PartialEq, Eq)]
757pub struct GenericPublishLevel<T> {
758    /// Zero-based level number.
759    pub level: usize,
760    /// Packages assigned to this level.
761    pub packages: Vec<T>,
762}
763
764/// Group packages into dependency levels.
765///
766/// `ordered_packages` should be deterministic. Dependencies that are not part
767/// of `ordered_packages` are ignored. If cyclic/inconsistent dependencies are
768/// encountered, the function falls back to deterministic singleton progress so
769/// every package still appears exactly once.
770pub fn group_packages_by_levels<T, F>(
771    ordered_packages: &[T],
772    package_name: F,
773    dependencies: &BTreeMap<String, Vec<String>>,
774) -> Vec<GenericPublishLevel<T>>
775where
776    T: Clone,
777    F: Fn(&T) -> &str,
778{
779    use std::collections::BTreeSet;
780
781    let mut ordered_names: Vec<String> = Vec::new();
782    let mut package_lookup: BTreeMap<String, T> = BTreeMap::new();
783
784    for package in ordered_packages {
785        let name = package_name(package).to_string();
786        if package_lookup.contains_key(&name) {
787            continue;
788        }
789        ordered_names.push(name.clone());
790        package_lookup.insert(name, package.clone());
791    }
792
793    if ordered_names.is_empty() {
794        return Vec::new();
795    }
796
797    let package_set: BTreeSet<String> = ordered_names.iter().cloned().collect();
798    let mut indegree: BTreeMap<String, usize> = package_set
799        .iter()
800        .map(|name| (name.clone(), 0usize))
801        .collect();
802    let mut dependents: BTreeMap<String, Vec<String>> = BTreeMap::new();
803
804    for name in &ordered_names {
805        if let Some(deps) = dependencies.get(name) {
806            for dep in deps {
807                if !package_set.contains(dep) {
808                    continue;
809                }
810                if let Some(degree) = indegree.get_mut(name) {
811                    *degree += 1;
812                }
813                dependents
814                    .entry(dep.clone())
815                    .or_default()
816                    .push(name.clone());
817            }
818        }
819    }
820
821    let mut remaining: BTreeSet<String> = package_set;
822    let mut levels: Vec<GenericPublishLevel<T>> = Vec::new();
823
824    while !remaining.is_empty() {
825        let mut current: Vec<String> = ordered_names
826            .iter()
827            .filter(|name| {
828                remaining.contains(*name) && indegree.get(*name).copied().unwrap_or(0) == 0
829            })
830            .cloned()
831            .collect();
832
833        if current.is_empty() {
834            if let Some(name) = ordered_names
835                .iter()
836                .find(|name| remaining.contains(*name))
837                .cloned()
838            {
839                current.push(name);
840            } else {
841                break;
842            }
843        }
844
845        let packages = current
846            .iter()
847            .filter_map(|name| package_lookup.get(name).cloned())
848            .collect();
849
850        levels.push(GenericPublishLevel {
851            level: levels.len(),
852            packages,
853        });
854
855        for name in current {
856            remaining.remove(&name);
857            if let Some(children) = dependents.get(&name) {
858                for child in children {
859                    if !remaining.contains(child) {
860                        continue;
861                    }
862                    if let Some(degree) = indegree.get_mut(child) {
863                        *degree = degree.saturating_sub(1);
864                    }
865                }
866            }
867        }
868    }
869
870    levels
871}
872
873/// The state of a package in the publish pipeline.
874///
875/// Each package in a release plan progresses through these states during
876/// publishing. The state is persisted to enable resumability after
877/// interruption.
878///
879/// # State Transitions
880///
881/// ```text
882/// Pending → Uploaded → Published
883///              ↓
884///            Failed
885///              ↓
886///           Pending (retry)
887/// ```
888///
889/// # Example
890///
891/// ```ignore
892/// use shipper::types::PackageState;
893///
894/// // Initial state
895/// let pending = PackageState::Pending;
896///
897/// // After successful upload
898/// let uploaded = PackageState::Uploaded;
899///
900/// // After visibility verification
901/// let published = PackageState::Published;
902///
903/// // When skipped (e.g., already published)
904/// let skipped = PackageState::Skipped {
905///     reason: "version already exists".to_string()
906/// };
907///
908/// // On failure
909/// let failed = PackageState::Failed {
910///     class: shipper::types::ErrorClass::Retryable,
911///     message: "network timeout".to_string(),
912/// };
913/// ```
914#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
915#[serde(tag = "state", rename_all = "snake_case")]
916pub enum PackageState {
917    Pending,
918    Uploaded,
919    Published,
920    Skipped { reason: String },
921    Failed { class: ErrorClass, message: String },
922    Ambiguous { message: String },
923}
924
925/// Classification of errors encountered during publishing.
926///
927/// Error classification determines whether a publish attempt should be
928/// retried. Some errors are permanent (retrying won't help) while others
929/// are transient (likely to succeed on retry).
930///
931/// # Example
932///
933/// ```ignore
934/// use shipper::types::ErrorClass;
935///
936/// // Network issues, rate limiting - worth retrying
937/// let retryable = ErrorClass::Retryable;
938///
939/// // Invalid credentials, version conflict - won't succeed on retry
940/// let permanent = ErrorClass::Permanent;
941///
942/// // Unclear - may or may not be retryable
943/// let ambiguous = ErrorClass::Ambiguous;
944/// ```
945///
946/// # Classification is a hint, not truth
947///
948/// This enum is produced by parsing cargo's stdout/stderr — a human-facing
949/// log that is explicitly not a stable machine protocol. Pattern-matching on
950/// cargo text gives Shipper a fast first-pass signal, but **it must never be
951/// treated as the final word** on what actually happened:
952///
953/// - [`ErrorClass::Permanent`] and [`ErrorClass::Retryable`] are still
954///   hints — they drive retry scheduling, but every retry attempt re-checks
955///   the registry before and after the next `cargo publish`.
956/// - [`ErrorClass::Ambiguous`] is the dangerous case. Cargo's publish flow
957///   uploads to the registry first and polls the index afterwards; the poll
958///   can time out without affecting the upload. So a non-zero cargo exit
959///   can coexist with a successful upload. Ambiguous outcomes MUST be
960///   reconciled against registry truth before any further action — never
961///   blind-retry. See [`ReconciliationOutcome`] and the reconciliation flow
962///   in `shipper::engine::parallel::reconcile`.
963///
964/// The authoritative classification for `Ambiguous` outcomes comes from
965/// **querying the registry** (sparse index + API) after the fact. Cargo
966/// stderr is a signal; the registry is the source of truth.
967///
968/// # Classification Heuristics (hints)
969///
970/// Shipper uses various heuristics to classify errors:
971/// - HTTP 429 (Too Many Requests) → Retryable
972/// - HTTP 401/403 (Auth errors) → Permanent
973/// - HTTP 409 (Version conflict) → Permanent
974/// - Network timeouts → Retryable
975/// - Unknown errors → Ambiguous (triggers registry reconciliation)
976#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
977#[serde(rename_all = "snake_case")]
978pub enum ErrorClass {
979    Retryable,
980    Permanent,
981    Ambiguous,
982}
983
984/// Report of drift between the authoritative event log and the projected state.
985///
986/// Per [`docs/INVARIANTS.md`](https://github.com/EffortlessMetrics/shipper/blob/main/docs/INVARIANTS.md),
987/// `events.jsonl` is the authoritative source of truth and `state.json` is a
988/// projection derived from it. They should always agree about which packages
989/// were published. A drift is a bug — this struct captures which side claims
990/// what, so the end-of-run consistency check can surface it loudly rather
991/// than silently corrupting resume.
992///
993/// A drift with both lists empty means the projection matches the truth and
994/// [`StateEventDrift::is_consistent`] returns `true`.
995///
996/// Labels use the `name@version` format consistent with the rest of the
997/// event stream (e.g., `shipper-types@0.3.0-rc.1`).
998#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
999pub struct StateEventDrift {
1000    /// Packages that have a `PackagePublished` event in `events.jsonl` but
1001    /// are NOT marked `PackageState::Published` in `state.json`.
1002    ///
1003    /// This is the dangerous direction: resume would re-attempt publishing
1004    /// packages that already uploaded successfully.
1005    pub in_events_only: Vec<String>,
1006    /// Packages that are marked `PackageState::Published` in `state.json`
1007    /// but have NO `PackagePublished` event in `events.jsonl`.
1008    ///
1009    /// This shouldn't happen if events are appended before state is
1010    /// written; if it does, something bypassed the event log.
1011    pub in_state_only: Vec<String>,
1012}
1013
1014impl StateEventDrift {
1015    /// Returns `true` iff no drift was detected (both sides agree).
1016    pub fn is_consistent(&self) -> bool {
1017        self.in_events_only.is_empty() && self.in_state_only.is_empty()
1018    }
1019}
1020
1021/// Outcome of reconciling an ambiguous publish attempt against registry truth.
1022///
1023/// When `cargo publish` exits with an ambiguous class (e.g., upload succeeded
1024/// but index poll timed out, or stdout did not parse into a known failure
1025/// pattern), Shipper refuses to blind-retry. Instead it polls the registry
1026/// within a bounded window and resolves one of three outcomes.
1027///
1028/// See also: [`ErrorClass::Ambiguous`] and [`PackageState::Ambiguous`].
1029#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1030#[serde(tag = "outcome", rename_all = "snake_case")]
1031pub enum ReconciliationOutcome {
1032    /// Registry confirms the crate+version is published. Safe to mark as
1033    /// Published and advance; no further retry should occur for this crate.
1034    Published { attempts: u32, elapsed_ms: u64 },
1035    /// Registry confirms the crate+version is NOT visible after the bounded
1036    /// polling window. Caller may safely enter the normal Retryable path
1037    /// (retry `cargo publish`) knowing there is no side-effect to duplicate.
1038    NotPublished { attempts: u32, elapsed_ms: u64 },
1039    /// Polling itself failed (repeated registry-query errors, or exceeded
1040    /// the operator's patience budget without a clear signal). Caller MUST
1041    /// NOT retry cargo publish; mark the package [`PackageState::Ambiguous`]
1042    /// and halt for operator decision.
1043    StillUnknown {
1044        attempts: u32,
1045        elapsed_ms: u64,
1046        reason: String,
1047    },
1048}
1049
1050/// Progress tracking for a single package in an execution.
1051///
1052/// This struct is persisted to disk during publishing to enable
1053/// resuming after interruption. It tracks the current state and
1054/// attempt count for each package.
1055///
1056/// # Example
1057///
1058/// ```ignore
1059/// use chrono::Utc;
1060/// use shipper::types::{PackageProgress, PackageState};
1061///
1062/// let progress = PackageProgress {
1063///     name: "my-crate".to_string(),
1064///     version: "1.2.3".to_string(),
1065///     attempts: 2,
1066///     state: PackageState::Pending,
1067///     last_updated_at: Utc::now(),
1068/// };
1069/// ```
1070#[derive(Debug, Clone, Serialize, Deserialize)]
1071pub struct PackageProgress {
1072    pub name: String,
1073    pub version: String,
1074    pub attempts: u32,
1075    pub state: PackageState,
1076    pub last_updated_at: DateTime<Utc>,
1077}
1078
1079/// The complete state of an in-progress publish operation.
1080///
1081/// This is the root structure persisted to disk during publishing.
1082/// It contains the plan ID, registry info, and progress for all packages.
1083///
1084/// # Example
1085///
1086/// ```ignore
1087/// use chrono::Utc;
1088/// use shipper::types::{ExecutionState, PackageProgress, Registry};
1089///
1090/// let state = ExecutionState {
1091///     state_version: "shipper.state.v1".to_string(),
1092///     plan_id: "abc123".to_string(),
1093///     registry: Registry::crates_io(),
1094///     created_at: Utc::now(),
1095///     updated_at: Utc::now(),
1096///     packages: std::collections::BTreeMap::new(),
1097/// };
1098///
1099/// // Save to disk for resumability
1100/// # Ok::<(), anyhow::Error>(())
1101/// ```
1102///
1103/// # Persistence
1104///
1105/// The execution state is saved to `state.json` in the state directory
1106/// after each package completes. This allows Shipper to resume
1107/// interrupted operations.
1108#[derive(Debug, Clone, Serialize, Deserialize)]
1109pub struct ExecutionState {
1110    pub state_version: String,
1111    pub plan_id: String,
1112    pub registry: Registry,
1113    pub created_at: DateTime<Utc>,
1114    pub updated_at: DateTime<Utc>,
1115    pub packages: BTreeMap<String, PackageProgress>,
1116}
1117
1118/// Receipt for a successfully published package.
1119///
1120/// This contains all evidence and metadata for a published crate,
1121/// useful for auditing and debugging. It's part of the final
1122/// [`Receipt`] document.
1123///
1124/// # Example
1125///
1126/// ```ignore
1127/// use chrono::Utc;
1128/// use shipper::types::{PackageReceipt, PackageState, PackageEvidence};
1129///
1130/// let receipt = PackageReceipt {
1131///     name: "my-crate".to_string(),
1132///     version: "1.2.3".to_string(),
1133///     attempts: 1,
1134///     state: PackageState::Published,
1135///     started_at: Utc::now(),
1136///     finished_at: Utc::now(),
1137///     duration_ms: 5000,
1138///     evidence: PackageEvidence {
1139///         attempts: vec![],
1140///         readiness_checks: vec![],
1141///     },
1142/// ///     compromised_at: None,
1143///     compromised_by: None,
1144///     superseded_by: None,
1145/// };
1146/// ```
1147#[derive(Debug, Clone, Serialize, Deserialize)]
1148pub struct PackageReceipt {
1149    pub name: String,
1150    pub version: String,
1151    pub attempts: u32,
1152    pub state: PackageState,
1153    pub started_at: DateTime<Utc>,
1154    pub finished_at: DateTime<Utc>,
1155    pub duration_ms: u128,
1156    pub evidence: PackageEvidence,
1157
1158    // ── Remediate pillar (#98) — compromised-release tracking ──
1159    // All three fields are additive Options; existing receipts read back
1160    // cleanly without migration. Shipper populates them via `shipper yank`
1161    // / `shipper plan-yank --mark-compromised` (PR 2) and
1162    // `shipper fix-forward` (PR 3). Tooling can read them to construct
1163    // containment and fix-forward plans.
1164    /// When this specific package+version was marked compromised. `None`
1165    /// if the package is healthy (the default for every published receipt).
1166    #[serde(default, skip_serializing_if = "Option::is_none")]
1167    pub compromised_at: Option<DateTime<Utc>>,
1168    /// Operator-supplied reason / attribution for the compromise marker.
1169    /// Often a CVE ID, an incident ticket, or a short free-form tag.
1170    /// Example: `"CVE-2026-0001: token leak via debug impl"`.
1171    #[serde(default, skip_serializing_if = "Option::is_none")]
1172    pub compromised_by: Option<String>,
1173    /// If a fix-forward release superseded this version, the successor
1174    /// version string (e.g., `"0.3.1"`). Populated by `shipper fix-forward`
1175    /// (PR 3); `None` before that PR lands OR when no fix release exists.
1176    #[serde(default, skip_serializing_if = "Option::is_none")]
1177    pub superseded_by: Option<String>,
1178}
1179
1180/// Evidence collected during package publishing.
1181///
1182/// This includes detailed information about each publish attempt and
1183/// readiness verification checks. It's used for debugging and auditing.
1184///
1185/// # Contents
1186///
1187/// - `attempts`: Details of each publish attempt (command, output, timing)
1188/// - `readiness_checks`: Results of visibility verification checks
1189#[derive(Debug, Clone, Serialize, Deserialize)]
1190pub struct PackageEvidence {
1191    pub attempts: Vec<AttemptEvidence>,
1192    pub readiness_checks: Vec<ReadinessEvidence>,
1193}
1194
1195/// Evidence for a single publish attempt.
1196///
1197/// Contains the command that was run, its output, and timing information.
1198/// This is useful for debugging failed publishes.
1199///
1200/// # Example
1201///
1202/// ```ignore
1203/// use chrono::Utc;
1204/// use std::time::Duration;
1205/// use shipper::types::AttemptEvidence;
1206///
1207/// let evidence = AttemptEvidence {
1208///     attempt_number: 1,
1209///     command: "cargo publish --registry crates-io".to_string(),
1210///     exit_code: 0,
1211///     stdout_tail: "Uploading my-crate v1.2.3".to_string(),
1212///     stderr_tail: "".to_string(),
1213///     timestamp: Utc::now(),
1214///     duration: Duration::from_secs(5),
1215/// };
1216/// ```
1217#[serde_as]
1218#[derive(Debug, Clone, Serialize, Deserialize)]
1219pub struct AttemptEvidence {
1220    pub attempt_number: u32,
1221    pub command: String,
1222    pub exit_code: i32,
1223    pub stdout_tail: String,
1224    pub stderr_tail: String,
1225    pub timestamp: DateTime<Utc>,
1226    #[serde_as(as = "DurationMilliSeconds<u64>")]
1227    pub duration: Duration,
1228}
1229
1230/// Evidence for a single readiness check.
1231///
1232/// Records the result of checking crate visibility after publishing.
1233///
1234/// # Example
1235///
1236/// ```ignore
1237/// use chrono::Utc;
1238/// use std::time::Duration;
1239/// use shipper::types::ReadinessEvidence;
1240///
1241/// let evidence = ReadinessEvidence {
1242///     attempt: 1,
1243///     visible: true,
1244///     timestamp: Utc::now(),
1245///     delay_before: Duration::from_secs(2),
1246/// };
1247/// ```
1248#[serde_as]
1249#[derive(Debug, Clone, Serialize, Deserialize)]
1250pub struct ReadinessEvidence {
1251    pub attempt: u32,
1252    pub visible: bool,
1253    pub timestamp: DateTime<Utc>,
1254    #[serde_as(as = "DurationMilliSeconds<u64>")]
1255    pub delay_before: Duration,
1256}
1257
1258/// Fingerprint of the environment where publishing occurred.
1259///
1260/// Captures version information about Shipper, Cargo, Rust, and the
1261/// operating system. This helps reproduce and debug issues.
1262///
1263/// # Example
1264///
1265/// ```ignore
1266/// use shipper::types::EnvironmentFingerprint;
1267///
1268/// let fp = EnvironmentFingerprint {
1269///     shipper_version: "0.2.0".to_string(),
1270///     cargo_version: Some("1.75.0".to_string()),
1271///     rust_version: Some("1.75.0".to_string()),
1272///     os: "linux".to_string(),
1273///     arch: "x86_64".to_string(),
1274/// };
1275/// ```
1276#[derive(Debug, Clone, Serialize, Deserialize)]
1277pub struct EnvironmentFingerprint {
1278    pub shipper_version: String,
1279    pub cargo_version: Option<String>,
1280    pub rust_version: Option<String>,
1281    pub os: String,
1282    pub arch: String,
1283}
1284
1285/// Git context at the time of publishing.
1286///
1287/// Captures the current git state, including commit hash, branch,
1288/// tag, and whether the working directory is dirty.
1289///
1290/// # Example
1291///
1292/// ```ignore
1293/// use shipper::types::GitContext;
1294///
1295/// let ctx = GitContext {
1296///     commit: Some("abc123def".to_string()),
1297///     branch: Some("main".to_string()),
1298///     tag: Some("v1.0.0".to_string()),
1299///     dirty: Some(false),
1300/// };
1301/// ```
1302#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1303pub struct GitContext {
1304    pub commit: Option<String>,
1305    pub branch: Option<String>,
1306    pub tag: Option<String>,
1307    pub dirty: Option<bool>,
1308}
1309
1310impl GitContext {
1311    /// Create a new empty git context.
1312    pub fn new() -> Self {
1313        Self::default()
1314    }
1315
1316    /// Whether the context has commit information.
1317    pub fn has_commit(&self) -> bool {
1318        self.commit.is_some()
1319    }
1320
1321    /// Whether the working tree is dirty.
1322    ///
1323    /// When `dirty` is `None`, this defaults to `true` (treat unknown as dirty) to
1324    /// preserve the safe-by-default semantics of the original `shipper-git` crate.
1325    pub fn is_dirty(&self) -> bool {
1326        self.dirty.unwrap_or(true)
1327    }
1328
1329    /// Get a short commit hash (first 7 bytes).
1330    ///
1331    /// Returns `None` if the context has no commit. The original `shipper-git`
1332    /// implementation sliced by byte index assuming ASCII hex; we preserve the
1333    /// same behavior here (input shorter than 7 bytes is returned verbatim).
1334    pub fn short_commit(&self) -> Option<&str> {
1335        self.commit
1336            .as_ref()
1337            .map(|c| if c.len() > 7 { &c[..7] } else { c.as_str() })
1338    }
1339}
1340
1341/// Complete receipt for a publish operation.
1342///
1343/// This is the final audit document containing all evidence and
1344/// metadata for a complete publish operation. It's saved to disk
1345/// after all packages are published.
1346///
1347/// # Example
1348///
1349/// ```ignore
1350/// use chrono::Utc;
1351/// use std::path::PathBuf;
1352/// use shipper::types::{Receipt, Registry, EnvironmentFingerprint};
1353///
1354/// let receipt = Receipt {
1355///     receipt_version: "shipper.receipt.v1".to_string(),
1356///     plan_id: "abc123".to_string(),
1357///     registry: Registry::crates_io(),
1358///     started_at: Utc::now(),
1359///     finished_at: Utc::now(),
1360///     packages: vec![],
1361///     event_log_path: PathBuf::from(".shipper/events.jsonl"),
1362///     git_context: None,
1363///     environment: EnvironmentFingerprint {
1364///         shipper_version: env!("CARGO_PKG_VERSION").to_string(),
1365///         cargo_version: None,
1366///         rust_version: None,
1367///         os: std::env::consts::OS.to_string(),
1368///         arch: std::env::consts::ARCH.to_string(),
1369///     },
1370/// };
1371/// # Ok::<(), anyhow::Error>(())
1372/// ```
1373///
1374/// # Storage
1375///
1376/// Receipts are stored in the state directory and can be used for:
1377/// - Auditing past publishes
1378/// - Debugging failed publishes
1379/// - Evidence for compliance requirements
1380#[derive(Debug, Clone, Serialize, Deserialize)]
1381pub struct Receipt {
1382    pub receipt_version: String,
1383    pub plan_id: String,
1384    pub registry: Registry,
1385    pub started_at: DateTime<Utc>,
1386    pub finished_at: DateTime<Utc>,
1387    pub packages: Vec<PackageReceipt>,
1388    pub event_log_path: PathBuf,
1389    #[serde(default)]
1390    pub git_context: Option<GitContext>,
1391    pub environment: EnvironmentFingerprint,
1392}
1393
1394// Event types for evidence-first receipts
1395
1396/// An event in the publish event log.
1397///
1398/// Events are written to an append-only JSONL file during publishing.
1399/// This provides a detailed timeline for debugging and auditing.
1400///
1401/// # Example
1402///
1403/// ```ignore
1404/// use chrono::Utc;
1405/// use shipper::types::{PublishEvent, EventType};
1406///
1407/// let event = PublishEvent {
1408///     timestamp: Utc::now(),
1409///     event_type: EventType::ExecutionStarted,
1410///     package: "".to_string(),
1411/// };
1412/// ```
1413#[derive(Debug, Clone, Serialize, Deserialize)]
1414pub struct PublishEvent {
1415    pub timestamp: DateTime<Utc>,
1416    pub event_type: EventType,
1417    pub package: String, // "name@version"
1418}
1419
1420/// Types of events that can occur during publishing.
1421///
1422/// These events are logged to provide a complete audit trail of the
1423/// publish operation. Each variant carries relevant data.
1424///
1425/// # Categories
1426///
1427/// - **Lifecycle events**: Plan created, execution started/finished
1428/// - **Package events**: Started, attempted, output, published, failed, skipped
1429/// - **Readiness events**: Started, polled, completed, timeout
1430/// - **Preflight events**: Started, verified, ownership checked, completed
1431///
1432/// # Example
1433///
1434/// ```ignore
1435/// use shipper::types::{EventType, ExecutionResult, ErrorClass, ReadinessMethod, Finishability};
1436///
1437/// // Lifecycle events
1438/// let plan_created = EventType::PlanCreated {
1439///     plan_id: "abc123".to_string(),
1440///     package_count: 5,
1441/// };
1442/// let started = EventType::ExecutionStarted;
1443/// let finished = EventType::ExecutionFinished {
1444///     result: ExecutionResult::Success
1445/// };
1446///
1447/// // Package events
1448/// let pkg_started = EventType::PackageStarted {
1449///     name: "my-crate".to_string(),
1450///     version: "1.0.0".to_string(),
1451/// };
1452/// let pkg_failed = EventType::PackageFailed {
1453///     class: ErrorClass::Retryable,
1454///     message: "rate limited".to_string(),
1455/// };
1456///
1457/// // Readiness events
1458/// let ready = EventType::ReadinessStarted {
1459///     method: ReadinessMethod::Api,
1460/// };
1461///
1462/// // Preflight events
1463/// let preflight = EventType::PreflightComplete {
1464///     finishability: Finishability::Proven,
1465/// };
1466/// ```
1467#[derive(Debug, Clone, Serialize, Deserialize)]
1468#[serde(tag = "type", rename_all = "snake_case")]
1469pub enum EventType {
1470    // Lifecycle events
1471    PlanCreated {
1472        plan_id: String,
1473        package_count: usize,
1474    },
1475    ExecutionStarted,
1476    ExecutionFinished {
1477        result: ExecutionResult,
1478    },
1479
1480    // Package events
1481    PackageStarted {
1482        name: String,
1483        version: String,
1484    },
1485    PackageAttempted {
1486        attempt: u32,
1487        command: String,
1488    },
1489    PackageOutput {
1490        stdout_tail: String,
1491        stderr_tail: String,
1492    },
1493    PackagePublished {
1494        duration_ms: u64,
1495    },
1496    PackageFailed {
1497        class: ErrorClass,
1498        message: String,
1499    },
1500    PackageSkipped {
1501        reason: String,
1502    },
1503
1504    // Reconciliation events (for `ErrorClass::Ambiguous` outcomes)
1505    PublishReconciling {
1506        method: ReadinessMethod,
1507    },
1508    PublishReconciled {
1509        outcome: ReconciliationOutcome,
1510    },
1511
1512    // End-of-run consistency check (events-as-truth invariant enforcement; #93)
1513    StateEventDriftDetected {
1514        drift: StateEventDrift,
1515    },
1516
1517    // Remediation / containment (#98). Emitted when `shipper yank` executes
1518    // a cargo yank against a specific crate+version. Reason is operator-
1519    // supplied (e.g., "CVE-2026-0001 disclosed"); plan_id ties the yank
1520    // to the remediation run that issued it.
1521    PackageYanked {
1522        crate_name: String,
1523        version: String,
1524        reason: String,
1525        exit_code: i32,
1526    },
1527
1528    // Rehearsal-registry proof (#97 PR 2). A rehearsal is phase-2 preflight:
1529    // publish every crate to a non-crates.io registry, verify visibility,
1530    // and (in a later PR) install-smoke it. The events below are emitted by
1531    // `shipper rehearse` so an auditor can replay the rehearsal from the
1532    // event log without re-running it.
1533    //
1534    // `plan_id` is NOT carried in the event payload — the enclosing
1535    // `PublishEvent.package` field already disambiguates per-package events,
1536    // and the end-of-run `RehearsalComplete` is sufficient for plan-level
1537    // correlation since events.jsonl is append-only scoped to one state dir.
1538    RehearsalStarted {
1539        registry: String,
1540        plan_id: String,
1541        package_count: usize,
1542    },
1543    RehearsalPackagePublished {
1544        name: String,
1545        version: String,
1546        duration_ms: u128,
1547    },
1548    RehearsalPackageFailed {
1549        name: String,
1550        version: String,
1551        class: ErrorClass,
1552        message: String,
1553    },
1554    RehearsalComplete {
1555        passed: bool,
1556        registry: String,
1557        /// Plan ID the rehearsal ran against. The hard gate (#97 PR 3)
1558        /// consults this: a subsequent `shipper publish` for the same
1559        /// plan_id can rely on this rehearsal; if the workspace changes
1560        /// (plan_id shifts), the rehearsal is stale and the gate fires.
1561        plan_id: String,
1562        summary: String,
1563    },
1564
1565    // #97 PR 4 — install/smoke check. Opt-in post-publish step that runs
1566    // `cargo install --registry <rehearsal> <crate>` to prove end-to-end
1567    // registry-index resolution — the scenario that killed the rc.1
1568    // first-publish. Events bracket the check so an auditor replaying
1569    // events.jsonl can see the proof (or failure) inline with publishes.
1570    RehearsalSmokeCheckStarted {
1571        name: String,
1572        version: String,
1573        registry: String,
1574    },
1575    RehearsalSmokeCheckSucceeded {
1576        name: String,
1577        version: String,
1578        duration_ms: u128,
1579    },
1580    RehearsalSmokeCheckFailed {
1581        name: String,
1582        version: String,
1583        message: String,
1584    },
1585
1586    // Retry visibility (#91) — emitted immediately before Shipper sleeps on a
1587    // retry backoff. `attempt` is the just-failed attempt number (1-indexed),
1588    // so the next attempt will be `attempt + 1` of `max_attempts`. `reason`
1589    // classifies why the retry is happening; `message` is the one-line
1590    // human-facing description (typically from cargo-failure classification).
1591    RetryBackoffStarted {
1592        attempt: u32,
1593        max_attempts: u32,
1594        delay_ms: u64,
1595        next_attempt_at: DateTime<Utc>,
1596        reason: ErrorClass,
1597        message: String,
1598    },
1599
1600    // Readiness events
1601    ReadinessStarted {
1602        method: ReadinessMethod,
1603    },
1604    ReadinessPoll {
1605        attempt: u32,
1606        visible: bool,
1607    },
1608    ReadinessComplete {
1609        duration_ms: u64,
1610        attempts: u32,
1611    },
1612    ReadinessTimeout {
1613        max_wait_ms: u64,
1614    },
1615    // Index readiness events
1616    IndexReadinessStarted {
1617        crate_name: String,
1618        version: String,
1619    },
1620    IndexReadinessCheck {
1621        crate_name: String,
1622        version: String,
1623        found: bool,
1624    },
1625    IndexReadinessComplete {
1626        crate_name: String,
1627        version: String,
1628        visible: bool,
1629    },
1630
1631    // Preflight events
1632    PreflightStarted,
1633    PreflightWorkspaceVerify {
1634        passed: bool,
1635        output: String,
1636    },
1637    PreflightNewCrateDetected {
1638        crate_name: String,
1639    },
1640    PreflightOwnershipCheck {
1641        crate_name: String,
1642        verified: bool,
1643    },
1644    PreflightComplete {
1645        finishability: Finishability,
1646    },
1647}
1648
1649/// The result of a publish execution.
1650///
1651/// This summarizes the overall outcome of attempting to publish
1652/// all packages in a release plan.
1653///
1654/// # Example
1655///
1656/// ```ignore
1657/// use shipper::types::ExecutionResult;
1658///
1659/// let success = ExecutionResult::Success;
1660/// let partial = ExecutionResult::PartialFailure;
1661/// let complete = ExecutionResult::CompleteFailure;
1662/// ```
1663///
1664/// # Meaning
1665///
1666/// - `Success`: All packages published successfully
1667/// - `PartialFailure`: Some packages failed but others succeeded
1668/// - `CompleteFailure`: All packages failed (or no packages to publish)
1669#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1670#[serde(rename_all = "snake_case")]
1671pub enum ExecutionResult {
1672    Success,
1673    PartialFailure,
1674    CompleteFailure,
1675}
1676
1677/// Authentication method used for publishing.
1678///
1679/// Shipper supports multiple authentication mechanisms, and this
1680/// enum tracks which one was used for a particular publish.
1681///
1682/// # Example
1683///
1684/// ```ignore
1685/// use shipper::types::AuthType;
1686///
1687/// let token = AuthType::Token;
1688/// let trusted = AuthType::TrustedPublishing;
1689/// let unknown = AuthType::Unknown;
1690/// ```
1691///
1692/// # Authentication Methods
1693///
1694/// - `Token`: Traditional Cargo token (CARGO_REGISTRY_TOKEN)
1695/// - `TrustedPublishing`: GitHub OIDC token from CI/CD
1696/// - `Unknown`: Could not determine the auth method
1697#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1698#[serde(rename_all = "snake_case")]
1699pub enum AuthType {
1700    Token,
1701    TrustedPublishing,
1702    Unknown,
1703}
1704
1705/// Whether a preflight-verified publish is guaranteed to succeed.
1706///
1707/// This is determined during preflight checks based on various
1708/// factors like whether the crate is new, if ownership is verified, etc.
1709///
1710/// # Example
1711///
1712/// ```ignore
1713/// use shipper::types::Finishability;
1714///
1715/// let proven = Finishability::Proven;       // Should succeed
1716/// let not_proven = Finishability::NotProven; // Might succeed
1717/// let failed = Finishability::Failed;        // Won't succeed
1718/// ```
1719///
1720/// # Determination
1721///
1722/// - `Proven`: All preflight checks passed strongly (new crate, owned, etc.)
1723/// - `NotProven`: Some uncertainty (already published version, etc.)
1724/// - `Failed`: Preflight checks failed (auth issues, dry-run failed, etc.)
1725#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1726#[serde(rename_all = "snake_case")]
1727pub enum Finishability {
1728    Proven,
1729    NotProven,
1730    Failed,
1731}
1732
1733/// Report from preflight verification checks.
1734///
1735/// Before publishing, Shipper runs various preflight checks to catch
1736/// issues early. This report summarizes the findings.
1737///
1738/// # Example
1739///
1740/// ```ignore
1741/// use chrono::Utc;
1742/// use shipper::types::{PreflightReport, Finishability, PreflightPackage, Registry};
1743///
1744/// let report = PreflightReport {
1745///     plan_id: "abc123".to_string(),
1746///     token_detected: true,
1747///     finishability: Finishability::Proven,
1748///     packages: vec![
1749///         PreflightPackage {
1750///             name: "my-crate".to_string(),
1751///             version: "1.0.0".to_string(),
1752///             already_published: false,
1753///             is_new_crate: true,
1754///             auth_type: Some(shipper::types::AuthType::Token),
1755///             ownership_verified: true,
1756///             dry_run_passed: true,
1757///         },
1758///     ],
1759///     timestamp: Utc::now(),
1760/// };
1761/// # Ok::<(), anyhow::Error>(())
1762/// ```
1763///
1764/// # Usage
1765///
1766/// The preflight report is used to:
1767/// - Determine if publishing should proceed
1768/// - Provide transparency about potential issues
1769/// - Support debugging if publish fails
1770#[derive(Debug, Clone, Serialize, Deserialize)]
1771pub struct PreflightReport {
1772    pub plan_id: String,
1773    pub token_detected: bool,
1774    pub finishability: Finishability,
1775    pub packages: Vec<PreflightPackage>,
1776    pub timestamp: DateTime<Utc>,
1777    /// Detailed output from workspace-level dry-run verification
1778    pub dry_run_output: Option<String>,
1779}
1780
1781/// Preflight status for a single package.
1782///
1783/// Contains the results of preflight checks for one crate in the
1784/// workspace.
1785///
1786/// # Example
1787///
1788/// ```ignore
1789/// use shipper::types::{PreflightPackage, AuthType};
1790///
1791/// let pkg = PreflightPackage {
1792///     name: "my-crate".to_string(),
1793///     version: "1.0.0".to_string(),
1794///     already_published: false,
1795///     is_new_crate: true,
1796///     auth_type: Some(AuthType::Token),
1797///     ownership_verified: true,
1798///     dry_run_passed: true,
1799///     dry_run_output: None,
1800/// };
1801/// ```
1802#[derive(Debug, Clone, Serialize, Deserialize)]
1803pub struct PreflightPackage {
1804    pub name: String,
1805    pub version: String,
1806    pub already_published: bool,
1807    pub is_new_crate: bool,
1808    pub auth_type: Option<AuthType>,
1809    pub ownership_verified: bool,
1810    pub dry_run_passed: bool,
1811    /// Detailed output from package-level dry-run verification
1812    pub dry_run_output: Option<String>,
1813}
1814
1815#[cfg(test)]
1816mod tests {
1817    use super::*;
1818
1819    #[test]
1820    fn crates_io_registry_defaults_are_expected() {
1821        let reg = Registry::crates_io();
1822        assert_eq!(reg.name, "crates-io");
1823        assert_eq!(reg.api_base, "https://crates.io");
1824    }
1825
1826    #[test]
1827    fn uploaded_state_serde_roundtrip() {
1828        let st = PackageState::Uploaded;
1829        let json = serde_json::to_string(&st).expect("serialize");
1830        assert_eq!(json, r#"{"state":"uploaded"}"#);
1831        let rt: PackageState = serde_json::from_str(&json).expect("deserialize");
1832        assert_eq!(rt, PackageState::Uploaded);
1833    }
1834
1835    #[test]
1836    fn package_state_serializes_with_tagged_representation() {
1837        let st = PackageState::Failed {
1838            class: ErrorClass::Permanent,
1839            message: "nope".to_string(),
1840        };
1841
1842        let json = serde_json::to_string(&st).expect("serialize");
1843        assert!(json.contains("\"state\":\"failed\""));
1844        assert!(json.contains("\"class\":\"permanent\""));
1845
1846        let rt: PackageState = serde_json::from_str(&json).expect("deserialize");
1847        assert_eq!(rt, st);
1848    }
1849
1850    #[test]
1851    fn execution_state_roundtrips_json() {
1852        let mut packages = BTreeMap::new();
1853        packages.insert(
1854            "demo@1.2.3".to_string(),
1855            PackageProgress {
1856                name: "demo".to_string(),
1857                version: "1.2.3".to_string(),
1858                attempts: 2,
1859                state: PackageState::Published,
1860                last_updated_at: Utc::now(),
1861            },
1862        );
1863
1864        let st = ExecutionState {
1865            state_version: "shipper.state.v1".to_string(),
1866            plan_id: "plan-1".to_string(),
1867            registry: Registry::crates_io(),
1868            created_at: Utc::now(),
1869            updated_at: Utc::now(),
1870            packages,
1871        };
1872
1873        let json = serde_json::to_string_pretty(&st).expect("serialize");
1874        let parsed: ExecutionState = serde_json::from_str(&json).expect("deserialize");
1875        assert_eq!(parsed.plan_id, "plan-1");
1876        assert!(parsed.packages.contains_key("demo@1.2.3"));
1877    }
1878
1879    #[test]
1880    fn registry_get_index_base_strips_sparse_prefix() {
1881        let registry = Registry {
1882            name: "crates-io".to_string(),
1883            api_base: "https://crates.io".to_string(),
1884            index_base: Some("sparse+https://index.crates.io".to_string()),
1885        };
1886
1887        assert_eq!(registry.get_index_base(), "https://index.crates.io");
1888    }
1889
1890    #[test]
1891    fn readiness_method_default_is_api() {
1892        let method = ReadinessMethod::default();
1893        assert_eq!(method, ReadinessMethod::Api);
1894    }
1895
1896    #[test]
1897    fn readiness_config_default_values() {
1898        let config = ReadinessConfig::default();
1899        assert!(config.enabled);
1900        assert_eq!(config.method, ReadinessMethod::Api);
1901        assert_eq!(config.initial_delay, Duration::from_secs(1));
1902        assert_eq!(config.max_delay, Duration::from_secs(60));
1903        assert_eq!(config.max_total_wait, Duration::from_secs(300));
1904        assert_eq!(config.poll_interval, Duration::from_secs(2));
1905        assert_eq!(config.jitter_factor, 0.5);
1906    }
1907
1908    #[test]
1909    fn readiness_config_can_be_customized() {
1910        let config = ReadinessConfig {
1911            enabled: false,
1912            method: ReadinessMethod::Both,
1913            initial_delay: Duration::from_millis(500),
1914            max_delay: Duration::from_secs(30),
1915            max_total_wait: Duration::from_secs(600),
1916            poll_interval: Duration::from_secs(5),
1917            jitter_factor: 0.25,
1918            index_path: None,
1919            prefer_index: false,
1920        };
1921        assert!(!config.enabled);
1922        assert_eq!(config.method, ReadinessMethod::Both);
1923        assert_eq!(config.initial_delay, Duration::from_millis(500));
1924        assert_eq!(config.max_delay, Duration::from_secs(30));
1925        assert_eq!(config.max_total_wait, Duration::from_secs(600));
1926        assert_eq!(config.poll_interval, Duration::from_secs(5));
1927        assert_eq!(config.jitter_factor, 0.25);
1928    }
1929
1930    // ===== PackageState transition tests =====
1931
1932    #[test]
1933    fn package_state_pending_to_uploaded_is_valid() {
1934        let pending = PackageState::Pending;
1935        let uploaded = PackageState::Uploaded;
1936        assert_eq!(pending, PackageState::Pending);
1937        assert_eq!(uploaded, PackageState::Uploaded);
1938        // Pending can transition to Uploaded
1939        assert_ne!(pending, uploaded);
1940    }
1941
1942    #[test]
1943    fn package_state_uploaded_to_published_is_valid() {
1944        let uploaded = PackageState::Uploaded;
1945        let published = PackageState::Published;
1946        assert_ne!(uploaded, published);
1947    }
1948
1949    #[test]
1950    fn package_state_pending_to_failed_is_valid() {
1951        let pending = PackageState::Pending;
1952        let failed = PackageState::Failed {
1953            class: ErrorClass::Retryable,
1954            message: "connection refused".to_string(),
1955        };
1956        assert_ne!(pending, failed);
1957    }
1958
1959    #[test]
1960    fn package_state_pending_to_skipped_is_valid() {
1961        let skipped = PackageState::Skipped {
1962            reason: "already published".to_string(),
1963        };
1964        assert!(matches!(skipped, PackageState::Skipped { .. }));
1965    }
1966
1967    #[test]
1968    fn package_state_uploaded_to_ambiguous_is_valid() {
1969        let ambiguous = PackageState::Ambiguous {
1970            message: "upload succeeded but timed out waiting for visibility".to_string(),
1971        };
1972        assert!(matches!(ambiguous, PackageState::Ambiguous { .. }));
1973    }
1974
1975    #[test]
1976    fn package_state_failed_equality_requires_matching_fields() {
1977        let f1 = PackageState::Failed {
1978            class: ErrorClass::Retryable,
1979            message: "timeout".to_string(),
1980        };
1981        let f2 = PackageState::Failed {
1982            class: ErrorClass::Retryable,
1983            message: "timeout".to_string(),
1984        };
1985        let f3 = PackageState::Failed {
1986            class: ErrorClass::Permanent,
1987            message: "timeout".to_string(),
1988        };
1989        let f4 = PackageState::Failed {
1990            class: ErrorClass::Retryable,
1991            message: "different".to_string(),
1992        };
1993        assert_eq!(f1, f2);
1994        assert_ne!(f1, f3);
1995        assert_ne!(f1, f4);
1996    }
1997
1998    #[test]
1999    fn package_state_skipped_equality_by_reason() {
2000        let s1 = PackageState::Skipped {
2001            reason: "exists".to_string(),
2002        };
2003        let s2 = PackageState::Skipped {
2004            reason: "exists".to_string(),
2005        };
2006        let s3 = PackageState::Skipped {
2007            reason: "other".to_string(),
2008        };
2009        assert_eq!(s1, s2);
2010        assert_ne!(s1, s3);
2011    }
2012
2013    #[test]
2014    fn package_state_all_unit_variants_are_distinct() {
2015        let states: Vec<PackageState> = vec![
2016            PackageState::Pending,
2017            PackageState::Uploaded,
2018            PackageState::Published,
2019        ];
2020        for (i, a) in states.iter().enumerate() {
2021            for (j, b) in states.iter().enumerate() {
2022                if i == j {
2023                    assert_eq!(a, b);
2024                } else {
2025                    assert_ne!(a, b);
2026                }
2027            }
2028        }
2029    }
2030
2031    // ===== ReleasePlan determinism =====
2032
2033    #[test]
2034    fn release_plan_serde_roundtrip_preserves_all_fields() {
2035        let plan = ReleasePlan {
2036            plan_version: "shipper.plan.v1".to_string(),
2037            plan_id: "deadbeef01234567".to_string(),
2038            created_at: "2025-06-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap(),
2039            registry: Registry::crates_io(),
2040            packages: vec![
2041                PlannedPackage {
2042                    name: "alpha".to_string(),
2043                    version: "1.0.0".to_string(),
2044                    manifest_path: PathBuf::from("crates/alpha/Cargo.toml"),
2045                },
2046                PlannedPackage {
2047                    name: "beta".to_string(),
2048                    version: "2.0.0".to_string(),
2049                    manifest_path: PathBuf::from("crates/beta/Cargo.toml"),
2050                },
2051            ],
2052            dependencies: BTreeMap::from([("beta".to_string(), vec!["alpha".to_string()])]),
2053        };
2054        let json = serde_json::to_string(&plan).unwrap();
2055        let parsed: ReleasePlan = serde_json::from_str(&json).unwrap();
2056        assert_eq!(parsed.plan_version, plan.plan_version);
2057        assert_eq!(parsed.plan_id, plan.plan_id);
2058        assert_eq!(parsed.packages.len(), 2);
2059        assert_eq!(parsed.packages[0].name, "alpha");
2060        assert_eq!(parsed.packages[1].name, "beta");
2061        assert_eq!(parsed.dependencies.len(), 1);
2062        assert_eq!(parsed.dependencies["beta"], vec!["alpha".to_string()]);
2063        assert_eq!(parsed.registry.name, "crates-io");
2064    }
2065
2066    #[test]
2067    fn release_plan_empty_dependencies_roundtrip() {
2068        let plan = ReleasePlan {
2069            plan_version: "shipper.plan.v1".to_string(),
2070            plan_id: "nodeps".to_string(),
2071            created_at: Utc::now(),
2072            registry: Registry::crates_io(),
2073            packages: vec![PlannedPackage {
2074                name: "standalone".to_string(),
2075                version: "0.1.0".to_string(),
2076                manifest_path: PathBuf::from("Cargo.toml"),
2077            }],
2078            dependencies: BTreeMap::new(),
2079        };
2080        let json = serde_json::to_string(&plan).unwrap();
2081        let parsed: ReleasePlan = serde_json::from_str(&json).unwrap();
2082        assert!(parsed.dependencies.is_empty());
2083    }
2084
2085    #[test]
2086    fn release_plan_group_by_levels_single_crate() {
2087        let plan = ReleasePlan {
2088            plan_version: "shipper.plan.v1".to_string(),
2089            plan_id: "single".to_string(),
2090            created_at: Utc::now(),
2091            registry: Registry::crates_io(),
2092            packages: vec![PlannedPackage {
2093                name: "solo".to_string(),
2094                version: "1.0.0".to_string(),
2095                manifest_path: PathBuf::from("Cargo.toml"),
2096            }],
2097            dependencies: BTreeMap::new(),
2098        };
2099        let levels = plan.group_by_levels();
2100        assert_eq!(levels.len(), 1);
2101        assert_eq!(levels[0].level, 0);
2102        assert_eq!(levels[0].packages.len(), 1);
2103        assert_eq!(levels[0].packages[0].name, "solo");
2104    }
2105
2106    #[test]
2107    fn release_plan_group_by_levels_chain() {
2108        let plan = ReleasePlan {
2109            plan_version: "shipper.plan.v1".to_string(),
2110            plan_id: "chain".to_string(),
2111            created_at: Utc::now(),
2112            registry: Registry::crates_io(),
2113            packages: vec![
2114                PlannedPackage {
2115                    name: "a".to_string(),
2116                    version: "1.0.0".to_string(),
2117                    manifest_path: PathBuf::from("a/Cargo.toml"),
2118                },
2119                PlannedPackage {
2120                    name: "b".to_string(),
2121                    version: "1.0.0".to_string(),
2122                    manifest_path: PathBuf::from("b/Cargo.toml"),
2123                },
2124                PlannedPackage {
2125                    name: "c".to_string(),
2126                    version: "1.0.0".to_string(),
2127                    manifest_path: PathBuf::from("c/Cargo.toml"),
2128                },
2129            ],
2130            dependencies: BTreeMap::from([
2131                ("b".to_string(), vec!["a".to_string()]),
2132                ("c".to_string(), vec!["b".to_string()]),
2133            ]),
2134        };
2135        let levels = plan.group_by_levels();
2136        assert_eq!(levels.len(), 3);
2137        assert_eq!(levels[0].level, 0);
2138        assert_eq!(levels[0].packages[0].name, "a");
2139        assert_eq!(levels[1].level, 1);
2140        assert_eq!(levels[1].packages[0].name, "b");
2141        assert_eq!(levels[2].level, 2);
2142        assert_eq!(levels[2].packages[0].name, "c");
2143    }
2144
2145    #[test]
2146    fn release_plan_group_by_levels_parallel_at_level_zero() {
2147        let plan = ReleasePlan {
2148            plan_version: "shipper.plan.v1".to_string(),
2149            plan_id: "parallel".to_string(),
2150            created_at: Utc::now(),
2151            registry: Registry::crates_io(),
2152            packages: vec![
2153                PlannedPackage {
2154                    name: "x".to_string(),
2155                    version: "1.0.0".to_string(),
2156                    manifest_path: PathBuf::from("x/Cargo.toml"),
2157                },
2158                PlannedPackage {
2159                    name: "y".to_string(),
2160                    version: "1.0.0".to_string(),
2161                    manifest_path: PathBuf::from("y/Cargo.toml"),
2162                },
2163                PlannedPackage {
2164                    name: "z".to_string(),
2165                    version: "1.0.0".to_string(),
2166                    manifest_path: PathBuf::from("z/Cargo.toml"),
2167                },
2168            ],
2169            dependencies: BTreeMap::new(),
2170        };
2171        let levels = plan.group_by_levels();
2172        assert_eq!(levels.len(), 1);
2173        assert_eq!(levels[0].packages.len(), 3);
2174    }
2175
2176    // ===== Receipt serialization roundtrips =====
2177
2178    #[test]
2179    fn receipt_with_ambiguous_state_roundtrip() {
2180        let t = "2025-01-15T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
2181        let receipt = Receipt {
2182            receipt_version: "shipper.receipt.v1".to_string(),
2183            plan_id: "ambig-test".to_string(),
2184            registry: Registry::crates_io(),
2185            started_at: t,
2186            finished_at: t,
2187            packages: vec![PackageReceipt {
2188                name: "ambig-crate".to_string(),
2189                version: "0.1.0".to_string(),
2190                attempts: 2,
2191                state: PackageState::Ambiguous {
2192                    message: "upload ok but readiness timed out".to_string(),
2193                },
2194                started_at: t,
2195                finished_at: t,
2196                duration_ms: 60000,
2197                evidence: PackageEvidence {
2198                    attempts: vec![],
2199                    readiness_checks: vec![],
2200                },
2201                compromised_at: None,
2202                compromised_by: None,
2203                superseded_by: None,
2204            }],
2205            event_log_path: PathBuf::from(".shipper/events.jsonl"),
2206            git_context: None,
2207            environment: EnvironmentFingerprint {
2208                shipper_version: "0.3.0".to_string(),
2209                cargo_version: None,
2210                rust_version: None,
2211                os: "linux".to_string(),
2212                arch: "x86_64".to_string(),
2213            },
2214        };
2215        let json = serde_json::to_string(&receipt).unwrap();
2216        let parsed: Receipt = serde_json::from_str(&json).unwrap();
2217        assert!(matches!(
2218            &parsed.packages[0].state,
2219            PackageState::Ambiguous { message } if message.contains("readiness timed out")
2220        ));
2221    }
2222
2223    #[test]
2224    fn receipt_empty_packages_roundtrip() {
2225        let t = Utc::now();
2226        let receipt = Receipt {
2227            receipt_version: "shipper.receipt.v1".to_string(),
2228            plan_id: "empty".to_string(),
2229            registry: Registry::crates_io(),
2230            started_at: t,
2231            finished_at: t,
2232            packages: vec![],
2233            event_log_path: PathBuf::from(".shipper/events.jsonl"),
2234            git_context: None,
2235            environment: EnvironmentFingerprint {
2236                shipper_version: "0.3.0".to_string(),
2237                cargo_version: None,
2238                rust_version: None,
2239                os: "linux".to_string(),
2240                arch: "x86_64".to_string(),
2241            },
2242        };
2243        let json = serde_json::to_string(&receipt).unwrap();
2244        let parsed: Receipt = serde_json::from_str(&json).unwrap();
2245        assert!(parsed.packages.is_empty());
2246    }
2247
2248    #[test]
2249    fn receipt_all_state_variants_roundtrip() {
2250        let t = Utc::now();
2251        let states = vec![
2252            PackageState::Published,
2253            PackageState::Uploaded,
2254            PackageState::Pending,
2255            PackageState::Skipped {
2256                reason: "exists".to_string(),
2257            },
2258            PackageState::Failed {
2259                class: ErrorClass::Permanent,
2260                message: "auth".to_string(),
2261            },
2262            PackageState::Ambiguous {
2263                message: "unclear".to_string(),
2264            },
2265        ];
2266        let packages: Vec<PackageReceipt> = states
2267            .into_iter()
2268            .enumerate()
2269            .map(|(i, state)| PackageReceipt {
2270                name: format!("crate-{i}"),
2271                version: "1.0.0".to_string(),
2272                attempts: 1,
2273                state,
2274                started_at: t,
2275                finished_at: t,
2276                duration_ms: 100,
2277                evidence: PackageEvidence {
2278                    attempts: vec![],
2279                    readiness_checks: vec![],
2280                },
2281                compromised_at: None,
2282                compromised_by: None,
2283                superseded_by: None,
2284            })
2285            .collect();
2286        let receipt = Receipt {
2287            receipt_version: "shipper.receipt.v1".to_string(),
2288            plan_id: "all-variants".to_string(),
2289            registry: Registry::crates_io(),
2290            started_at: t,
2291            finished_at: t,
2292            packages,
2293            event_log_path: PathBuf::from(".shipper/events.jsonl"),
2294            git_context: None,
2295            environment: EnvironmentFingerprint {
2296                shipper_version: "0.3.0".to_string(),
2297                cargo_version: None,
2298                rust_version: None,
2299                os: "linux".to_string(),
2300                arch: "x86_64".to_string(),
2301            },
2302        };
2303        let json = serde_json::to_string(&receipt).unwrap();
2304        let parsed: Receipt = serde_json::from_str(&json).unwrap();
2305        assert_eq!(parsed.packages.len(), 6);
2306        assert!(matches!(parsed.packages[0].state, PackageState::Published));
2307        assert!(matches!(parsed.packages[1].state, PackageState::Uploaded));
2308        assert!(matches!(parsed.packages[2].state, PackageState::Pending));
2309        assert!(matches!(
2310            parsed.packages[3].state,
2311            PackageState::Skipped { .. }
2312        ));
2313        assert!(matches!(
2314            parsed.packages[4].state,
2315            PackageState::Failed { .. }
2316        ));
2317        assert!(matches!(
2318            parsed.packages[5].state,
2319            PackageState::Ambiguous { .. }
2320        ));
2321    }
2322
2323    // ===== PublishPolicy / VerifyMode =====
2324
2325    #[test]
2326    fn publish_policy_default_is_safe() {
2327        assert_eq!(PublishPolicy::default(), PublishPolicy::Safe);
2328    }
2329
2330    #[test]
2331    fn publish_policy_exhaustive_serde() {
2332        let policies = [
2333            PublishPolicy::Safe,
2334            PublishPolicy::Balanced,
2335            PublishPolicy::Fast,
2336        ];
2337        let expected_json = [r#""safe""#, r#""balanced""#, r#""fast""#];
2338        for (policy, expected) in policies.iter().zip(expected_json.iter()) {
2339            let json = serde_json::to_string(policy).unwrap();
2340            assert_eq!(&json, expected);
2341            let parsed: PublishPolicy = serde_json::from_str(&json).unwrap();
2342            assert_eq!(&parsed, policy);
2343        }
2344    }
2345
2346    #[test]
2347    fn verify_mode_default_is_workspace() {
2348        assert_eq!(VerifyMode::default(), VerifyMode::Workspace);
2349    }
2350
2351    #[test]
2352    fn verify_mode_exhaustive_serde() {
2353        let modes = [VerifyMode::Workspace, VerifyMode::Package, VerifyMode::None];
2354        let expected_json = [r#""workspace""#, r#""package""#, r#""none""#];
2355        for (mode, expected) in modes.iter().zip(expected_json.iter()) {
2356            let json = serde_json::to_string(mode).unwrap();
2357            assert_eq!(&json, expected);
2358            let parsed: VerifyMode = serde_json::from_str(&json).unwrap();
2359            assert_eq!(&parsed, mode);
2360        }
2361    }
2362
2363    #[test]
2364    fn readiness_method_exhaustive_serde() {
2365        let methods = [
2366            ReadinessMethod::Api,
2367            ReadinessMethod::Index,
2368            ReadinessMethod::Both,
2369        ];
2370        let expected_json = [r#""api""#, r#""index""#, r#""both""#];
2371        for (method, expected) in methods.iter().zip(expected_json.iter()) {
2372            let json = serde_json::to_string(method).unwrap();
2373            assert_eq!(&json, expected);
2374            let parsed: ReadinessMethod = serde_json::from_str(&json).unwrap();
2375            assert_eq!(&parsed, method);
2376        }
2377    }
2378
2379    // ===== PackageProgress =====
2380
2381    #[test]
2382    fn package_progress_epoch_timestamp_roundtrip() {
2383        let epoch = DateTime::from_timestamp(0, 0).unwrap();
2384        let progress = PackageProgress {
2385            name: "epoch-crate".to_string(),
2386            version: "0.0.1".to_string(),
2387            attempts: 0,
2388            state: PackageState::Pending,
2389            last_updated_at: epoch,
2390        };
2391        let json = serde_json::to_string(&progress).unwrap();
2392        let parsed: PackageProgress = serde_json::from_str(&json).unwrap();
2393        assert_eq!(parsed.last_updated_at.timestamp(), 0);
2394    }
2395
2396    #[test]
2397    fn package_progress_far_future_timestamp_roundtrip() {
2398        let far_future = DateTime::from_timestamp(4102444800, 0).unwrap(); // 2100-01-01
2399        let progress = PackageProgress {
2400            name: "future-crate".to_string(),
2401            version: "99.0.0".to_string(),
2402            attempts: 0,
2403            state: PackageState::Pending,
2404            last_updated_at: far_future,
2405        };
2406        let json = serde_json::to_string(&progress).unwrap();
2407        let parsed: PackageProgress = serde_json::from_str(&json).unwrap();
2408        assert_eq!(parsed.last_updated_at.timestamp(), 4102444800);
2409    }
2410
2411    #[test]
2412    fn package_progress_all_states_roundtrip() {
2413        let states = vec![
2414            PackageState::Pending,
2415            PackageState::Uploaded,
2416            PackageState::Published,
2417            PackageState::Skipped {
2418                reason: "r".to_string(),
2419            },
2420            PackageState::Failed {
2421                class: ErrorClass::Ambiguous,
2422                message: "m".to_string(),
2423            },
2424            PackageState::Ambiguous {
2425                message: "a".to_string(),
2426            },
2427        ];
2428        for state in states {
2429            let progress = PackageProgress {
2430                name: "test".to_string(),
2431                version: "1.0.0".to_string(),
2432                attempts: 1,
2433                state: state.clone(),
2434                last_updated_at: Utc::now(),
2435            };
2436            let json = serde_json::to_string(&progress).unwrap();
2437            let parsed: PackageProgress = serde_json::from_str(&json).unwrap();
2438            assert_eq!(parsed.state, state);
2439        }
2440    }
2441
2442    // ===== RuntimeOptions =====
2443
2444    fn make_default_runtime_options() -> RuntimeOptions {
2445        RuntimeOptions {
2446            allow_dirty: false,
2447            skip_ownership_check: false,
2448            strict_ownership: false,
2449            no_verify: false,
2450            max_attempts: 3,
2451            base_delay: Duration::from_secs(1),
2452            max_delay: Duration::from_secs(60),
2453            retry_strategy: shipper_retry::RetryStrategyType::Exponential,
2454            retry_jitter: 0.5,
2455            retry_per_error: shipper_retry::PerErrorConfig::default(),
2456            verify_timeout: Duration::from_secs(600),
2457            verify_poll_interval: Duration::from_secs(10),
2458            state_dir: PathBuf::from(".shipper"),
2459            force_resume: false,
2460            policy: PublishPolicy::Safe,
2461            verify_mode: VerifyMode::Workspace,
2462            readiness: ReadinessConfig::default(),
2463            output_lines: 1000,
2464            force: false,
2465            lock_timeout: Duration::from_secs(3600),
2466            parallel: ParallelConfig::default(),
2467            webhook: WebhookConfig::default(),
2468            encryption: EncryptionSettings::default(),
2469            registries: vec![],
2470            resume_from: None,
2471            rehearsal_registry: None,
2472            rehearsal_skip: false,
2473            rehearsal_smoke_install: None,
2474        }
2475    }
2476
2477    #[test]
2478    fn runtime_options_default_values() {
2479        let opts = make_default_runtime_options();
2480        assert!(!opts.allow_dirty);
2481        assert!(!opts.skip_ownership_check);
2482        assert!(!opts.strict_ownership);
2483        assert!(!opts.no_verify);
2484        assert_eq!(opts.max_attempts, 3);
2485        assert_eq!(opts.base_delay, Duration::from_secs(1));
2486        assert_eq!(opts.max_delay, Duration::from_secs(60));
2487        assert_eq!(opts.policy, PublishPolicy::Safe);
2488        assert_eq!(opts.verify_mode, VerifyMode::Workspace);
2489        assert_eq!(opts.output_lines, 1000);
2490        assert!(!opts.force);
2491        assert!(!opts.force_resume);
2492        assert!(opts.registries.is_empty());
2493        assert!(opts.resume_from.is_none());
2494    }
2495
2496    #[test]
2497    fn runtime_options_all_booleans_toggled() {
2498        let opts = RuntimeOptions {
2499            allow_dirty: true,
2500            skip_ownership_check: true,
2501            strict_ownership: true,
2502            no_verify: true,
2503            force_resume: true,
2504            force: true,
2505            ..make_default_runtime_options()
2506        };
2507        assert!(opts.allow_dirty);
2508        assert!(opts.skip_ownership_check);
2509        assert!(opts.strict_ownership);
2510        assert!(opts.no_verify);
2511        assert!(opts.force_resume);
2512        assert!(opts.force);
2513    }
2514
2515    #[test]
2516    fn runtime_options_with_multiple_registries() {
2517        let opts = RuntimeOptions {
2518            registries: vec![
2519                Registry::crates_io(),
2520                Registry {
2521                    name: "private".to_string(),
2522                    api_base: "https://registry.example.com".to_string(),
2523                    index_base: None,
2524                },
2525            ],
2526            ..make_default_runtime_options()
2527        };
2528        assert_eq!(opts.registries.len(), 2);
2529        assert_eq!(opts.registries[0].name, "crates-io");
2530        assert_eq!(opts.registries[1].name, "private");
2531    }
2532
2533    #[test]
2534    fn runtime_options_with_resume_from() {
2535        let opts = RuntimeOptions {
2536            resume_from: Some("my-crate".to_string()),
2537            ..make_default_runtime_options()
2538        };
2539        assert_eq!(opts.resume_from.as_deref(), Some("my-crate"));
2540    }
2541
2542    // ===== Registry =====
2543
2544    #[test]
2545    fn registry_get_index_base_derives_from_api_https() {
2546        let reg = Registry {
2547            name: "custom".to_string(),
2548            api_base: "https://registry.example.com".to_string(),
2549            index_base: None,
2550        };
2551        assert_eq!(reg.get_index_base(), "https://index.registry.example.com");
2552    }
2553
2554    #[test]
2555    fn registry_get_index_base_derives_from_api_http() {
2556        let reg = Registry {
2557            name: "local".to_string(),
2558            api_base: "http://localhost:8080".to_string(),
2559            index_base: None,
2560        };
2561        assert_eq!(reg.get_index_base(), "http://index.localhost:8080");
2562    }
2563
2564    #[test]
2565    fn registry_get_index_base_uses_explicit_value() {
2566        let reg = Registry {
2567            name: "custom".to_string(),
2568            api_base: "https://api.example.com".to_string(),
2569            index_base: Some("https://my-index.example.com".to_string()),
2570        };
2571        assert_eq!(reg.get_index_base(), "https://my-index.example.com");
2572    }
2573
2574    #[test]
2575    fn registry_crates_io_get_index_base() {
2576        let reg = Registry::crates_io();
2577        assert_eq!(reg.get_index_base(), "https://index.crates.io");
2578    }
2579
2580    #[test]
2581    fn registry_serde_skips_none_index_base() {
2582        let reg = Registry {
2583            name: "test".to_string(),
2584            api_base: "https://test.io".to_string(),
2585            index_base: None,
2586        };
2587        let json = serde_json::to_string(&reg).unwrap();
2588        assert!(!json.contains("index_base"));
2589    }
2590
2591    // ===== ErrorClass =====
2592
2593    #[test]
2594    fn error_class_serde_values() {
2595        let classes = [
2596            ErrorClass::Retryable,
2597            ErrorClass::Permanent,
2598            ErrorClass::Ambiguous,
2599        ];
2600        let expected = [r#""retryable""#, r#""permanent""#, r#""ambiguous""#];
2601        for (class, exp) in classes.iter().zip(expected.iter()) {
2602            let json = serde_json::to_string(class).unwrap();
2603            assert_eq!(&json, exp);
2604        }
2605    }
2606
2607    #[test]
2608    fn error_class_clone_and_eq() {
2609        let original = ErrorClass::Retryable;
2610        let cloned = original.clone();
2611        assert_eq!(original, cloned);
2612    }
2613
2614    // ===== ExecutionResult =====
2615
2616    #[test]
2617    fn execution_result_serde_values() {
2618        let results = [
2619            ExecutionResult::Success,
2620            ExecutionResult::PartialFailure,
2621            ExecutionResult::CompleteFailure,
2622        ];
2623        let expected = [
2624            r#""success""#,
2625            r#""partial_failure""#,
2626            r#""complete_failure""#,
2627        ];
2628        for (result, exp) in results.iter().zip(expected.iter()) {
2629            let json = serde_json::to_string(result).unwrap();
2630            assert_eq!(&json, exp);
2631        }
2632    }
2633
2634    // ===== AuthType =====
2635
2636    #[test]
2637    fn auth_type_serde_values() {
2638        let types = [
2639            AuthType::Token,
2640            AuthType::TrustedPublishing,
2641            AuthType::Unknown,
2642        ];
2643        let expected = [r#""token""#, r#""trusted_publishing""#, r#""unknown""#];
2644        for (auth, exp) in types.iter().zip(expected.iter()) {
2645            let json = serde_json::to_string(auth).unwrap();
2646            assert_eq!(&json, exp);
2647        }
2648    }
2649
2650    // ===== Finishability =====
2651
2652    #[test]
2653    fn finishability_serde_values() {
2654        let fins = [
2655            Finishability::Proven,
2656            Finishability::NotProven,
2657            Finishability::Failed,
2658        ];
2659        let expected = [r#""proven""#, r#""not_proven""#, r#""failed""#];
2660        for (fin, exp) in fins.iter().zip(expected.iter()) {
2661            let json = serde_json::to_string(fin).unwrap();
2662            assert_eq!(&json, exp);
2663        }
2664    }
2665
2666    // ===== ParallelConfig =====
2667
2668    #[test]
2669    fn parallel_config_default_values() {
2670        let config = ParallelConfig::default();
2671        assert!(!config.enabled);
2672        assert_eq!(config.max_concurrent, 4);
2673        assert_eq!(config.per_package_timeout, Duration::from_secs(1800));
2674    }
2675
2676    #[test]
2677    fn parallel_config_serde_roundtrip() {
2678        let config = ParallelConfig {
2679            enabled: true,
2680            max_concurrent: 16,
2681            per_package_timeout: Duration::from_secs(300),
2682        };
2683        let json = serde_json::to_string(&config).unwrap();
2684        let parsed: ParallelConfig = serde_json::from_str(&json).unwrap();
2685        assert!(parsed.enabled);
2686        assert_eq!(parsed.max_concurrent, 16);
2687        assert_eq!(parsed.per_package_timeout, Duration::from_secs(300));
2688    }
2689
2690    // ===== PackageState serde =====
2691
2692    #[test]
2693    fn package_state_pending_json() {
2694        let json = serde_json::to_string(&PackageState::Pending).unwrap();
2695        assert_eq!(json, r#"{"state":"pending"}"#);
2696    }
2697
2698    #[test]
2699    fn package_state_published_json() {
2700        let json = serde_json::to_string(&PackageState::Published).unwrap();
2701        assert_eq!(json, r#"{"state":"published"}"#);
2702    }
2703
2704    #[test]
2705    fn package_state_skipped_json_contains_reason() {
2706        let state = PackageState::Skipped {
2707            reason: "version exists".to_string(),
2708        };
2709        let json = serde_json::to_string(&state).unwrap();
2710        assert!(json.contains(r#""state":"skipped""#));
2711        assert!(json.contains(r#""reason":"version exists""#));
2712    }
2713
2714    #[test]
2715    fn package_state_ambiguous_serde_roundtrip() {
2716        let state = PackageState::Ambiguous {
2717            message: "timeout during readiness".to_string(),
2718        };
2719        let json = serde_json::to_string(&state).unwrap();
2720        let parsed: PackageState = serde_json::from_str(&json).unwrap();
2721        assert_eq!(parsed, state);
2722    }
2723
2724    // ===== EventType serde =====
2725
2726    #[test]
2727    fn event_type_preflight_variants_roundtrip() {
2728        let events = vec![
2729            EventType::PreflightStarted,
2730            EventType::PreflightWorkspaceVerify {
2731                passed: true,
2732                output: "all good".to_string(),
2733            },
2734            EventType::PreflightNewCrateDetected {
2735                crate_name: "new-crate".to_string(),
2736            },
2737            EventType::PreflightOwnershipCheck {
2738                crate_name: "my-crate".to_string(),
2739                verified: true,
2740            },
2741            EventType::PreflightComplete {
2742                finishability: Finishability::Proven,
2743            },
2744        ];
2745        for event in &events {
2746            let json = serde_json::to_string(event).unwrap();
2747            let parsed: EventType = serde_json::from_str(&json).unwrap();
2748            let reparsed_json = serde_json::to_string(&parsed).unwrap();
2749            assert_eq!(json, reparsed_json);
2750        }
2751    }
2752
2753    // ===== GitContext =====
2754
2755    #[test]
2756    fn git_context_all_none_roundtrip() {
2757        let ctx = GitContext {
2758            commit: None,
2759            branch: None,
2760            tag: None,
2761            dirty: None,
2762        };
2763        let json = serde_json::to_string(&ctx).unwrap();
2764        let parsed: GitContext = serde_json::from_str(&json).unwrap();
2765        assert!(parsed.commit.is_none());
2766        assert!(parsed.branch.is_none());
2767        assert!(parsed.tag.is_none());
2768        assert!(parsed.dirty.is_none());
2769    }
2770
2771    #[test]
2772    fn git_context_all_some_roundtrip() {
2773        let ctx = GitContext {
2774            commit: Some("abc123".to_string()),
2775            branch: Some("main".to_string()),
2776            tag: Some("v1.0.0".to_string()),
2777            dirty: Some(true),
2778        };
2779        let json = serde_json::to_string(&ctx).unwrap();
2780        let parsed: GitContext = serde_json::from_str(&json).unwrap();
2781        assert_eq!(parsed.commit.as_deref(), Some("abc123"));
2782        assert_eq!(parsed.dirty, Some(true));
2783    }
2784
2785    // ===== EnvironmentFingerprint =====
2786
2787    #[test]
2788    fn environment_fingerprint_optional_fields_roundtrip() {
2789        let fp = EnvironmentFingerprint {
2790            shipper_version: "0.3.0".to_string(),
2791            cargo_version: None,
2792            rust_version: None,
2793            os: "wasm".to_string(),
2794            arch: "wasm32".to_string(),
2795        };
2796        let json = serde_json::to_string(&fp).unwrap();
2797        let parsed: EnvironmentFingerprint = serde_json::from_str(&json).unwrap();
2798        assert!(parsed.cargo_version.is_none());
2799        assert!(parsed.rust_version.is_none());
2800        assert_eq!(parsed.os, "wasm");
2801    }
2802
2803    // ===== AttemptEvidence / ReadinessEvidence =====
2804
2805    #[test]
2806    fn attempt_evidence_duration_serialized_as_millis() {
2807        let evidence = AttemptEvidence {
2808            attempt_number: 1,
2809            command: "cargo publish".to_string(),
2810            exit_code: 0,
2811            stdout_tail: String::new(),
2812            stderr_tail: String::new(),
2813            timestamp: Utc::now(),
2814            duration: Duration::from_secs(5),
2815        };
2816        let json = serde_json::to_string(&evidence).unwrap();
2817        assert!(json.contains("5000"));
2818    }
2819
2820    #[test]
2821    fn readiness_evidence_duration_serialized_as_millis() {
2822        let evidence = ReadinessEvidence {
2823            attempt: 1,
2824            visible: true,
2825            timestamp: Utc::now(),
2826            delay_before: Duration::from_millis(2500),
2827        };
2828        let json = serde_json::to_string(&evidence).unwrap();
2829        assert!(json.contains("2500"));
2830    }
2831
2832    // ===== ReadinessConfig serde =====
2833
2834    #[test]
2835    fn readiness_config_serde_with_index_path_roundtrip() {
2836        let config = ReadinessConfig {
2837            enabled: true,
2838            method: ReadinessMethod::Index,
2839            initial_delay: Duration::from_secs(2),
2840            max_delay: Duration::from_secs(120),
2841            max_total_wait: Duration::from_secs(600),
2842            poll_interval: Duration::from_secs(5),
2843            jitter_factor: 0.3,
2844            index_path: Some(PathBuf::from("/tmp/test-index")),
2845            prefer_index: true,
2846        };
2847        let json = serde_json::to_string(&config).unwrap();
2848        let parsed: ReadinessConfig = serde_json::from_str(&json).unwrap();
2849        assert_eq!(parsed.index_path, Some(PathBuf::from("/tmp/test-index")));
2850        assert!(parsed.prefer_index);
2851    }
2852
2853    #[test]
2854    fn readiness_config_defaults_from_json_empty_object() {
2855        let config: ReadinessConfig = serde_json::from_str("{}").unwrap();
2856        assert!(config.enabled);
2857        assert_eq!(config.method, ReadinessMethod::Api);
2858        assert_eq!(config.jitter_factor, 0.5);
2859        assert!(!config.prefer_index);
2860        assert!(config.index_path.is_none());
2861    }
2862
2863    // ===== Debug output snapshot tests =====
2864
2865    mod debug_snapshots {
2866        use super::*;
2867
2868        #[test]
2869        fn publish_policy_debug_snapshot() {
2870            insta::assert_debug_snapshot!(PublishPolicy::Safe);
2871            insta::assert_debug_snapshot!(PublishPolicy::Balanced);
2872            insta::assert_debug_snapshot!(PublishPolicy::Fast);
2873        }
2874
2875        #[test]
2876        fn verify_mode_debug_snapshot() {
2877            insta::assert_debug_snapshot!(VerifyMode::Workspace);
2878            insta::assert_debug_snapshot!(VerifyMode::Package);
2879            insta::assert_debug_snapshot!(VerifyMode::None);
2880        }
2881
2882        #[test]
2883        fn readiness_method_debug_snapshot() {
2884            insta::assert_debug_snapshot!(ReadinessMethod::Api);
2885            insta::assert_debug_snapshot!(ReadinessMethod::Index);
2886            insta::assert_debug_snapshot!(ReadinessMethod::Both);
2887        }
2888
2889        #[test]
2890        fn runtime_options_debug_snapshot() {
2891            let opts = super::make_default_runtime_options();
2892            insta::assert_debug_snapshot!(opts);
2893        }
2894
2895        #[test]
2896        fn package_progress_debug_snapshot() {
2897            let t = "2025-01-15T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
2898            let progress = PackageProgress {
2899                name: "snapshot-crate".to_string(),
2900                version: "1.0.0".to_string(),
2901                attempts: 2,
2902                state: PackageState::Failed {
2903                    class: ErrorClass::Retryable,
2904                    message: "timeout".to_string(),
2905                },
2906                last_updated_at: t,
2907            };
2908            insta::assert_debug_snapshot!(progress);
2909        }
2910    }
2911
2912    mod snapshots {
2913        use super::*;
2914
2915        fn fixed_time() -> DateTime<Utc> {
2916            "2025-01-15T12:00:00Z".parse::<DateTime<Utc>>().unwrap()
2917        }
2918
2919        #[test]
2920        fn release_plan_snapshot() {
2921            let plan = ReleasePlan {
2922                plan_version: "shipper.plan.v1".to_string(),
2923                plan_id: "abc123".to_string(),
2924                created_at: fixed_time(),
2925                registry: Registry::crates_io(),
2926                packages: vec![
2927                    PlannedPackage {
2928                        name: "core-lib".to_string(),
2929                        version: "0.1.0".to_string(),
2930                        manifest_path: PathBuf::from("crates/core-lib/Cargo.toml"),
2931                    },
2932                    PlannedPackage {
2933                        name: "my-cli".to_string(),
2934                        version: "0.2.0".to_string(),
2935                        manifest_path: PathBuf::from("crates/my-cli/Cargo.toml"),
2936                    },
2937                ],
2938                dependencies: BTreeMap::from([(
2939                    "my-cli".to_string(),
2940                    vec!["core-lib".to_string()],
2941                )]),
2942            };
2943            insta::assert_yaml_snapshot!(plan);
2944        }
2945
2946        #[test]
2947        fn package_state_all_variants() {
2948            let variants: Vec<(&str, PackageState)> = vec![
2949                ("pending", PackageState::Pending),
2950                ("uploaded", PackageState::Uploaded),
2951                ("published", PackageState::Published),
2952                (
2953                    "skipped",
2954                    PackageState::Skipped {
2955                        reason: "already published".to_string(),
2956                    },
2957                ),
2958                (
2959                    "failed",
2960                    PackageState::Failed {
2961                        class: ErrorClass::Retryable,
2962                        message: "network timeout".to_string(),
2963                    },
2964                ),
2965                (
2966                    "ambiguous",
2967                    PackageState::Ambiguous {
2968                        message: "unclear outcome".to_string(),
2969                    },
2970                ),
2971            ];
2972            for (label, state) in variants {
2973                insta::assert_yaml_snapshot!(format!("package_state_{label}"), state);
2974            }
2975        }
2976
2977        #[test]
2978        fn receipt_full_snapshot() {
2979            let t = fixed_time();
2980            let receipt = Receipt {
2981                receipt_version: "shipper.receipt.v1".to_string(),
2982                plan_id: "plan-42".to_string(),
2983                registry: Registry::crates_io(),
2984                started_at: t,
2985                finished_at: t,
2986                packages: vec![PackageReceipt {
2987                    name: "demo".to_string(),
2988                    version: "1.0.0".to_string(),
2989                    attempts: 1,
2990                    state: PackageState::Published,
2991                    started_at: t,
2992                    finished_at: t,
2993                    duration_ms: 4500,
2994                    evidence: PackageEvidence {
2995                        attempts: vec![AttemptEvidence {
2996                            attempt_number: 1,
2997                            command: "cargo publish -p demo".to_string(),
2998                            exit_code: 0,
2999                            stdout_tail: "Uploading demo v1.0.0".to_string(),
3000                            stderr_tail: String::new(),
3001                            timestamp: t,
3002                            duration: Duration::from_millis(4200),
3003                        }],
3004                        readiness_checks: vec![ReadinessEvidence {
3005                            attempt: 1,
3006                            visible: true,
3007                            timestamp: t,
3008                            delay_before: Duration::from_secs(2),
3009                        }],
3010                    },
3011                    compromised_at: None,
3012                    compromised_by: None,
3013                    superseded_by: None,
3014                }],
3015                event_log_path: PathBuf::from(".shipper/events.jsonl"),
3016                git_context: Some(GitContext {
3017                    commit: Some("abcdef1234567890".to_string()),
3018                    branch: Some("main".to_string()),
3019                    tag: Some("v1.0.0".to_string()),
3020                    dirty: Some(false),
3021                }),
3022                environment: EnvironmentFingerprint {
3023                    shipper_version: "0.2.0".to_string(),
3024                    cargo_version: Some("1.82.0".to_string()),
3025                    rust_version: Some("1.82.0".to_string()),
3026                    os: "linux".to_string(),
3027                    arch: "x86_64".to_string(),
3028                },
3029            };
3030            insta::assert_yaml_snapshot!(receipt);
3031        }
3032
3033        #[test]
3034        fn execution_state_snapshot() {
3035            let t = fixed_time();
3036            let mut packages = BTreeMap::new();
3037            packages.insert(
3038                "core-lib@0.1.0".to_string(),
3039                PackageProgress {
3040                    name: "core-lib".to_string(),
3041                    version: "0.1.0".to_string(),
3042                    attempts: 1,
3043                    state: PackageState::Published,
3044                    last_updated_at: t,
3045                },
3046            );
3047            packages.insert(
3048                "my-cli@0.2.0".to_string(),
3049                PackageProgress {
3050                    name: "my-cli".to_string(),
3051                    version: "0.2.0".to_string(),
3052                    attempts: 0,
3053                    state: PackageState::Pending,
3054                    last_updated_at: t,
3055                },
3056            );
3057            let state = ExecutionState {
3058                state_version: "shipper.state.v1".to_string(),
3059                plan_id: "plan-42".to_string(),
3060                registry: Registry::crates_io(),
3061                created_at: t,
3062                updated_at: t,
3063                packages,
3064            };
3065            insta::assert_yaml_snapshot!(state);
3066        }
3067
3068        #[test]
3069        fn preflight_report_snapshot() {
3070            let report = PreflightReport {
3071                plan_id: "plan-42".to_string(),
3072                token_detected: true,
3073                finishability: Finishability::Proven,
3074                packages: vec![
3075                    PreflightPackage {
3076                        name: "core-lib".to_string(),
3077                        version: "0.1.0".to_string(),
3078                        already_published: false,
3079                        is_new_crate: true,
3080                        auth_type: Some(AuthType::Token),
3081                        ownership_verified: true,
3082                        dry_run_passed: true,
3083                        dry_run_output: None,
3084                    },
3085                    PreflightPackage {
3086                        name: "my-cli".to_string(),
3087                        version: "0.2.0".to_string(),
3088                        already_published: false,
3089                        is_new_crate: false,
3090                        auth_type: Some(AuthType::TrustedPublishing),
3091                        ownership_verified: true,
3092                        dry_run_passed: true,
3093                        dry_run_output: Some("dry-run ok".to_string()),
3094                    },
3095                ],
3096                timestamp: fixed_time(),
3097                dry_run_output: Some("workspace dry-run passed".to_string()),
3098            };
3099            insta::assert_yaml_snapshot!(report);
3100        }
3101
3102        // --- ReleasePlan variations ---
3103
3104        #[test]
3105        fn release_plan_single_package() {
3106            let plan = ReleasePlan {
3107                plan_version: "shipper.plan.v1".to_string(),
3108                plan_id: "single-pkg-001".to_string(),
3109                created_at: fixed_time(),
3110                registry: Registry::crates_io(),
3111                packages: vec![PlannedPackage {
3112                    name: "solo-crate".to_string(),
3113                    version: "1.0.0".to_string(),
3114                    manifest_path: PathBuf::from("Cargo.toml"),
3115                }],
3116                dependencies: BTreeMap::new(),
3117            };
3118            insta::assert_yaml_snapshot!(plan);
3119        }
3120
3121        #[test]
3122        fn release_plan_custom_registry() {
3123            let plan = ReleasePlan {
3124                plan_version: "shipper.plan.v1".to_string(),
3125                plan_id: "custom-reg-001".to_string(),
3126                created_at: fixed_time(),
3127                registry: Registry {
3128                    name: "my-private-registry".to_string(),
3129                    api_base: "https://registry.example.com".to_string(),
3130                    index_base: Some("https://index.registry.example.com".to_string()),
3131                },
3132                packages: vec![
3133                    PlannedPackage {
3134                        name: "internal-utils".to_string(),
3135                        version: "2.1.0".to_string(),
3136                        manifest_path: PathBuf::from("crates/internal-utils/Cargo.toml"),
3137                    },
3138                    PlannedPackage {
3139                        name: "internal-api".to_string(),
3140                        version: "3.0.0".to_string(),
3141                        manifest_path: PathBuf::from("crates/internal-api/Cargo.toml"),
3142                    },
3143                ],
3144                dependencies: BTreeMap::from([(
3145                    "internal-api".to_string(),
3146                    vec!["internal-utils".to_string()],
3147                )]),
3148            };
3149            insta::assert_yaml_snapshot!(plan);
3150        }
3151
3152        #[test]
3153        fn release_plan_deep_dependency_chain() {
3154            let plan = ReleasePlan {
3155                plan_version: "shipper.plan.v1".to_string(),
3156                plan_id: "deep-deps-001".to_string(),
3157                created_at: fixed_time(),
3158                registry: Registry::crates_io(),
3159                packages: vec![
3160                    PlannedPackage {
3161                        name: "foundation".to_string(),
3162                        version: "0.1.0".to_string(),
3163                        manifest_path: PathBuf::from("crates/foundation/Cargo.toml"),
3164                    },
3165                    PlannedPackage {
3166                        name: "middleware".to_string(),
3167                        version: "0.2.0".to_string(),
3168                        manifest_path: PathBuf::from("crates/middleware/Cargo.toml"),
3169                    },
3170                    PlannedPackage {
3171                        name: "service".to_string(),
3172                        version: "0.3.0".to_string(),
3173                        manifest_path: PathBuf::from("crates/service/Cargo.toml"),
3174                    },
3175                    PlannedPackage {
3176                        name: "gateway".to_string(),
3177                        version: "1.0.0".to_string(),
3178                        manifest_path: PathBuf::from("crates/gateway/Cargo.toml"),
3179                    },
3180                ],
3181                dependencies: BTreeMap::from([
3182                    ("foundation".to_string(), Vec::new()),
3183                    ("middleware".to_string(), vec!["foundation".to_string()]),
3184                    (
3185                        "service".to_string(),
3186                        vec!["foundation".to_string(), "middleware".to_string()],
3187                    ),
3188                    ("gateway".to_string(), vec!["service".to_string()]),
3189                ]),
3190            };
3191            insta::assert_yaml_snapshot!(plan);
3192        }
3193
3194        // --- Receipt variations ---
3195
3196        #[test]
3197        fn receipt_partial_failure() {
3198            let t = fixed_time();
3199            let receipt = Receipt {
3200                receipt_version: "shipper.receipt.v1".to_string(),
3201                plan_id: "plan-partial".to_string(),
3202                registry: Registry::crates_io(),
3203                started_at: t,
3204                finished_at: t,
3205                packages: vec![
3206                    PackageReceipt {
3207                        name: "core-lib".to_string(),
3208                        version: "0.1.0".to_string(),
3209                        attempts: 1,
3210                        state: PackageState::Published,
3211                        started_at: t,
3212                        finished_at: t,
3213                        duration_ms: 3200,
3214                        evidence: PackageEvidence {
3215                            attempts: vec![AttemptEvidence {
3216                                attempt_number: 1,
3217                                command: "cargo publish -p core-lib".to_string(),
3218                                exit_code: 0,
3219                                stdout_tail: "Uploading core-lib v0.1.0".to_string(),
3220                                stderr_tail: String::new(),
3221                                timestamp: t,
3222                                duration: Duration::from_millis(3000),
3223                            }],
3224                            readiness_checks: vec![ReadinessEvidence {
3225                                attempt: 1,
3226                                visible: true,
3227                                timestamp: t,
3228                                delay_before: Duration::from_secs(1),
3229                            }],
3230                        },
3231                        compromised_at: None,
3232                        compromised_by: None,
3233                        superseded_by: None,
3234                    },
3235                    PackageReceipt {
3236                        name: "api-server".to_string(),
3237                        version: "0.2.0".to_string(),
3238                        attempts: 3,
3239                        state: PackageState::Failed {
3240                            class: ErrorClass::Retryable,
3241                            message: "rate limited by registry".to_string(),
3242                        },
3243                        started_at: t,
3244                        finished_at: t,
3245                        duration_ms: 15000,
3246                        evidence: PackageEvidence {
3247                            attempts: vec![
3248                                AttemptEvidence {
3249                                    attempt_number: 1,
3250                                    command: "cargo publish -p api-server".to_string(),
3251                                    exit_code: 1,
3252                                    stdout_tail: String::new(),
3253                                    stderr_tail: "error: rate limit exceeded".to_string(),
3254                                    timestamp: t,
3255                                    duration: Duration::from_millis(500),
3256                                },
3257                                AttemptEvidence {
3258                                    attempt_number: 2,
3259                                    command: "cargo publish -p api-server".to_string(),
3260                                    exit_code: 1,
3261                                    stdout_tail: String::new(),
3262                                    stderr_tail: "error: rate limit exceeded".to_string(),
3263                                    timestamp: t,
3264                                    duration: Duration::from_millis(600),
3265                                },
3266                                AttemptEvidence {
3267                                    attempt_number: 3,
3268                                    command: "cargo publish -p api-server".to_string(),
3269                                    exit_code: 1,
3270                                    stdout_tail: String::new(),
3271                                    stderr_tail: "error: rate limit exceeded".to_string(),
3272                                    timestamp: t,
3273                                    duration: Duration::from_millis(700),
3274                                },
3275                            ],
3276                            readiness_checks: vec![],
3277                        },
3278                        compromised_at: None,
3279                        compromised_by: None,
3280                        superseded_by: None,
3281                    },
3282                    PackageReceipt {
3283                        name: "old-compat".to_string(),
3284                        version: "0.1.0".to_string(),
3285                        attempts: 0,
3286                        state: PackageState::Skipped {
3287                            reason: "version already exists on registry".to_string(),
3288                        },
3289                        started_at: t,
3290                        finished_at: t,
3291                        duration_ms: 50,
3292                        evidence: PackageEvidence {
3293                            attempts: vec![],
3294                            readiness_checks: vec![],
3295                        },
3296                        compromised_at: None,
3297                        compromised_by: None,
3298                        superseded_by: None,
3299                    },
3300                ],
3301                event_log_path: PathBuf::from(".shipper/events.jsonl"),
3302                git_context: Some(GitContext {
3303                    commit: Some("deadbeef12345678".to_string()),
3304                    branch: Some("release/v0.2".to_string()),
3305                    tag: None,
3306                    dirty: Some(true),
3307                }),
3308                environment: EnvironmentFingerprint {
3309                    shipper_version: "0.3.0".to_string(),
3310                    cargo_version: Some("1.82.0".to_string()),
3311                    rust_version: Some("1.82.0".to_string()),
3312                    os: "linux".to_string(),
3313                    arch: "x86_64".to_string(),
3314                },
3315            };
3316            insta::assert_yaml_snapshot!(receipt);
3317        }
3318
3319        #[test]
3320        fn receipt_no_git_context() {
3321            let t = fixed_time();
3322            let receipt = Receipt {
3323                receipt_version: "shipper.receipt.v1".to_string(),
3324                plan_id: "plan-nogit".to_string(),
3325                registry: Registry::crates_io(),
3326                started_at: t,
3327                finished_at: t,
3328                packages: vec![PackageReceipt {
3329                    name: "headless-lib".to_string(),
3330                    version: "0.5.0".to_string(),
3331                    attempts: 1,
3332                    state: PackageState::Published,
3333                    started_at: t,
3334                    finished_at: t,
3335                    duration_ms: 2000,
3336                    evidence: PackageEvidence {
3337                        attempts: vec![],
3338                        readiness_checks: vec![],
3339                    },
3340                    compromised_at: None,
3341                    compromised_by: None,
3342                    superseded_by: None,
3343                }],
3344                event_log_path: PathBuf::from(".shipper/events.jsonl"),
3345                git_context: None,
3346                environment: EnvironmentFingerprint {
3347                    shipper_version: "0.3.0".to_string(),
3348                    cargo_version: None,
3349                    rust_version: None,
3350                    os: "windows".to_string(),
3351                    arch: "aarch64".to_string(),
3352                },
3353            };
3354            insta::assert_yaml_snapshot!(receipt);
3355        }
3356
3357        #[test]
3358        fn receipt_complete_failure() {
3359            let t = fixed_time();
3360            let receipt = Receipt {
3361                receipt_version: "shipper.receipt.v1".to_string(),
3362                plan_id: "plan-allfail".to_string(),
3363                registry: Registry::crates_io(),
3364                started_at: t,
3365                finished_at: t,
3366                packages: vec![
3367                    PackageReceipt {
3368                        name: "broken-crate".to_string(),
3369                        version: "0.1.0".to_string(),
3370                        attempts: 3,
3371                        state: PackageState::Failed {
3372                            class: ErrorClass::Permanent,
3373                            message: "invalid credentials".to_string(),
3374                        },
3375                        started_at: t,
3376                        finished_at: t,
3377                        duration_ms: 800,
3378                        evidence: PackageEvidence {
3379                            attempts: vec![AttemptEvidence {
3380                                attempt_number: 1,
3381                                command: "cargo publish -p broken-crate".to_string(),
3382                                exit_code: 1,
3383                                stdout_tail: String::new(),
3384                                stderr_tail: "error: 403 Forbidden".to_string(),
3385                                timestamp: t,
3386                                duration: Duration::from_millis(200),
3387                            }],
3388                            readiness_checks: vec![],
3389                        },
3390                        compromised_at: None,
3391                        compromised_by: None,
3392                        superseded_by: None,
3393                    },
3394                    PackageReceipt {
3395                        name: "dependent-crate".to_string(),
3396                        version: "0.2.0".to_string(),
3397                        attempts: 0,
3398                        state: PackageState::Skipped {
3399                            reason: "dependency broken-crate failed".to_string(),
3400                        },
3401                        started_at: t,
3402                        finished_at: t,
3403                        duration_ms: 0,
3404                        evidence: PackageEvidence {
3405                            attempts: vec![],
3406                            readiness_checks: vec![],
3407                        },
3408                        compromised_at: None,
3409                        compromised_by: None,
3410                        superseded_by: None,
3411                    },
3412                ],
3413                event_log_path: PathBuf::from(".shipper/events.jsonl"),
3414                git_context: Some(GitContext {
3415                    commit: Some("abcdef0123456789".to_string()),
3416                    branch: Some("main".to_string()),
3417                    tag: Some("v0.1.0".to_string()),
3418                    dirty: Some(false),
3419                }),
3420                environment: EnvironmentFingerprint {
3421                    shipper_version: "0.3.0".to_string(),
3422                    cargo_version: Some("1.82.0".to_string()),
3423                    rust_version: Some("1.82.0".to_string()),
3424                    os: "macos".to_string(),
3425                    arch: "aarch64".to_string(),
3426                },
3427            };
3428            insta::assert_yaml_snapshot!(receipt);
3429        }
3430
3431        // --- ExecutionState variations ---
3432
3433        #[test]
3434        fn execution_state_all_pending() {
3435            let t = fixed_time();
3436            let mut packages = BTreeMap::new();
3437            for (name, ver) in [("alpha", "0.1.0"), ("beta", "0.2.0"), ("gamma", "0.3.0")] {
3438                packages.insert(
3439                    format!("{name}@{ver}"),
3440                    PackageProgress {
3441                        name: name.to_string(),
3442                        version: ver.to_string(),
3443                        attempts: 0,
3444                        state: PackageState::Pending,
3445                        last_updated_at: t,
3446                    },
3447                );
3448            }
3449            let state = ExecutionState {
3450                state_version: "shipper.state.v1".to_string(),
3451                plan_id: "plan-fresh".to_string(),
3452                registry: Registry::crates_io(),
3453                created_at: t,
3454                updated_at: t,
3455                packages,
3456            };
3457            insta::assert_yaml_snapshot!(state);
3458        }
3459
3460        #[test]
3461        fn execution_state_completed() {
3462            let t = fixed_time();
3463            let mut packages = BTreeMap::new();
3464            for (name, ver) in [("alpha", "0.1.0"), ("beta", "0.2.0")] {
3465                packages.insert(
3466                    format!("{name}@{ver}"),
3467                    PackageProgress {
3468                        name: name.to_string(),
3469                        version: ver.to_string(),
3470                        attempts: 1,
3471                        state: PackageState::Published,
3472                        last_updated_at: t,
3473                    },
3474                );
3475            }
3476            let state = ExecutionState {
3477                state_version: "shipper.state.v1".to_string(),
3478                plan_id: "plan-done".to_string(),
3479                registry: Registry::crates_io(),
3480                created_at: t,
3481                updated_at: t,
3482                packages,
3483            };
3484            insta::assert_yaml_snapshot!(state);
3485        }
3486
3487        #[test]
3488        fn execution_state_mixed_with_failures() {
3489            let t = fixed_time();
3490            let mut packages = BTreeMap::new();
3491            packages.insert(
3492                "core@0.1.0".to_string(),
3493                PackageProgress {
3494                    name: "core".to_string(),
3495                    version: "0.1.0".to_string(),
3496                    attempts: 1,
3497                    state: PackageState::Published,
3498                    last_updated_at: t,
3499                },
3500            );
3501            packages.insert(
3502                "net@0.2.0".to_string(),
3503                PackageProgress {
3504                    name: "net".to_string(),
3505                    version: "0.2.0".to_string(),
3506                    attempts: 3,
3507                    state: PackageState::Failed {
3508                        class: ErrorClass::Retryable,
3509                        message: "connection reset".to_string(),
3510                    },
3511                    last_updated_at: t,
3512                },
3513            );
3514            packages.insert(
3515                "cli@0.3.0".to_string(),
3516                PackageProgress {
3517                    name: "cli".to_string(),
3518                    version: "0.3.0".to_string(),
3519                    attempts: 1,
3520                    state: PackageState::Ambiguous {
3521                        message: "upload succeeded but readiness timed out".to_string(),
3522                    },
3523                    last_updated_at: t,
3524                },
3525            );
3526            packages.insert(
3527                "compat@0.1.0".to_string(),
3528                PackageProgress {
3529                    name: "compat".to_string(),
3530                    version: "0.1.0".to_string(),
3531                    attempts: 0,
3532                    state: PackageState::Skipped {
3533                        reason: "version already on registry".to_string(),
3534                    },
3535                    last_updated_at: t,
3536                },
3537            );
3538            let state = ExecutionState {
3539                state_version: "shipper.state.v1".to_string(),
3540                plan_id: "plan-mixed".to_string(),
3541                registry: Registry::crates_io(),
3542                created_at: t,
3543                updated_at: t,
3544                packages,
3545            };
3546            insta::assert_yaml_snapshot!(state);
3547        }
3548
3549        // --- Config snapshots ---
3550
3551        #[test]
3552        fn readiness_config_default_snapshot() {
3553            let config = ReadinessConfig::default();
3554            insta::assert_yaml_snapshot!(config);
3555        }
3556
3557        #[test]
3558        fn readiness_config_custom_snapshot() {
3559            let config = ReadinessConfig {
3560                enabled: false,
3561                method: ReadinessMethod::Both,
3562                initial_delay: Duration::from_millis(500),
3563                max_delay: Duration::from_secs(120),
3564                max_total_wait: Duration::from_secs(900),
3565                poll_interval: Duration::from_secs(10),
3566                jitter_factor: 0.25,
3567                index_path: Some(PathBuf::from("/tmp/test-index")),
3568                prefer_index: true,
3569            };
3570            insta::assert_yaml_snapshot!(config);
3571        }
3572
3573        #[test]
3574        fn parallel_config_default_snapshot() {
3575            let config = ParallelConfig::default();
3576            insta::assert_yaml_snapshot!(config);
3577        }
3578
3579        #[test]
3580        fn parallel_config_enabled_snapshot() {
3581            let config = ParallelConfig {
3582                enabled: true,
3583                max_concurrent: 8,
3584                per_package_timeout: Duration::from_secs(600),
3585            };
3586            insta::assert_yaml_snapshot!(config);
3587        }
3588
3589        // --- Ancillary type snapshots ---
3590
3591        #[test]
3592        fn environment_fingerprint_snapshot() {
3593            let fp = EnvironmentFingerprint {
3594                shipper_version: "0.3.0".to_string(),
3595                cargo_version: Some("1.82.0".to_string()),
3596                rust_version: Some("1.82.0".to_string()),
3597                os: "linux".to_string(),
3598                arch: "x86_64".to_string(),
3599            };
3600            insta::assert_yaml_snapshot!(fp);
3601        }
3602
3603        #[test]
3604        fn git_context_full_snapshot() {
3605            let ctx = GitContext {
3606                commit: Some("a1b2c3d4e5f6".to_string()),
3607                branch: Some("release/v2.0".to_string()),
3608                tag: Some("v2.0.0".to_string()),
3609                dirty: Some(false),
3610            };
3611            insta::assert_yaml_snapshot!(ctx);
3612        }
3613
3614        #[test]
3615        fn git_context_minimal_snapshot() {
3616            let ctx = GitContext {
3617                commit: None,
3618                branch: None,
3619                tag: None,
3620                dirty: None,
3621            };
3622            insta::assert_yaml_snapshot!(ctx);
3623        }
3624
3625        #[test]
3626        fn publish_event_lifecycle_snapshot() {
3627            let t = fixed_time();
3628            let events = vec![
3629                PublishEvent {
3630                    timestamp: t,
3631                    event_type: EventType::PlanCreated {
3632                        plan_id: "plan-99".to_string(),
3633                        package_count: 3,
3634                    },
3635                    package: String::new(),
3636                },
3637                PublishEvent {
3638                    timestamp: t,
3639                    event_type: EventType::ExecutionStarted,
3640                    package: String::new(),
3641                },
3642                PublishEvent {
3643                    timestamp: t,
3644                    event_type: EventType::ExecutionFinished {
3645                        result: ExecutionResult::PartialFailure,
3646                    },
3647                    package: String::new(),
3648                },
3649            ];
3650            insta::assert_yaml_snapshot!(events);
3651        }
3652
3653        #[test]
3654        fn publish_event_package_flow_snapshot() {
3655            let t = fixed_time();
3656            let events = vec![
3657                PublishEvent {
3658                    timestamp: t,
3659                    event_type: EventType::PackageStarted {
3660                        name: "my-crate".to_string(),
3661                        version: "1.0.0".to_string(),
3662                    },
3663                    package: "my-crate@1.0.0".to_string(),
3664                },
3665                PublishEvent {
3666                    timestamp: t,
3667                    event_type: EventType::PackageAttempted {
3668                        attempt: 1,
3669                        command: "cargo publish -p my-crate".to_string(),
3670                    },
3671                    package: "my-crate@1.0.0".to_string(),
3672                },
3673                PublishEvent {
3674                    timestamp: t,
3675                    event_type: EventType::PackageOutput {
3676                        stdout_tail: "Uploading my-crate v1.0.0".to_string(),
3677                        stderr_tail: String::new(),
3678                    },
3679                    package: "my-crate@1.0.0".to_string(),
3680                },
3681                PublishEvent {
3682                    timestamp: t,
3683                    event_type: EventType::PackagePublished { duration_ms: 4500 },
3684                    package: "my-crate@1.0.0".to_string(),
3685                },
3686            ];
3687            insta::assert_yaml_snapshot!(events);
3688        }
3689
3690        #[test]
3691        fn error_class_all_variants_snapshot() {
3692            let variants: Vec<(&str, ErrorClass)> = vec![
3693                ("retryable", ErrorClass::Retryable),
3694                ("permanent", ErrorClass::Permanent),
3695                ("ambiguous", ErrorClass::Ambiguous),
3696            ];
3697            for (label, class) in variants {
3698                insta::assert_yaml_snapshot!(format!("error_class_{label}"), class);
3699            }
3700        }
3701
3702        #[test]
3703        fn execution_result_all_variants_snapshot() {
3704            let variants: Vec<(&str, ExecutionResult)> = vec![
3705                ("success", ExecutionResult::Success),
3706                ("partial_failure", ExecutionResult::PartialFailure),
3707                ("complete_failure", ExecutionResult::CompleteFailure),
3708            ];
3709            for (label, result) in variants {
3710                insta::assert_yaml_snapshot!(format!("execution_result_{label}"), result);
3711            }
3712        }
3713
3714        #[test]
3715        fn finishability_all_variants_snapshot() {
3716            let variants: Vec<(&str, Finishability)> = vec![
3717                ("proven", Finishability::Proven),
3718                ("not_proven", Finishability::NotProven),
3719                ("failed", Finishability::Failed),
3720            ];
3721            for (label, fin) in variants {
3722                insta::assert_yaml_snapshot!(format!("finishability_{label}"), fin);
3723            }
3724        }
3725
3726        #[test]
3727        fn preflight_report_failed_snapshot() {
3728            let report = PreflightReport {
3729                plan_id: "plan-fail-preflight".to_string(),
3730                token_detected: false,
3731                finishability: Finishability::Failed,
3732                packages: vec![PreflightPackage {
3733                    name: "broken".to_string(),
3734                    version: "0.1.0".to_string(),
3735                    already_published: false,
3736                    is_new_crate: true,
3737                    auth_type: None,
3738                    ownership_verified: false,
3739                    dry_run_passed: false,
3740                    dry_run_output: Some("error: could not compile".to_string()),
3741                }],
3742                timestamp: fixed_time(),
3743                dry_run_output: Some("workspace dry-run failed".to_string()),
3744            };
3745            insta::assert_yaml_snapshot!(report);
3746        }
3747    }
3748
3749    // Property-based tests using proptest
3750
3751    #[cfg(test)]
3752    mod proptests {
3753        use super::*;
3754        use proptest::prelude::*;
3755
3756        proptest! {
3757            // Preflight report serialization/deserialization roundtrip
3758            #[test]
3759            fn preflight_report_roundtrip(
3760                plan_id in "[a-z0-9-]+",
3761                token_detected in any::<bool>(),
3762                finishability_variant in 0u8..3,
3763                package_count in 0usize..10,
3764            ) {
3765                let finishability = match finishability_variant {
3766                    0 => Finishability::Proven,
3767                    1 => Finishability::NotProven,
3768                    _ => Finishability::Failed,
3769                };
3770
3771                let packages: Vec<PreflightPackage> = (0..package_count)
3772                    .map(|i| PreflightPackage {
3773                        name: format!("crate-{}", i),
3774                        version: format!("0.{}.0", i),
3775                        already_published: i % 2 == 0,
3776                        is_new_crate: i % 3 == 0,
3777                        auth_type: if i % 2 == 0 { Some(AuthType::Token) } else { None },
3778                        ownership_verified: i % 3 != 0,
3779                        dry_run_passed: i % 5 != 0,
3780                        dry_run_output: if i % 5 == 0 { Some("failed".to_string()) } else { None },
3781                    })
3782                    .collect();
3783
3784                let report = PreflightReport {
3785                    plan_id: plan_id.clone(),
3786                    token_detected,
3787                    finishability,
3788                    packages: packages.clone(),
3789                    timestamp: Utc::now(),
3790                    dry_run_output: Some("workspace dry-run output".to_string()),
3791                };
3792
3793                // Serialize and deserialize
3794                let json = serde_json::to_string(&report).unwrap();
3795                let parsed: PreflightReport = serde_json::from_str(&json).unwrap();
3796
3797                // Verify roundtrip
3798                assert_eq!(parsed.plan_id, report.plan_id);
3799                assert_eq!(parsed.token_detected, report.token_detected);
3800                assert_eq!(parsed.finishability, report.finishability);
3801                assert_eq!(parsed.packages.len(), report.packages.len());
3802                assert_eq!(parsed.dry_run_output, report.dry_run_output);
3803                for (orig, parsed_pkg) in report.packages.iter().zip(parsed.packages.iter()) {
3804                    assert_eq!(parsed_pkg.name, orig.name);
3805                    assert_eq!(parsed_pkg.version, orig.version);
3806                    assert_eq!(parsed_pkg.already_published, orig.already_published);
3807                    assert_eq!(parsed_pkg.is_new_crate, orig.is_new_crate);
3808                    assert_eq!(parsed_pkg.auth_type, orig.auth_type);
3809                    assert_eq!(parsed_pkg.ownership_verified, orig.ownership_verified);
3810                    assert_eq!(parsed_pkg.dry_run_passed, orig.dry_run_passed);
3811                    assert_eq!(parsed_pkg.dry_run_output, orig.dry_run_output);
3812                }
3813            }
3814
3815            // Preflight package serialization roundtrip
3816            #[test]
3817            fn preflight_package_roundtrip(
3818                name in "[a-z][a-z0-9-]*",
3819                version in "[0-9]+\\.[0-9]+\\.[0-9]+",
3820                already_published in any::<bool>(),
3821                is_new_crate in any::<bool>(),
3822                auth_type_variant in 0u8..4,
3823                ownership_verified in any::<bool>(),
3824                dry_run_passed in any::<bool>(),
3825                dry_run_output in proptest::option::of(".*"),
3826            ) {
3827                let auth_type = match auth_type_variant {
3828                    0 => Some(AuthType::Token),
3829                    1 => Some(AuthType::TrustedPublishing),
3830                    2 => Some(AuthType::Unknown),
3831                    _ => None,
3832                };
3833
3834                let pkg = PreflightPackage {
3835                    name: name.clone(),
3836                    version: version.clone(),
3837                    already_published,
3838                    is_new_crate,
3839                    auth_type: auth_type.clone(),
3840                    ownership_verified,
3841                    dry_run_passed,
3842                    dry_run_output: dry_run_output.clone(),
3843                };
3844
3845                // Serialize and deserialize
3846                let json = serde_json::to_string(&pkg).unwrap();
3847                let parsed: PreflightPackage = serde_json::from_str(&json).unwrap();
3848
3849                // Verify roundtrip
3850                assert_eq!(parsed.name, pkg.name);
3851                assert_eq!(parsed.version, pkg.version);
3852                assert_eq!(parsed.already_published, pkg.already_published);
3853                assert_eq!(parsed.is_new_crate, pkg.is_new_crate);
3854                assert_eq!(parsed.auth_type, pkg.auth_type);
3855                assert_eq!(parsed.ownership_verified, pkg.ownership_verified);
3856                assert_eq!(parsed.dry_run_passed, pkg.dry_run_passed);
3857                assert_eq!(parsed.dry_run_output, pkg.dry_run_output);
3858            }
3859
3860            // AuthType serialization roundtrip
3861            #[test]
3862            fn auth_type_roundtrip(auth_type_variant in 0u8..3) {
3863                let auth_type = match auth_type_variant {
3864                    0 => AuthType::Token,
3865                    1 => AuthType::TrustedPublishing,
3866                    _ => AuthType::Unknown,
3867                };
3868
3869                let json = serde_json::to_string(&auth_type).unwrap();
3870                let parsed: AuthType = serde_json::from_str(&json).unwrap();
3871
3872                assert_eq!(parsed, auth_type);
3873            }
3874
3875            // Finishability serialization roundtrip
3876            #[test]
3877            fn finishability_roundtrip(finishability_variant in 0u8..3) {
3878                let finishability = match finishability_variant {
3879                    0 => Finishability::Proven,
3880                    1 => Finishability::NotProven,
3881                    _ => Finishability::Failed,
3882                };
3883
3884                let json = serde_json::to_string(&finishability).unwrap();
3885                let parsed: Finishability = serde_json::from_str(&json).unwrap();
3886
3887                assert_eq!(parsed, finishability);
3888            }
3889
3890            // EnvironmentFingerprint serialization roundtrip
3891            #[test]
3892            fn environment_fingerprint_roundtrip(
3893                shipper_version in "[0-9]+\\.[0-9]+\\.[0-9]+",
3894                cargo_version in prop::option::of("[0-9]+\\.[0-9]+\\.[0-9]+"),
3895                rust_version in prop::option::of("[0-9]+\\.[0-9]+\\.[0-9]+"),
3896                os in "[a-z]+",
3897                arch in "[a-z0-9_]+",
3898            ) {
3899                let fingerprint = EnvironmentFingerprint {
3900                    shipper_version: shipper_version.clone(),
3901                    cargo_version: cargo_version.clone(),
3902                    rust_version: rust_version.clone(),
3903                    os: os.clone(),
3904                    arch: arch.clone(),
3905                };
3906
3907                // Serialize and deserialize
3908                let json = serde_json::to_string(&fingerprint).unwrap();
3909                let parsed: EnvironmentFingerprint = serde_json::from_str(&json).unwrap();
3910
3911                // Verify roundtrip
3912                assert_eq!(parsed.shipper_version, fingerprint.shipper_version);
3913                assert_eq!(parsed.cargo_version, fingerprint.cargo_version);
3914                assert_eq!(parsed.rust_version, fingerprint.rust_version);
3915                assert_eq!(parsed.os, fingerprint.os);
3916                assert_eq!(parsed.arch, fingerprint.arch);
3917            }
3918
3919            // GitContext serialization roundtrip
3920            #[test]
3921            fn git_context_roundtrip(
3922                commit in prop::option::of("[a-f0-9]+"),
3923                branch in prop::option::of("[a-z0-9-]+"),
3924                tag in prop::option::of("[a-z0-9-\\.]+"),
3925                dirty in prop::option::of(any::<bool>()),
3926            ) {
3927                let git_context = GitContext {
3928                    commit: commit.clone(),
3929                    branch: branch.clone(),
3930                    tag: tag.clone(),
3931                    dirty,
3932                };
3933
3934                // Serialize and deserialize
3935                let json = serde_json::to_string(&git_context).unwrap();
3936                let parsed: GitContext = serde_json::from_str(&json).unwrap();
3937
3938                // Verify roundtrip
3939                assert_eq!(parsed.commit, git_context.commit);
3940                assert_eq!(parsed.branch, git_context.branch);
3941                assert_eq!(parsed.tag, git_context.tag);
3942                assert_eq!(parsed.dirty, git_context.dirty);
3943            }
3944
3945            // Registry serialization roundtrip
3946            #[test]
3947            fn registry_roundtrip(
3948                name in "[a-z0-9-]+",
3949                api_base in "https?://[a-z0-9.-]+",
3950                index_base in prop::option::of("https?://[a-z0-9.-]+"),
3951            ) {
3952                let registry = Registry {
3953                    name: name.clone(),
3954                    api_base: api_base.clone(),
3955                    index_base: index_base.clone(),
3956                };
3957
3958                // Serialize and deserialize
3959                let json = serde_json::to_string(&registry).unwrap();
3960                let parsed: Registry = serde_json::from_str(&json).unwrap();
3961
3962                // Verify roundtrip
3963                assert_eq!(parsed.name, registry.name);
3964                assert_eq!(parsed.api_base, registry.api_base);
3965                assert_eq!(parsed.index_base, registry.index_base);
3966            }
3967
3968            // ReadinessConfig serialization roundtrip
3969            #[test]
3970            fn readiness_config_roundtrip(
3971                enabled in any::<bool>(),
3972                method_variant in 0u8..3,
3973                initial_delay_ms in 0u64..10000,
3974                max_delay_ms in 0u64..100000,
3975                max_total_wait_ms in 0u64..1000000,
3976                poll_interval_ms in 0u64..10000,
3977                jitter_factor in 0.0f64..1.0,
3978                prefer_index in any::<bool>(),
3979            ) {
3980                let method = match method_variant {
3981                    0 => ReadinessMethod::Api,
3982                    1 => ReadinessMethod::Index,
3983                    _ => ReadinessMethod::Both,
3984                };
3985
3986                let config = ReadinessConfig {
3987                    enabled,
3988                    method,
3989                    initial_delay: Duration::from_millis(initial_delay_ms),
3990                    max_delay: Duration::from_millis(max_delay_ms),
3991                    max_total_wait: Duration::from_millis(max_total_wait_ms),
3992                    poll_interval: Duration::from_millis(poll_interval_ms),
3993                    jitter_factor,
3994                    index_path: None,
3995                    prefer_index,
3996                };
3997
3998                // Serialize and deserialize
3999                let json = serde_json::to_string(&config).unwrap();
4000                let parsed: ReadinessConfig = serde_json::from_str(&json).unwrap();
4001
4002                // Verify roundtrip
4003                assert_eq!(parsed.enabled, config.enabled);
4004                assert_eq!(parsed.method, config.method);
4005                assert_eq!(parsed.initial_delay, config.initial_delay);
4006                assert_eq!(parsed.max_delay, config.max_delay);
4007                assert_eq!(parsed.max_total_wait, config.max_total_wait);
4008                assert_eq!(parsed.poll_interval, config.poll_interval);
4009                assert!((parsed.jitter_factor - config.jitter_factor).abs() < 1e-10,
4010                    "jitter_factor mismatch: {} vs {}", parsed.jitter_factor, config.jitter_factor);
4011                assert_eq!(parsed.prefer_index, config.prefer_index);
4012            }
4013
4014            // Index path calculation is deterministic
4015            #[test]
4016            fn index_path_deterministic(crate_name in "[a-z0-9-]+") {
4017                // Calculate the index path twice and verify it's the same
4018                let first = calculate_index_path_for_crate(&crate_name);
4019                let second = calculate_index_path_for_crate(&crate_name);
4020                assert_eq!(first, second, "Index path calculation should be deterministic");
4021            }
4022
4023            // Index path follows Cargo's sparse index scheme
4024            #[test]
4025            fn index_path_follows_pattern(crate_name in "[a-z0-9-]{3,20}") {
4026                let path = calculate_index_path_for_crate(&crate_name);
4027                let lower = crate_name.to_lowercase();
4028                let parts: Vec<&str> = path.split('/').collect();
4029
4030                match lower.len() {
4031                    3 => {
4032                        assert_eq!(parts.len(), 3, "3-char crate should have 3 parts");
4033                        assert_eq!(parts[0], "3");
4034                        assert_eq!(parts[1], &lower[..1]);
4035                        assert_eq!(parts[2], lower);
4036                    }
4037                    n if n >= 4 => {
4038                        assert_eq!(parts.len(), 3, "4+ char crate should have 3 parts");
4039                        assert_eq!(parts[0], &lower[..2]);
4040                        assert_eq!(parts[1], &lower[2..4]);
4041                        assert_eq!(parts[2], lower);
4042                    }
4043                    _ => unreachable!("regex guarantees at least 3 chars"),
4044                }
4045            }
4046
4047            // Schema version parsing is deterministic
4048            #[test]
4049            fn schema_version_parsing_deterministic(
4050                middle in "[a-z]+",
4051                version_num in 1u32..1000,
4052            ) {
4053                let version_str = format!("shipper.{}.v{}", middle, version_num);
4054
4055                let first = parse_schema_version_for_test(&version_str);
4056                let second = parse_schema_version_for_test(&version_str);
4057
4058                assert_eq!(first, second, "Schema version parsing should be deterministic");
4059                assert_eq!(first, Ok(version_num));
4060            }
4061        }
4062
4063        // --- PackageState roundtrip for all variants ---
4064        proptest! {
4065            #[test]
4066            fn package_state_pending_roundtrip(_dummy in 0u8..1) {
4067                let state = PackageState::Pending;
4068                let json = serde_json::to_string(&state).unwrap();
4069                let parsed: PackageState = serde_json::from_str(&json).unwrap();
4070                assert_eq!(parsed, state);
4071            }
4072
4073            #[test]
4074            fn package_state_uploaded_roundtrip(_dummy in 0u8..1) {
4075                let state = PackageState::Uploaded;
4076                let json = serde_json::to_string(&state).unwrap();
4077                let parsed: PackageState = serde_json::from_str(&json).unwrap();
4078                assert_eq!(parsed, state);
4079            }
4080
4081            #[test]
4082            fn package_state_published_roundtrip(_dummy in 0u8..1) {
4083                let state = PackageState::Published;
4084                let json = serde_json::to_string(&state).unwrap();
4085                let parsed: PackageState = serde_json::from_str(&json).unwrap();
4086                assert_eq!(parsed, state);
4087            }
4088
4089            #[test]
4090            fn package_state_skipped_roundtrip(reason in "\\PC{0,50}") {
4091                let state = PackageState::Skipped { reason };
4092                let json = serde_json::to_string(&state).unwrap();
4093                let parsed: PackageState = serde_json::from_str(&json).unwrap();
4094                assert_eq!(parsed, state);
4095            }
4096
4097            #[test]
4098            fn package_state_failed_roundtrip(
4099                class_variant in 0u8..3,
4100                message in "\\PC{0,80}",
4101            ) {
4102                let class = match class_variant {
4103                    0 => ErrorClass::Retryable,
4104                    1 => ErrorClass::Permanent,
4105                    _ => ErrorClass::Ambiguous,
4106                };
4107                let state = PackageState::Failed { class, message };
4108                let json = serde_json::to_string(&state).unwrap();
4109                let parsed: PackageState = serde_json::from_str(&json).unwrap();
4110                assert_eq!(parsed, state);
4111            }
4112
4113            #[test]
4114            fn package_state_ambiguous_roundtrip(message in "\\PC{0,80}") {
4115                let state = PackageState::Ambiguous { message };
4116                let json = serde_json::to_string(&state).unwrap();
4117                let parsed: PackageState = serde_json::from_str(&json).unwrap();
4118                assert_eq!(parsed, state);
4119            }
4120
4121            // --- ErrorClass roundtrip ---
4122            #[test]
4123            fn error_class_roundtrip(variant in 0u8..3) {
4124                let class = match variant {
4125                    0 => ErrorClass::Retryable,
4126                    1 => ErrorClass::Permanent,
4127                    _ => ErrorClass::Ambiguous,
4128                };
4129                let json = serde_json::to_string(&class).unwrap();
4130                let parsed: ErrorClass = serde_json::from_str(&json).unwrap();
4131                assert_eq!(parsed, class);
4132            }
4133
4134            // --- ExecutionResult roundtrip ---
4135            #[test]
4136            fn execution_result_roundtrip(variant in 0u8..3) {
4137                let result = match variant {
4138                    0 => ExecutionResult::Success,
4139                    1 => ExecutionResult::PartialFailure,
4140                    _ => ExecutionResult::CompleteFailure,
4141                };
4142                let json = serde_json::to_string(&result).unwrap();
4143                let parsed: ExecutionResult = serde_json::from_str(&json).unwrap();
4144                assert_eq!(parsed, result);
4145            }
4146
4147            // --- PublishPolicy roundtrip ---
4148            #[test]
4149            fn publish_policy_roundtrip(variant in 0u8..3) {
4150                let policy = match variant {
4151                    0 => PublishPolicy::Safe,
4152                    1 => PublishPolicy::Balanced,
4153                    _ => PublishPolicy::Fast,
4154                };
4155                let json = serde_json::to_string(&policy).unwrap();
4156                let parsed: PublishPolicy = serde_json::from_str(&json).unwrap();
4157                assert_eq!(parsed, policy);
4158            }
4159
4160            // --- VerifyMode roundtrip ---
4161            #[test]
4162            fn verify_mode_roundtrip(variant in 0u8..3) {
4163                let mode = match variant {
4164                    0 => VerifyMode::Workspace,
4165                    1 => VerifyMode::Package,
4166                    _ => VerifyMode::None,
4167                };
4168                let json = serde_json::to_string(&mode).unwrap();
4169                let parsed: VerifyMode = serde_json::from_str(&json).unwrap();
4170                assert_eq!(parsed, mode);
4171            }
4172
4173            // --- ReadinessMethod roundtrip ---
4174            #[test]
4175            fn readiness_method_roundtrip(variant in 0u8..3) {
4176                let method = match variant {
4177                    0 => ReadinessMethod::Api,
4178                    1 => ReadinessMethod::Index,
4179                    _ => ReadinessMethod::Both,
4180                };
4181                let json = serde_json::to_string(&method).unwrap();
4182                let parsed: ReadinessMethod = serde_json::from_str(&json).unwrap();
4183                assert_eq!(parsed, method);
4184            }
4185
4186            // --- PlannedPackage roundtrip ---
4187            #[test]
4188            fn planned_package_roundtrip(
4189                name in "[a-z][a-z0-9-]{0,20}",
4190                version in "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}",
4191            ) {
4192                let pkg = PlannedPackage {
4193                    name,
4194                    version,
4195                    manifest_path: PathBuf::from("crates/test/Cargo.toml"),
4196                };
4197                let json = serde_json::to_string(&pkg).unwrap();
4198                let parsed: PlannedPackage = serde_json::from_str(&json).unwrap();
4199                assert_eq!(parsed.name, pkg.name);
4200                assert_eq!(parsed.version, pkg.version);
4201                assert_eq!(parsed.manifest_path, pkg.manifest_path);
4202            }
4203
4204            // --- PublishLevel roundtrip ---
4205            #[test]
4206            fn publish_level_roundtrip(
4207                level in 0usize..10,
4208                pkg_count in 1usize..5,
4209            ) {
4210                let packages: Vec<PlannedPackage> = (0..pkg_count)
4211                    .map(|i| PlannedPackage {
4212                        name: format!("crate-{i}"),
4213                        version: format!("{i}.0.0"),
4214                        manifest_path: PathBuf::from(format!("crates/crate-{i}/Cargo.toml")),
4215                    })
4216                    .collect();
4217                let lvl = PublishLevel { level, packages };
4218                let json = serde_json::to_string(&lvl).unwrap();
4219                let parsed: PublishLevel = serde_json::from_str(&json).unwrap();
4220                assert_eq!(parsed.level, lvl.level);
4221                assert_eq!(parsed.packages.len(), lvl.packages.len());
4222            }
4223
4224            // --- ReleasePlan roundtrip ---
4225            #[test]
4226            fn release_plan_roundtrip(
4227                plan_id in "[a-f0-9]{8,64}",
4228                pkg_count in 1usize..5,
4229            ) {
4230                let packages: Vec<PlannedPackage> = (0..pkg_count)
4231                    .map(|i| PlannedPackage {
4232                        name: format!("crate-{i}"),
4233                        version: format!("{i}.0.0"),
4234                        manifest_path: PathBuf::from(format!("crates/crate-{i}/Cargo.toml")),
4235                    })
4236                    .collect();
4237                let mut deps = BTreeMap::new();
4238                if pkg_count > 1 {
4239                    deps.insert(
4240                        "crate-1".to_string(),
4241                        vec!["crate-0".to_string()],
4242                    );
4243                }
4244                let plan = ReleasePlan {
4245                    plan_version: "shipper.plan.v1".to_string(),
4246                    plan_id,
4247                    created_at: Utc::now(),
4248                    registry: Registry::crates_io(),
4249                    packages,
4250                    dependencies: deps,
4251                };
4252                let json = serde_json::to_string(&plan).unwrap();
4253                let parsed: ReleasePlan = serde_json::from_str(&json).unwrap();
4254                assert_eq!(parsed.plan_id, plan.plan_id);
4255                assert_eq!(parsed.plan_version, plan.plan_version);
4256                assert_eq!(parsed.packages.len(), plan.packages.len());
4257                assert_eq!(parsed.dependencies, plan.dependencies);
4258            }
4259
4260            // --- PackageProgress roundtrip ---
4261            #[test]
4262            fn package_progress_roundtrip(
4263                name in "[a-z][a-z0-9-]{0,15}",
4264                version in "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}",
4265                attempts in 0u32..10,
4266                state_variant in 0u8..4,
4267            ) {
4268                let state = match state_variant {
4269                    0 => PackageState::Pending,
4270                    1 => PackageState::Uploaded,
4271                    2 => PackageState::Published,
4272                    _ => PackageState::Skipped { reason: "already exists".to_string() },
4273                };
4274                let progress = PackageProgress {
4275                    name,
4276                    version,
4277                    attempts,
4278                    state,
4279                    last_updated_at: Utc::now(),
4280                };
4281                let json = serde_json::to_string(&progress).unwrap();
4282                let parsed: PackageProgress = serde_json::from_str(&json).unwrap();
4283                assert_eq!(parsed.name, progress.name);
4284                assert_eq!(parsed.version, progress.version);
4285                assert_eq!(parsed.attempts, progress.attempts);
4286                assert_eq!(parsed.state, progress.state);
4287            }
4288
4289            // --- ExecutionState roundtrip ---
4290            #[test]
4291            fn execution_state_roundtrip(
4292                plan_id in "[a-f0-9]{8,64}",
4293                pkg_count in 0usize..5,
4294            ) {
4295                let mut packages = BTreeMap::new();
4296                for i in 0..pkg_count {
4297                    let key = format!("crate-{i}@{i}.0.0");
4298                    packages.insert(key, PackageProgress {
4299                        name: format!("crate-{i}"),
4300                        version: format!("{i}.0.0"),
4301                        attempts: i as u32,
4302                        state: PackageState::Pending,
4303                        last_updated_at: Utc::now(),
4304                    });
4305                }
4306                let state = ExecutionState {
4307                    state_version: "shipper.state.v1".to_string(),
4308                    plan_id,
4309                    registry: Registry::crates_io(),
4310                    created_at: Utc::now(),
4311                    updated_at: Utc::now(),
4312                    packages,
4313                };
4314                let json = serde_json::to_string(&state).unwrap();
4315                let parsed: ExecutionState = serde_json::from_str(&json).unwrap();
4316                assert_eq!(parsed.plan_id, state.plan_id);
4317                assert_eq!(parsed.packages.len(), state.packages.len());
4318            }
4319
4320            // --- ParallelConfig roundtrip ---
4321            #[test]
4322            fn parallel_config_roundtrip(
4323                enabled in any::<bool>(),
4324                max_concurrent in 1usize..32,
4325                timeout_secs in 1u64..7200,
4326            ) {
4327                let config = ParallelConfig {
4328                    enabled,
4329                    max_concurrent,
4330                    per_package_timeout: Duration::from_secs(timeout_secs),
4331                };
4332                let json = serde_json::to_string(&config).unwrap();
4333                let parsed: ParallelConfig = serde_json::from_str(&json).unwrap();
4334                assert_eq!(parsed.enabled, config.enabled);
4335                assert_eq!(parsed.max_concurrent, config.max_concurrent);
4336                assert_eq!(parsed.per_package_timeout, config.per_package_timeout);
4337            }
4338
4339            // --- AttemptEvidence roundtrip ---
4340            #[test]
4341            fn attempt_evidence_roundtrip(
4342                attempt_number in 1u32..10,
4343                exit_code in -1i32..256,
4344                duration_ms in 0u64..600_000,
4345            ) {
4346                let evidence = AttemptEvidence {
4347                    attempt_number,
4348                    command: "cargo publish -p test".to_string(),
4349                    exit_code,
4350                    stdout_tail: "Uploading test v1.0.0".to_string(),
4351                    stderr_tail: String::new(),
4352                    timestamp: Utc::now(),
4353                    duration: Duration::from_millis(duration_ms),
4354                };
4355                let json = serde_json::to_string(&evidence).unwrap();
4356                let parsed: AttemptEvidence = serde_json::from_str(&json).unwrap();
4357                assert_eq!(parsed.attempt_number, evidence.attempt_number);
4358                assert_eq!(parsed.exit_code, evidence.exit_code);
4359                assert_eq!(parsed.duration, evidence.duration);
4360            }
4361
4362            // --- ReadinessEvidence roundtrip ---
4363            #[test]
4364            fn readiness_evidence_roundtrip(
4365                attempt in 1u32..20,
4366                visible in any::<bool>(),
4367                delay_ms in 0u64..120_000,
4368            ) {
4369                let evidence = ReadinessEvidence {
4370                    attempt,
4371                    visible,
4372                    timestamp: Utc::now(),
4373                    delay_before: Duration::from_millis(delay_ms),
4374                };
4375                let json = serde_json::to_string(&evidence).unwrap();
4376                let parsed: ReadinessEvidence = serde_json::from_str(&json).unwrap();
4377                assert_eq!(parsed.attempt, evidence.attempt);
4378                assert_eq!(parsed.visible, evidence.visible);
4379                assert_eq!(parsed.delay_before, evidence.delay_before);
4380            }
4381
4382            // --- PackageEvidence roundtrip ---
4383            #[test]
4384            fn package_evidence_roundtrip(attempt_count in 0usize..4) {
4385                let attempts: Vec<AttemptEvidence> = (0..attempt_count)
4386                    .map(|i| AttemptEvidence {
4387                        attempt_number: i as u32 + 1,
4388                        command: format!("cargo publish attempt {i}"),
4389                        exit_code: if i == attempt_count - 1 { 0 } else { 1 },
4390                        stdout_tail: "output".to_string(),
4391                        stderr_tail: String::new(),
4392                        timestamp: Utc::now(),
4393                        duration: Duration::from_secs(5),
4394                    })
4395                    .collect();
4396                let evidence = PackageEvidence {
4397                    attempts,
4398                    readiness_checks: vec![],
4399                };
4400                let json = serde_json::to_string(&evidence).unwrap();
4401                let parsed: PackageEvidence = serde_json::from_str(&json).unwrap();
4402                assert_eq!(parsed.attempts.len(), evidence.attempts.len());
4403            }
4404
4405            // --- PackageReceipt roundtrip ---
4406            #[test]
4407            fn package_receipt_roundtrip(
4408                name in "[a-z][a-z0-9-]{0,15}",
4409                version in "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}",
4410                attempts in 1u32..5,
4411                duration_ms in 0u128..600_000,
4412            ) {
4413                let now = Utc::now();
4414                let receipt = PackageReceipt {
4415                    name,
4416                    version,
4417                    attempts,
4418                    state: PackageState::Published,
4419                    started_at: now,
4420                    finished_at: now,
4421                    duration_ms,
4422                    evidence: PackageEvidence {
4423                        attempts: vec![],
4424                        readiness_checks: vec![],
4425                    },
4426                                    compromised_at: None,
4427                    compromised_by: None,
4428                    superseded_by: None,
4429                };
4430                let json = serde_json::to_string(&receipt).unwrap();
4431                let parsed: PackageReceipt = serde_json::from_str(&json).unwrap();
4432                assert_eq!(parsed.name, receipt.name);
4433                assert_eq!(parsed.version, receipt.version);
4434                assert_eq!(parsed.attempts, receipt.attempts);
4435                assert_eq!(parsed.state, receipt.state);
4436                assert_eq!(parsed.duration_ms, receipt.duration_ms);
4437            }
4438
4439            // --- Receipt roundtrip ---
4440            #[test]
4441            fn receipt_roundtrip(
4442                plan_id in "[a-f0-9]{8,64}",
4443                pkg_count in 0usize..3,
4444            ) {
4445                let now = Utc::now();
4446                let packages: Vec<PackageReceipt> = (0..pkg_count)
4447                    .map(|i| PackageReceipt {
4448                        name: format!("crate-{i}"),
4449                        version: format!("{i}.0.0"),
4450                        attempts: 1,
4451                        state: PackageState::Published,
4452                        started_at: now,
4453                        finished_at: now,
4454                        duration_ms: 1000,
4455                        evidence: PackageEvidence {
4456                            attempts: vec![],
4457                            readiness_checks: vec![],
4458                        },
4459                                            compromised_at: None,
4460                        compromised_by: None,
4461                        superseded_by: None,
4462                    })
4463                    .collect();
4464                let receipt = Receipt {
4465                    receipt_version: "shipper.receipt.v1".to_string(),
4466                    plan_id,
4467                    registry: Registry::crates_io(),
4468                    started_at: now,
4469                    finished_at: now,
4470                    packages,
4471                    event_log_path: PathBuf::from(".shipper/events.jsonl"),
4472                    git_context: Some(GitContext {
4473                        commit: Some("abc123".to_string()),
4474                        branch: Some("main".to_string()),
4475                        tag: None,
4476                        dirty: Some(false),
4477                    }),
4478                    environment: EnvironmentFingerprint {
4479                        shipper_version: "0.3.0".to_string(),
4480                        cargo_version: Some("1.80.0".to_string()),
4481                        rust_version: Some("1.80.0".to_string()),
4482                        os: "linux".to_string(),
4483                        arch: "x86_64".to_string(),
4484                    },
4485                };
4486                let json = serde_json::to_string(&receipt).unwrap();
4487                let parsed: Receipt = serde_json::from_str(&json).unwrap();
4488                assert_eq!(parsed.plan_id, receipt.plan_id);
4489                assert_eq!(parsed.packages.len(), receipt.packages.len());
4490                assert_eq!(parsed.receipt_version, receipt.receipt_version);
4491                assert!(parsed.git_context.is_some());
4492            }
4493
4494            // --- PublishEvent roundtrip ---
4495            #[test]
4496            fn publish_event_roundtrip(variant in 0u8..5) {
4497                let event_type = match variant {
4498                    0 => EventType::ExecutionStarted,
4499                    1 => EventType::PlanCreated {
4500                        plan_id: "abc".to_string(),
4501                        package_count: 3,
4502                    },
4503                    2 => EventType::PackageStarted {
4504                        name: "test".to_string(),
4505                        version: "1.0.0".to_string(),
4506                    },
4507                    3 => EventType::PackageFailed {
4508                        class: ErrorClass::Retryable,
4509                        message: "timeout".to_string(),
4510                    },
4511                    _ => EventType::ExecutionFinished {
4512                        result: ExecutionResult::Success,
4513                    },
4514                };
4515                let event = PublishEvent {
4516                    timestamp: Utc::now(),
4517                    event_type,
4518                    package: "test@1.0.0".to_string(),
4519                };
4520                let json = serde_json::to_string(&event).unwrap();
4521                let parsed: PublishEvent = serde_json::from_str(&json).unwrap();
4522                assert_eq!(parsed.package, event.package);
4523            }
4524
4525            // --- EventType all variants roundtrip ---
4526            #[test]
4527            fn event_type_all_variants_roundtrip(variant in 0u8..18) {
4528                let event_type = match variant {
4529                    0 => EventType::PlanCreated { plan_id: "id1".to_string(), package_count: 5 },
4530                    1 => EventType::ExecutionStarted,
4531                    2 => EventType::ExecutionFinished { result: ExecutionResult::Success },
4532                    3 => EventType::PackageStarted { name: "a".to_string(), version: "1.0.0".to_string() },
4533                    4 => EventType::PackageAttempted { attempt: 1, command: "cargo publish".to_string() },
4534                    5 => EventType::PackageOutput { stdout_tail: "ok".to_string(), stderr_tail: "".to_string() },
4535                    6 => EventType::PackagePublished { duration_ms: 100 },
4536                    7 => EventType::PackageFailed { class: ErrorClass::Retryable, message: "err".to_string() },
4537                    8 => EventType::PackageSkipped { reason: "exists".to_string() },
4538                    9 => EventType::ReadinessStarted { method: ReadinessMethod::Api },
4539                    10 => EventType::ReadinessPoll { attempt: 1, visible: false },
4540                    11 => EventType::ReadinessComplete { duration_ms: 500, attempts: 3 },
4541                    12 => EventType::ReadinessTimeout { max_wait_ms: 60000 },
4542                    13 => EventType::IndexReadinessStarted { crate_name: "a".to_string(), version: "1.0.0".to_string() },
4543                    14 => EventType::IndexReadinessCheck { crate_name: "a".to_string(), version: "1.0.0".to_string(), found: true },
4544                    15 => EventType::IndexReadinessComplete { crate_name: "a".to_string(), version: "1.0.0".to_string(), visible: true },
4545                    16 => EventType::PreflightStarted,
4546                    _ => EventType::PreflightComplete { finishability: Finishability::Proven },
4547                };
4548                let json = serde_json::to_string(&event_type).unwrap();
4549                let _parsed: EventType = serde_json::from_str(&json).unwrap();
4550            }
4551        }
4552
4553        // ===== PackageState transition validity =====
4554
4555        /// Valid transitions from each PackageState
4556        fn valid_next_states(state: &PackageState) -> Vec<PackageState> {
4557            match state {
4558                PackageState::Pending => vec![
4559                    PackageState::Uploaded,
4560                    PackageState::Failed {
4561                        class: ErrorClass::Retryable,
4562                        message: "err".to_string(),
4563                    },
4564                    PackageState::Skipped {
4565                        reason: "already published".to_string(),
4566                    },
4567                ],
4568                PackageState::Uploaded => vec![
4569                    PackageState::Published,
4570                    PackageState::Failed {
4571                        class: ErrorClass::Retryable,
4572                        message: "readiness timeout".to_string(),
4573                    },
4574                    PackageState::Ambiguous {
4575                        message: "unclear".to_string(),
4576                    },
4577                ],
4578                PackageState::Failed { .. } => vec![
4579                    PackageState::Pending, // retry resets to Pending
4580                ],
4581                // Terminal states
4582                PackageState::Published => vec![],
4583                PackageState::Skipped { .. } => vec![],
4584                PackageState::Ambiguous { .. } => vec![],
4585            }
4586        }
4587
4588        fn is_terminal(state: &PackageState) -> bool {
4589            matches!(
4590                state,
4591                PackageState::Published
4592                    | PackageState::Skipped { .. }
4593                    | PackageState::Ambiguous { .. }
4594            )
4595        }
4596
4597        proptest! {
4598            #[test]
4599            fn package_state_transitions_are_valid(
4600                start_variant in 0u8..6,
4601            ) {
4602                let start = match start_variant {
4603                    0 => PackageState::Pending,
4604                    1 => PackageState::Uploaded,
4605                    2 => PackageState::Published,
4606                    3 => PackageState::Skipped { reason: "exists".to_string() },
4607                    4 => PackageState::Failed { class: ErrorClass::Retryable, message: "err".to_string() },
4608                    _ => PackageState::Ambiguous { message: "unclear".to_string() },
4609                };
4610
4611                let nexts = valid_next_states(&start);
4612                if is_terminal(&start) {
4613                    assert!(nexts.is_empty(), "Terminal state {:?} should have no valid transitions", start);
4614                } else {
4615                    assert!(!nexts.is_empty(), "Non-terminal state {:?} should have valid transitions", start);
4616                }
4617            }
4618
4619            /// Failed states can always retry back to Pending
4620            #[test]
4621            fn failed_state_can_retry(
4622                class_variant in 0u8..3,
4623                message in "[a-z ]{1,30}",
4624            ) {
4625                let class = match class_variant {
4626                    0 => ErrorClass::Retryable,
4627                    1 => ErrorClass::Permanent,
4628                    _ => ErrorClass::Ambiguous,
4629                };
4630                let failed = PackageState::Failed { class, message };
4631                let nexts = valid_next_states(&failed);
4632                assert!(nexts.contains(&PackageState::Pending),
4633                    "Failed state should allow retry to Pending");
4634            }
4635
4636            /// Pending always leads to Uploaded, Failed, or Skipped
4637            #[test]
4638            fn pending_has_expected_transitions(_dummy in 0u8..1) {
4639                let nexts = valid_next_states(&PackageState::Pending);
4640                assert_eq!(nexts.len(), 3);
4641                assert!(matches!(nexts[0], PackageState::Uploaded));
4642                assert!(matches!(nexts[1], PackageState::Failed { .. }));
4643                assert!(matches!(nexts[2], PackageState::Skipped { .. }));
4644            }
4645        }
4646
4647        // ===== Plan determinism =====
4648
4649        proptest! {
4650            /// Same inputs produce the same plan_id (SHA256 determinism)
4651            #[test]
4652            fn plan_id_deterministic_for_same_inputs(
4653                pkg_count in 1usize..6,
4654                seed in 0u64..1000,
4655            ) {
4656                use std::collections::hash_map::DefaultHasher;
4657                use std::hash::{Hash, Hasher};
4658
4659                // Generate a deterministic "plan_id" from the same inputs
4660                fn compute_plan_id(packages: &[PlannedPackage], registry_name: &str) -> String {
4661                    let mut hasher = DefaultHasher::new();
4662                    registry_name.hash(&mut hasher);
4663                    for pkg in packages {
4664                        pkg.name.hash(&mut hasher);
4665                        pkg.version.hash(&mut hasher);
4666                    }
4667                    format!("{:016x}", hasher.finish())
4668                }
4669
4670                let packages: Vec<PlannedPackage> = (0..pkg_count)
4671                    .map(|i| PlannedPackage {
4672                        name: format!("crate-{}-{}", seed, i),
4673                        version: format!("{}.0.0", i),
4674                        manifest_path: PathBuf::from(format!("crates/crate-{i}/Cargo.toml")),
4675                    })
4676                    .collect();
4677
4678                let id1 = compute_plan_id(&packages, "crates-io");
4679                let id2 = compute_plan_id(&packages, "crates-io");
4680                assert_eq!(id1, id2, "Same inputs must produce the same plan_id");
4681            }
4682
4683            /// Different package lists produce different plan_ids
4684            #[test]
4685            fn plan_id_differs_for_different_inputs(
4686                seed in 0u64..1000,
4687            ) {
4688                use std::collections::hash_map::DefaultHasher;
4689                use std::hash::{Hash, Hasher};
4690
4691                fn compute_plan_id(packages: &[PlannedPackage], registry_name: &str) -> String {
4692                    let mut hasher = DefaultHasher::new();
4693                    registry_name.hash(&mut hasher);
4694                    for pkg in packages {
4695                        pkg.name.hash(&mut hasher);
4696                        pkg.version.hash(&mut hasher);
4697                    }
4698                    format!("{:016x}", hasher.finish())
4699                }
4700
4701                let pkgs_a = vec![PlannedPackage {
4702                    name: format!("crate-a-{seed}"),
4703                    version: "1.0.0".to_string(),
4704                    manifest_path: PathBuf::from("Cargo.toml"),
4705                }];
4706                let pkgs_b = vec![PlannedPackage {
4707                    name: format!("crate-b-{seed}"),
4708                    version: "1.0.0".to_string(),
4709                    manifest_path: PathBuf::from("Cargo.toml"),
4710                }];
4711
4712                let id_a = compute_plan_id(&pkgs_a, "crates-io");
4713                let id_b = compute_plan_id(&pkgs_b, "crates-io");
4714                assert_ne!(id_a, id_b, "Different inputs must produce different plan_ids");
4715            }
4716        }
4717
4718        // ===== Receipt generation from ExecutionState =====
4719
4720        fn build_receipt_from_state(state: &ExecutionState) -> Receipt {
4721            let now = Utc::now();
4722            let packages: Vec<PackageReceipt> = state
4723                .packages
4724                .values()
4725                .map(|progress| PackageReceipt {
4726                    name: progress.name.clone(),
4727                    version: progress.version.clone(),
4728                    attempts: progress.attempts,
4729                    state: progress.state.clone(),
4730                    started_at: state.created_at,
4731                    finished_at: now,
4732                    duration_ms: 0,
4733                    evidence: PackageEvidence {
4734                        attempts: vec![],
4735                        readiness_checks: vec![],
4736                    },
4737                    compromised_at: None,
4738                    compromised_by: None,
4739                    superseded_by: None,
4740                })
4741                .collect();
4742
4743            Receipt {
4744                receipt_version: "shipper.receipt.v1".to_string(),
4745                plan_id: state.plan_id.clone(),
4746                registry: state.registry.clone(),
4747                started_at: state.created_at,
4748                finished_at: now,
4749                packages,
4750                event_log_path: PathBuf::from(".shipper/events.jsonl"),
4751                git_context: None,
4752                environment: EnvironmentFingerprint {
4753                    shipper_version: "0.3.0".to_string(),
4754                    cargo_version: None,
4755                    rust_version: None,
4756                    os: "test".to_string(),
4757                    arch: "test".to_string(),
4758                },
4759            }
4760        }
4761
4762        proptest! {
4763            #[test]
4764            fn receipt_from_state_preserves_plan_id(
4765                plan_id in "[a-f0-9]{8,32}",
4766                pkg_count in 0usize..5,
4767            ) {
4768                let mut packages = BTreeMap::new();
4769                for i in 0..pkg_count {
4770                    packages.insert(
4771                        format!("pkg-{i}@{i}.0.0"),
4772                        PackageProgress {
4773                            name: format!("pkg-{i}"),
4774                            version: format!("{i}.0.0"),
4775                            attempts: 1,
4776                            state: PackageState::Published,
4777                            last_updated_at: Utc::now(),
4778                        },
4779                    );
4780                }
4781                let state = ExecutionState {
4782                    state_version: "shipper.state.v1".to_string(),
4783                    plan_id: plan_id.clone(),
4784                    registry: Registry::crates_io(),
4785                    created_at: Utc::now(),
4786                    updated_at: Utc::now(),
4787                    packages,
4788                };
4789
4790                let receipt = build_receipt_from_state(&state);
4791                assert_eq!(receipt.plan_id, plan_id);
4792                assert_eq!(receipt.packages.len(), pkg_count);
4793                for pkg_receipt in &receipt.packages {
4794                    assert!(state.packages.values().any(|p| p.name == pkg_receipt.name));
4795                    assert_eq!(pkg_receipt.state, PackageState::Published);
4796                }
4797            }
4798
4799            #[test]
4800            fn receipt_from_state_includes_all_packages(pkg_count in 1usize..8) {
4801                let mut packages = BTreeMap::new();
4802                for i in 0..pkg_count {
4803                    let state_variant = match i % 3 {
4804                        0 => PackageState::Published,
4805                        1 => PackageState::Skipped { reason: "exists".to_string() },
4806                        _ => PackageState::Failed {
4807                            class: ErrorClass::Permanent,
4808                            message: "auth failure".to_string(),
4809                        },
4810                    };
4811                    packages.insert(
4812                        format!("pkg-{i}@{i}.0.0"),
4813                        PackageProgress {
4814                            name: format!("pkg-{i}"),
4815                            version: format!("{i}.0.0"),
4816                            attempts: (i as u32) + 1,
4817                            state: state_variant,
4818                            last_updated_at: Utc::now(),
4819                        },
4820                    );
4821                }
4822                let state = ExecutionState {
4823                    state_version: "shipper.state.v1".to_string(),
4824                    plan_id: "test-plan".to_string(),
4825                    registry: Registry::crates_io(),
4826                    created_at: Utc::now(),
4827                    updated_at: Utc::now(),
4828                    packages,
4829                };
4830
4831                let receipt = build_receipt_from_state(&state);
4832                assert_eq!(receipt.packages.len(), pkg_count);
4833                // Receipt should be serializable
4834                let json = serde_json::to_string(&receipt).unwrap();
4835                let parsed: Receipt = serde_json::from_str(&json).unwrap();
4836                assert_eq!(parsed.packages.len(), pkg_count);
4837            }
4838        }
4839
4840        // ===== Version string parsing roundtrips =====
4841
4842        /// Parse a semver-like version string and reconstruct it
4843        fn parse_version(v: &str) -> Option<(u32, u32, u32, Option<String>)> {
4844            let (main, pre) = if let Some(idx) = v.find('-') {
4845                (&v[..idx], Some(v[idx + 1..].to_string()))
4846            } else {
4847                (v, None)
4848            };
4849            let parts: Vec<&str> = main.split('.').collect();
4850            if parts.len() != 3 {
4851                return None;
4852            }
4853            let major = parts[0].parse::<u32>().ok()?;
4854            let minor = parts[1].parse::<u32>().ok()?;
4855            let patch = parts[2].parse::<u32>().ok()?;
4856            Some((major, minor, patch, pre))
4857        }
4858
4859        fn format_version(major: u32, minor: u32, patch: u32, pre: Option<&str>) -> String {
4860            match pre {
4861                Some(p) => format!("{major}.{minor}.{patch}-{p}"),
4862                None => format!("{major}.{minor}.{patch}"),
4863            }
4864        }
4865
4866        proptest! {
4867            /// Parsing a version string and reformatting yields the original
4868            #[test]
4869            fn version_string_roundtrip(
4870                major in 0u32..100,
4871                minor in 0u32..100,
4872                patch in 0u32..100,
4873            ) {
4874                let version = format!("{major}.{minor}.{patch}");
4875                let (m, mi, p, pre) = parse_version(&version).unwrap();
4876                assert_eq!(m, major);
4877                assert_eq!(mi, minor);
4878                assert_eq!(p, patch);
4879                assert!(pre.is_none());
4880                let reconstructed = format_version(m, mi, p, pre.as_deref());
4881                assert_eq!(reconstructed, version);
4882            }
4883
4884            /// Version with prerelease tag roundtrips
4885            #[test]
4886            fn version_string_with_prerelease_roundtrip(
4887                major in 0u32..100,
4888                minor in 0u32..100,
4889                patch in 0u32..100,
4890                pre_tag in "[a-z]{1,5}\\.[0-9]{1,3}",
4891            ) {
4892                let version = format!("{major}.{minor}.{patch}-{pre_tag}");
4893                let (m, mi, p, pre) = parse_version(&version).unwrap();
4894                assert_eq!(m, major);
4895                assert_eq!(mi, minor);
4896                assert_eq!(p, patch);
4897                assert_eq!(pre.as_deref(), Some(pre_tag.as_str()));
4898                let reconstructed = format_version(m, mi, p, pre.as_deref());
4899                assert_eq!(reconstructed, version);
4900            }
4901
4902            /// Version fields stored in PlannedPackage survive JSON roundtrip
4903            #[test]
4904            fn version_in_planned_package_roundtrip(
4905                major in 0u32..100,
4906                minor in 0u32..100,
4907                patch in 0u32..100,
4908            ) {
4909                let version = format!("{major}.{minor}.{patch}");
4910                let pkg = PlannedPackage {
4911                    name: "test-crate".to_string(),
4912                    version: version.clone(),
4913                    manifest_path: PathBuf::from("Cargo.toml"),
4914                };
4915                let json = serde_json::to_string(&pkg).unwrap();
4916                let parsed: PlannedPackage = serde_json::from_str(&json).unwrap();
4917                let (m, mi, p, _) = parse_version(&parsed.version).unwrap();
4918                assert_eq!((m, mi, p), (major, minor, patch));
4919            }
4920        }
4921
4922        // ===== ReleasePlan roundtrip with varied registry =====
4923
4924        proptest! {
4925            #[test]
4926            fn release_plan_with_custom_registry_roundtrip(
4927                plan_id in "[a-f0-9]{8,64}",
4928                registry_name in "[a-z][a-z0-9-]{0,15}",
4929                api_base in "https://[a-z]{3,10}\\.[a-z]{2,5}",
4930                index_base in prop::option::of("https://index\\.[a-z]{3,10}\\.[a-z]{2,5}"),
4931                pkg_count in 1usize..6,
4932                dep_count in 0usize..3,
4933            ) {
4934                let packages: Vec<PlannedPackage> = (0..pkg_count)
4935                    .map(|i| PlannedPackage {
4936                        name: format!("crate-{i}"),
4937                        version: format!("{}.0.0", i + 1),
4938                        manifest_path: PathBuf::from(format!("crates/crate-{i}/Cargo.toml")),
4939                    })
4940                    .collect();
4941                let mut deps = BTreeMap::new();
4942                for d in 0..dep_count.min(pkg_count.saturating_sub(1)) {
4943                    deps.insert(
4944                        format!("crate-{}", d + 1),
4945                        vec![format!("crate-{d}")],
4946                    );
4947                }
4948                let plan = ReleasePlan {
4949                    plan_version: "shipper.plan.v1".to_string(),
4950                    plan_id: plan_id.clone(),
4951                    created_at: Utc::now(),
4952                    registry: Registry {
4953                        name: registry_name.clone(),
4954                        api_base: api_base.clone(),
4955                        index_base: index_base.clone(),
4956                    },
4957                    packages,
4958                    dependencies: deps.clone(),
4959                };
4960                let json = serde_json::to_string(&plan).unwrap();
4961                let parsed: ReleasePlan = serde_json::from_str(&json).unwrap();
4962                assert_eq!(parsed.plan_id, plan_id);
4963                assert_eq!(parsed.registry.name, registry_name);
4964                assert_eq!(parsed.registry.api_base, api_base);
4965                assert_eq!(parsed.registry.index_base, index_base);
4966                assert_eq!(parsed.packages.len(), pkg_count);
4967                assert_eq!(parsed.dependencies, deps);
4968            }
4969        }
4970
4971        // ===== RuntimeOptions duration validation =====
4972
4973        proptest! {
4974            #[test]
4975            fn runtime_options_durations_positive(
4976                base_delay_ms in 1u64..60_000,
4977                max_delay_ms in 1u64..600_000,
4978                verify_timeout_ms in 1u64..3_600_000,
4979                verify_poll_ms in 1u64..60_000,
4980                lock_timeout_ms in 1u64..86_400_000,
4981                pkg_timeout_ms in 1u64..7_200_000,
4982                readiness_initial_ms in 1u64..10_000,
4983                readiness_max_ms in 1u64..120_000,
4984                readiness_total_ms in 1u64..600_000,
4985                readiness_poll_ms in 1u64..10_000,
4986            ) {
4987                let opts = RuntimeOptions {
4988                    allow_dirty: false,
4989                    skip_ownership_check: false,
4990                    strict_ownership: false,
4991                    no_verify: false,
4992                    max_attempts: 3,
4993                    base_delay: Duration::from_millis(base_delay_ms),
4994                    max_delay: Duration::from_millis(max_delay_ms),
4995                    retry_strategy: shipper_retry::RetryStrategyType::Exponential,
4996                    retry_jitter: 0.5,
4997                    retry_per_error: shipper_retry::PerErrorConfig::default(),
4998                    verify_timeout: Duration::from_millis(verify_timeout_ms),
4999                    verify_poll_interval: Duration::from_millis(verify_poll_ms),
5000                    state_dir: PathBuf::from(".shipper"),
5001                    force_resume: false,
5002                    policy: PublishPolicy::Safe,
5003                    verify_mode: VerifyMode::Workspace,
5004                    readiness: ReadinessConfig {
5005                        enabled: true,
5006                        method: ReadinessMethod::Api,
5007                        initial_delay: Duration::from_millis(readiness_initial_ms),
5008                        max_delay: Duration::from_millis(readiness_max_ms),
5009                        max_total_wait: Duration::from_millis(readiness_total_ms),
5010                        poll_interval: Duration::from_millis(readiness_poll_ms),
5011                        jitter_factor: 0.5,
5012                        index_path: None,
5013                        prefer_index: false,
5014                    },
5015                    output_lines: 1000,
5016                    force: false,
5017                    lock_timeout: Duration::from_millis(lock_timeout_ms),
5018                    parallel: ParallelConfig {
5019                        enabled: false,
5020                        max_concurrent: 4,
5021                        per_package_timeout: Duration::from_millis(pkg_timeout_ms),
5022                    },
5023                    webhook: WebhookConfig::default(),
5024                    encryption: EncryptionSettings::default(),
5025                    registries: vec![],
5026                    resume_from: None,
5027            rehearsal_registry: None,
5028            rehearsal_skip: false,
5029            rehearsal_smoke_install: None,
5030                };
5031
5032                // All duration fields must be positive
5033                assert!(opts.base_delay > Duration::ZERO);
5034                assert!(opts.max_delay > Duration::ZERO);
5035                assert!(opts.verify_timeout > Duration::ZERO);
5036                assert!(opts.verify_poll_interval > Duration::ZERO);
5037                assert!(opts.lock_timeout > Duration::ZERO);
5038                assert!(opts.parallel.per_package_timeout > Duration::ZERO);
5039                assert!(opts.readiness.initial_delay > Duration::ZERO);
5040                assert!(opts.readiness.max_delay > Duration::ZERO);
5041                assert!(opts.readiness.max_total_wait > Duration::ZERO);
5042                assert!(opts.readiness.poll_interval > Duration::ZERO);
5043            }
5044        }
5045
5046        // ===== Receipt with mixed package states roundtrip =====
5047
5048        proptest! {
5049            #[test]
5050            fn receipt_with_mixed_states_roundtrip(
5051                plan_id in "[a-f0-9]{8,32}",
5052                pkg_count in 1usize..6,
5053                git_commit in prop::option::of("[a-f0-9]{7,40}"),
5054                git_branch in prop::option::of("[a-z0-9/-]{1,20}"),
5055                shipper_ver in "[0-9]{1,2}\\.[0-9]{1,2}\\.[0-9]{1,2}",
5056                os_name in "[a-z]{3,10}",
5057            ) {
5058                let now = Utc::now();
5059                let packages: Vec<PackageReceipt> = (0..pkg_count)
5060                    .map(|i| {
5061                        let state = match i % 5 {
5062                            0 => PackageState::Published,
5063                            1 => PackageState::Skipped { reason: "already exists".to_string() },
5064                            2 => PackageState::Failed {
5065                                class: ErrorClass::Permanent,
5066                                message: "auth error".to_string(),
5067                            },
5068                            3 => PackageState::Ambiguous { message: "timeout".to_string() },
5069                            _ => PackageState::Uploaded,
5070                        };
5071                        PackageReceipt {
5072                            name: format!("crate-{i}"),
5073                            version: format!("{i}.1.0"),
5074                            attempts: (i as u32) + 1,
5075                            state,
5076                            started_at: now,
5077                            finished_at: now,
5078                            duration_ms: (i as u128 + 1) * 500,
5079                            evidence: PackageEvidence {
5080                                attempts: vec![],
5081                                readiness_checks: vec![],
5082                            },
5083                                                    compromised_at: None,
5084                            compromised_by: None,
5085                            superseded_by: None,
5086                        }
5087                    })
5088                    .collect();
5089                let receipt = Receipt {
5090                    receipt_version: "shipper.receipt.v1".to_string(),
5091                    plan_id: plan_id.clone(),
5092                    registry: Registry::crates_io(),
5093                    started_at: now,
5094                    finished_at: now,
5095                    packages: packages.clone(),
5096                    event_log_path: PathBuf::from(".shipper/events.jsonl"),
5097                    git_context: Some(GitContext {
5098                        commit: git_commit.clone(),
5099                        branch: git_branch.clone(),
5100                        tag: None,
5101                        dirty: Some(false),
5102                    }),
5103                    environment: EnvironmentFingerprint {
5104                        shipper_version: shipper_ver.clone(),
5105                        cargo_version: None,
5106                        rust_version: None,
5107                        os: os_name.clone(),
5108                        arch: "x86_64".to_string(),
5109                    },
5110                };
5111                let json = serde_json::to_string(&receipt).unwrap();
5112                let parsed: Receipt = serde_json::from_str(&json).unwrap();
5113                assert_eq!(parsed.plan_id, plan_id);
5114                assert_eq!(parsed.packages.len(), pkg_count);
5115                assert_eq!(parsed.environment.shipper_version, shipper_ver);
5116                assert_eq!(parsed.environment.os, os_name);
5117                let ctx = parsed.git_context.unwrap();
5118                assert_eq!(ctx.commit, git_commit);
5119                assert_eq!(ctx.branch, git_branch);
5120                for (orig, p) in packages.iter().zip(parsed.packages.iter()) {
5121                    assert_eq!(p.name, orig.name);
5122                    assert_eq!(p.state, orig.state);
5123                    assert_eq!(p.duration_ms, orig.duration_ms);
5124                }
5125            }
5126        }
5127
5128        // ===== ExecutionState with varied package states roundtrip =====
5129
5130        proptest! {
5131            #[test]
5132            fn execution_state_with_varied_states_roundtrip(
5133                plan_id in "[a-f0-9]{8,32}",
5134                pkg_count in 1usize..6,
5135            ) {
5136                let mut packages = BTreeMap::new();
5137                for i in 0..pkg_count {
5138                    let state = match i % 5 {
5139                        0 => PackageState::Pending,
5140                        1 => PackageState::Uploaded,
5141                        2 => PackageState::Published,
5142                        3 => PackageState::Skipped { reason: "exists".to_string() },
5143                        _ => PackageState::Failed {
5144                            class: ErrorClass::Retryable,
5145                            message: "timeout".to_string(),
5146                        },
5147                    };
5148                    packages.insert(
5149                        format!("crate-{i}@{i}.0.0"),
5150                        PackageProgress {
5151                            name: format!("crate-{i}"),
5152                            version: format!("{i}.0.0"),
5153                            attempts: (i as u32) + 1,
5154                            state,
5155                            last_updated_at: Utc::now(),
5156                        },
5157                    );
5158                }
5159                let exec_state = ExecutionState {
5160                    state_version: "shipper.state.v1".to_string(),
5161                    plan_id: plan_id.clone(),
5162                    registry: Registry::crates_io(),
5163                    created_at: Utc::now(),
5164                    updated_at: Utc::now(),
5165                    packages: packages.clone(),
5166                };
5167                let json = serde_json::to_string(&exec_state).unwrap();
5168                let parsed: ExecutionState = serde_json::from_str(&json).unwrap();
5169                assert_eq!(parsed.plan_id, plan_id);
5170                assert_eq!(parsed.packages.len(), pkg_count);
5171                for (key, orig) in &packages {
5172                    let p = parsed.packages.get(key).unwrap();
5173                    assert_eq!(p.name, orig.name);
5174                    assert_eq!(p.version, orig.version);
5175                    assert_eq!(p.attempts, orig.attempts);
5176                    assert_eq!(p.state, orig.state);
5177                }
5178            }
5179        }
5180
5181        // ===== PackageState transition monotonicity =====
5182
5183        /// Ordinal value for PackageState in the forward progress direction.
5184        /// Higher values represent more progress toward completion.
5185        fn state_ordinal(state: &PackageState) -> u8 {
5186            match state {
5187                PackageState::Pending => 0,
5188                PackageState::Uploaded => 1,
5189                PackageState::Published => 2,
5190                PackageState::Skipped { .. } => 2,   // terminal
5191                PackageState::Failed { .. } => 1,    // same level as Uploaded
5192                PackageState::Ambiguous { .. } => 2, // terminal
5193            }
5194        }
5195
5196        proptest! {
5197            /// Forward transitions (non-retry) never decrease the ordinal
5198            #[test]
5199            fn package_state_forward_transitions_monotonic(
5200                start_variant in 0u8..6,
5201            ) {
5202                let start = match start_variant {
5203                    0 => PackageState::Pending,
5204                    1 => PackageState::Uploaded,
5205                    2 => PackageState::Published,
5206                    3 => PackageState::Skipped { reason: "exists".to_string() },
5207                    4 => PackageState::Failed {
5208                        class: ErrorClass::Retryable,
5209                        message: "err".to_string(),
5210                    },
5211                    _ => PackageState::Ambiguous { message: "unclear".to_string() },
5212                };
5213                let start_ord = state_ordinal(&start);
5214                let nexts = valid_next_states(&start);
5215                for next in &nexts {
5216                    // The only allowed "backwards" transition is Failed -> Pending (retry)
5217                    let is_retry = matches!(
5218                        (&start, next),
5219                        (PackageState::Failed { .. }, PackageState::Pending)
5220                    );
5221                    if !is_retry {
5222                        assert!(
5223                            state_ordinal(next) >= start_ord,
5224                            "Non-retry transition {:?} -> {:?} must not decrease ordinal ({} -> {})",
5225                            start, next, start_ord, state_ordinal(next)
5226                        );
5227                    }
5228                }
5229            }
5230
5231            /// The happy path Pending -> Uploaded -> Published is strictly increasing
5232            #[test]
5233            fn happy_path_is_strictly_monotonic(_dummy in 0u8..1) {
5234                let path = [
5235                    PackageState::Pending,
5236                    PackageState::Uploaded,
5237                    PackageState::Published,
5238                ];
5239                for w in path.windows(2) {
5240                    assert!(
5241                        state_ordinal(&w[1]) > state_ordinal(&w[0]),
5242                        "Happy path must be strictly increasing: {:?} -> {:?}",
5243                        w[0], w[1]
5244                    );
5245                }
5246            }
5247
5248            /// Terminal states have no forward transitions (can't go backwards)
5249            #[test]
5250            fn terminal_states_have_no_transitions(variant in 0u8..3) {
5251                let state = match variant {
5252                    0 => PackageState::Published,
5253                    1 => PackageState::Skipped { reason: "exists".to_string() },
5254                    _ => PackageState::Ambiguous { message: "unclear".to_string() },
5255                };
5256                let nexts = valid_next_states(&state);
5257                assert!(
5258                    nexts.is_empty(),
5259                    "Terminal state {:?} must have no transitions but has {:?}",
5260                    state, nexts
5261                );
5262            }
5263        }
5264
5265        // ===== Error/type Debug formatting never panics =====
5266
5267        proptest! {
5268            #[test]
5269            fn package_state_debug_never_panics(
5270                variant in 0u8..6,
5271                message in "\\PC{0,200}",
5272            ) {
5273                let state = match variant {
5274                    0 => PackageState::Pending,
5275                    1 => PackageState::Uploaded,
5276                    2 => PackageState::Published,
5277                    3 => PackageState::Skipped { reason: message.clone() },
5278                    4 => PackageState::Failed {
5279                        class: ErrorClass::Retryable,
5280                        message: message.clone(),
5281                    },
5282                    _ => PackageState::Ambiguous { message },
5283                };
5284                let debug = format!("{:?}", state);
5285                assert!(!debug.is_empty());
5286            }
5287
5288            #[test]
5289            fn error_class_debug_never_panics(variant in 0u8..3) {
5290                let class = match variant {
5291                    0 => ErrorClass::Retryable,
5292                    1 => ErrorClass::Permanent,
5293                    _ => ErrorClass::Ambiguous,
5294                };
5295                let debug = format!("{:?}", class);
5296                assert!(!debug.is_empty());
5297            }
5298
5299            #[test]
5300            fn execution_result_debug_never_panics(variant in 0u8..3) {
5301                let result = match variant {
5302                    0 => ExecutionResult::Success,
5303                    1 => ExecutionResult::PartialFailure,
5304                    _ => ExecutionResult::CompleteFailure,
5305                };
5306                let debug = format!("{:?}", result);
5307                assert!(!debug.is_empty());
5308            }
5309
5310            #[test]
5311            fn finishability_debug_never_panics(variant in 0u8..3) {
5312                let fin = match variant {
5313                    0 => Finishability::Proven,
5314                    1 => Finishability::NotProven,
5315                    _ => Finishability::Failed,
5316                };
5317                let debug = format!("{:?}", fin);
5318                assert!(!debug.is_empty());
5319            }
5320
5321            #[test]
5322            fn event_type_debug_never_panics(
5323                variant in 0u8..18,
5324                msg in "\\PC{0,100}",
5325            ) {
5326                let event_type = match variant {
5327                    0 => EventType::PlanCreated { plan_id: msg.clone(), package_count: 5 },
5328                    1 => EventType::ExecutionStarted,
5329                    2 => EventType::ExecutionFinished { result: ExecutionResult::Success },
5330                    3 => EventType::PackageStarted { name: msg.clone(), version: "1.0.0".to_string() },
5331                    4 => EventType::PackageAttempted { attempt: 1, command: msg.clone() },
5332                    5 => EventType::PackageOutput { stdout_tail: msg.clone(), stderr_tail: String::new() },
5333                    6 => EventType::PackagePublished { duration_ms: 100 },
5334                    7 => EventType::PackageFailed { class: ErrorClass::Retryable, message: msg.clone() },
5335                    8 => EventType::PackageSkipped { reason: msg.clone() },
5336                    9 => EventType::ReadinessStarted { method: ReadinessMethod::Api },
5337                    10 => EventType::ReadinessPoll { attempt: 1, visible: false },
5338                    11 => EventType::ReadinessComplete { duration_ms: 500, attempts: 3 },
5339                    12 => EventType::ReadinessTimeout { max_wait_ms: 60000 },
5340                    13 => EventType::IndexReadinessStarted { crate_name: msg.clone(), version: "1.0.0".to_string() },
5341                    14 => EventType::IndexReadinessCheck { crate_name: msg.clone(), version: "1.0.0".to_string(), found: true },
5342                    15 => EventType::IndexReadinessComplete { crate_name: msg.clone(), version: "1.0.0".to_string(), visible: true },
5343                    16 => EventType::PreflightStarted,
5344                    _ => EventType::PreflightComplete { finishability: Finishability::Proven },
5345                };
5346                let debug = format!("{:?}", event_type);
5347                assert!(!debug.is_empty());
5348            }
5349
5350            #[test]
5351            fn publish_event_debug_never_panics(
5352                pkg in "[a-z][a-z0-9-]{0,15}@[0-9]+\\.[0-9]+\\.[0-9]+",
5353            ) {
5354                let event = PublishEvent {
5355                    timestamp: Utc::now(),
5356                    event_type: EventType::ExecutionStarted,
5357                    package: pkg,
5358                };
5359                let debug = format!("{:?}", event);
5360                assert!(!debug.is_empty());
5361            }
5362        }
5363
5364        // ===== Arbitrary PackageState sequences =====
5365
5366        proptest! {
5367            /// Random sequences of PackageState transitions follow valid_next_states
5368            #[test]
5369            fn arbitrary_package_state_sequence(steps in 1usize..10) {
5370                let mut current = PackageState::Pending;
5371                for _ in 0..steps {
5372                    let nexts = valid_next_states(&current);
5373                    if nexts.is_empty() {
5374                        break; // terminal state
5375                    }
5376                    // Always pick the first valid transition for determinism
5377                    current = nexts[0].clone();
5378                }
5379                // We should end in a well-known state
5380                let debug = format!("{:?}", current);
5381                assert!(!debug.is_empty());
5382            }
5383
5384            /// The happy path Pending→Uploaded→Published always completes in 2 transitions
5385            #[test]
5386            fn happy_path_always_reaches_published(_seed in 0u64..100) {
5387                let mut state = PackageState::Pending;
5388                // Pending -> Uploaded
5389                let nexts = valid_next_states(&state);
5390                assert!(nexts.iter().any(|s| matches!(s, PackageState::Uploaded)));
5391                state = PackageState::Uploaded;
5392                // Uploaded -> Published
5393                let nexts = valid_next_states(&state);
5394                assert!(nexts.iter().any(|s| matches!(s, PackageState::Published)));
5395                state = PackageState::Published;
5396                // Published is terminal
5397                assert!(valid_next_states(&state).is_empty());
5398            }
5399
5400            /// Full receipt with evidence roundtrips preserve attempt counts
5401            #[test]
5402            fn receipt_evidence_attempt_counts_preserved(
5403                attempt_count in 0usize..5,
5404                readiness_count in 0usize..5,
5405            ) {
5406                let now = Utc::now();
5407                let attempts: Vec<AttemptEvidence> = (0..attempt_count)
5408                    .map(|i| AttemptEvidence {
5409                        attempt_number: i as u32 + 1,
5410                        command: format!("cargo publish attempt {i}"),
5411                        exit_code: 0,
5412                        stdout_tail: "ok".to_string(),
5413                        stderr_tail: String::new(),
5414                        timestamp: now,
5415                        duration: Duration::from_secs(1),
5416                    })
5417                    .collect();
5418                let checks: Vec<ReadinessEvidence> = (0..readiness_count)
5419                    .map(|i| ReadinessEvidence {
5420                        attempt: i as u32 + 1,
5421                        visible: i == readiness_count - 1,
5422                        timestamp: now,
5423                        delay_before: Duration::from_secs(2),
5424                    })
5425                    .collect();
5426                let evidence = PackageEvidence {
5427                    attempts: attempts.clone(),
5428                    readiness_checks: checks.clone(),
5429                };
5430                let json = serde_json::to_string(&evidence).unwrap();
5431                let parsed: PackageEvidence = serde_json::from_str(&json).unwrap();
5432                assert_eq!(parsed.attempts.len(), attempt_count);
5433                assert_eq!(parsed.readiness_checks.len(), readiness_count);
5434                for (orig, p) in attempts.iter().zip(parsed.attempts.iter()) {
5435                    assert_eq!(orig.attempt_number, p.attempt_number);
5436                    assert_eq!(orig.exit_code, p.exit_code);
5437                }
5438            }
5439        }
5440
5441        // Helper functions for property-based tests
5442
5443        fn calculate_index_path_for_crate(crate_name: &str) -> String {
5444            let lower = crate_name.to_lowercase();
5445            match lower.len() {
5446                1 => format!("1/{}", lower),
5447                2 => format!("2/{}", lower),
5448                3 => format!("3/{}/{}", &lower[..1], lower),
5449                _ => format!("{}/{}/{}", &lower[..2], &lower[2..4], lower),
5450            }
5451        }
5452
5453        fn parse_schema_version_for_test(version: &str) -> Result<u32, String> {
5454            let parts: Vec<&str> = version.split('.').collect();
5455            if parts.len() != 3 || !parts[0].starts_with("shipper") || !parts[2].starts_with('v') {
5456                return Err("invalid format".to_string());
5457            }
5458
5459            let version_part = &parts[2][1..];
5460            version_part.parse::<u32>().map_err(|e| e.to_string())
5461        }
5462
5463        // --- Additional invariant proptests ---
5464
5465        proptest! {
5466            /// ReleasePlan JSON roundtrip with deps: serialize then deserialize preserves all fields.
5467            #[test]
5468            fn release_plan_with_deps_roundtrip(
5469                pkg_count in 0usize..8,
5470                plan_id in "[a-f0-9]{8}",
5471            ) {
5472                let packages: Vec<PlannedPackage> = (0..pkg_count)
5473                    .map(|i| PlannedPackage {
5474                        name: format!("crate-{i}"),
5475                        version: format!("0.{i}.0"),
5476                        manifest_path: PathBuf::from(format!("crates/crate-{i}/Cargo.toml")),
5477                    })
5478                    .collect();
5479
5480                let mut deps = BTreeMap::new();
5481                for i in 1..pkg_count {
5482                    deps.insert(
5483                        format!("crate-{i}"),
5484                        vec![format!("crate-{}", i - 1)],
5485                    );
5486                }
5487
5488                let plan = ReleasePlan {
5489                    plan_version: "shipper.plan.v1".to_string(),
5490                    plan_id: plan_id.clone(),
5491                    created_at: Utc::now(),
5492                    registry: Registry::crates_io(),
5493                    packages: packages.clone(),
5494                    dependencies: deps.clone(),
5495                };
5496
5497                let json = serde_json::to_string(&plan).unwrap();
5498                let parsed: ReleasePlan = serde_json::from_str(&json).unwrap();
5499
5500                prop_assert_eq!(parsed.plan_id, plan.plan_id);
5501                prop_assert_eq!(parsed.packages.len(), pkg_count);
5502                prop_assert_eq!(parsed.dependencies.len(), deps.len());
5503                for (orig, p) in plan.packages.iter().zip(parsed.packages.iter()) {
5504                    prop_assert_eq!(&p.name, &orig.name);
5505                    prop_assert_eq!(&p.version, &orig.version);
5506                }
5507            }
5508
5509            /// Plan ordering: group_by_levels always places dependencies before dependents.
5510            #[test]
5511            fn plan_levels_respect_dependency_ordering(
5512                pkg_count in 1usize..10,
5513            ) {
5514                let packages: Vec<PlannedPackage> = (0..pkg_count)
5515                    .map(|i| PlannedPackage {
5516                        name: format!("crate-{i}"),
5517                        version: format!("0.{i}.0"),
5518                        manifest_path: PathBuf::from(format!("crates/crate-{i}/Cargo.toml")),
5519                    })
5520                    .collect();
5521
5522                // Linear dependency chain: crate-1 depends on crate-0, crate-2 on crate-1, etc.
5523                let mut deps = BTreeMap::new();
5524                for i in 1..pkg_count {
5525                    deps.insert(
5526                        format!("crate-{i}"),
5527                        vec![format!("crate-{}", i - 1)],
5528                    );
5529                }
5530
5531                let plan = ReleasePlan {
5532                    plan_version: "shipper.plan.v1".to_string(),
5533                    plan_id: "test-plan".to_string(),
5534                    created_at: Utc::now(),
5535                    registry: Registry::crates_io(),
5536                    packages,
5537                    dependencies: deps.clone(),
5538                };
5539
5540                let levels = plan.group_by_levels();
5541
5542                // Build a map of package name -> level number
5543                let mut pkg_level: BTreeMap<String, usize> = BTreeMap::new();
5544                for level in &levels {
5545                    for pkg in &level.packages {
5546                        pkg_level.insert(pkg.name.clone(), level.level);
5547                    }
5548                }
5549
5550                // Every dependency must be at a strictly earlier level
5551                for (name, dep_list) in &deps {
5552                    if let Some(&my_level) = pkg_level.get(name.as_str()) {
5553                        for dep in dep_list {
5554                            if let Some(&dep_level) = pkg_level.get(dep.as_str()) {
5555                                prop_assert!(
5556                                    dep_level < my_level,
5557                                    "{name} (level {my_level}) depends on {dep} (level {dep_level})"
5558                                );
5559                            }
5560                        }
5561                    }
5562                }
5563            }
5564
5565            /// Receipt completeness: every package in the plan appears in the receipt.
5566            #[test]
5567            fn receipt_contains_all_plan_packages(
5568                pkg_count in 1usize..8,
5569            ) {
5570                let now = Utc::now();
5571                let packages: Vec<PlannedPackage> = (0..pkg_count)
5572                    .map(|i| PlannedPackage {
5573                        name: format!("crate-{i}"),
5574                        version: format!("0.{i}.0"),
5575                        manifest_path: PathBuf::from(format!("crates/crate-{i}/Cargo.toml")),
5576                    })
5577                    .collect();
5578
5579                let receipts: Vec<PackageReceipt> = packages
5580                    .iter()
5581                    .map(|pkg| PackageReceipt {
5582                        name: pkg.name.clone(),
5583                        version: pkg.version.clone(),
5584                        attempts: 1,
5585                        state: PackageState::Published,
5586                        started_at: now,
5587                        finished_at: now,
5588                        duration_ms: 100,
5589                        evidence: PackageEvidence {
5590                            attempts: vec![],
5591                            readiness_checks: vec![],
5592                        },
5593                                            compromised_at: None,
5594                        compromised_by: None,
5595                        superseded_by: None,
5596                    })
5597                    .collect();
5598
5599                let receipt = Receipt {
5600                    receipt_version: "shipper.receipt.v1".to_string(),
5601                    plan_id: "plan-test".to_string(),
5602                    registry: Registry::crates_io(),
5603                    started_at: now,
5604                    finished_at: now,
5605                    packages: receipts.clone(),
5606                    event_log_path: PathBuf::from(".shipper/events.jsonl"),
5607                    git_context: None,
5608                    environment: EnvironmentFingerprint {
5609                        shipper_version: "0.1.0".to_string(),
5610                        cargo_version: None,
5611                        rust_version: None,
5612                        os: "linux".to_string(),
5613                        arch: "x86_64".to_string(),
5614                    },
5615                };
5616
5617                // Every planned package appears in the receipt
5618                for pkg in &packages {
5619                    let found = receipt.packages.iter().any(|r| r.name == pkg.name && r.version == pkg.version);
5620                    prop_assert!(found, "package {}@{} missing from receipt", pkg.name, pkg.version);
5621                }
5622                prop_assert_eq!(receipt.packages.len(), packages.len());
5623
5624                // Roundtrip the receipt
5625                let json = serde_json::to_string(&receipt).unwrap();
5626                let parsed: Receipt = serde_json::from_str(&json).unwrap();
5627                prop_assert_eq!(parsed.packages.len(), receipt.packages.len());
5628            }
5629        }
5630    }
5631}