Skip to main content

shipper_core/engine/
mod.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3use std::thread;
4use std::time::{Duration, Instant};
5
6use anyhow::{Context, Result, bail};
7use chrono::Utc;
8
9use crate::cargo;
10use crate::git;
11use crate::lock;
12use crate::ops::auth;
13use crate::plan::PlannedWorkspace;
14use crate::registry::RegistryClient;
15use crate::runtime::environment;
16use crate::runtime::execution::{
17    backoff_delay, classify_cargo_failure, pkg_key, registry_aware_backoff, resolve_state_dir,
18    short_state, update_state,
19};
20use crate::state::events;
21use crate::state::execution_state as state;
22use crate::types::{
23    AttemptEvidence, ErrorClass, EventType, ExecutionResult, ExecutionState, Finishability,
24    PackageProgress, PackageReceipt, PackageState, PreflightPackage, PreflightReport, PublishEvent,
25    ReadinessEvidence, Receipt, ReconciliationOutcome, Registry, RuntimeOptions,
26};
27use crate::webhook::{self, WebhookEvent};
28
29pub trait Reporter {
30    fn info(&mut self, msg: &str);
31    fn warn(&mut self, msg: &str);
32    fn error(&mut self, msg: &str);
33}
34
35pub(crate) fn policy_effects(opts: &RuntimeOptions) -> crate::runtime::policy::PolicyEffects {
36    crate::runtime::policy::policy_effects(opts)
37}
38
39/// Run preflight verification checks before publishing.
40///
41/// This function performs various pre-publish checks to catch issues early:
42/// - Git cleanliness (if `allow_dirty` is false)
43/// - Registry reachability
44/// - Dry-run compilation verification
45/// - Version existence check (skip already-published versions)
46/// - Ownership verification (optional, based on policy)
47///
48/// # Arguments
49///
50/// * `ws` - The planned workspace containing packages to publish
51/// * `opts` - Runtime options controlling behavior
52/// * `reporter` - A reporter for outputting progress and warnings
53///
54/// # Returns
55///
56/// Returns a [`PreflightReport`] containing:
57/// - Whether a token was detected
58/// - The finishability assessment (Proven/NotProven/Failed)
59/// - Per-package preflight results
60///
61/// # Example
62///
63/// ```ignore
64/// let ws = plan::build_plan(&spec)?;
65/// let opts = types::RuntimeOptions { /* ... */ };
66/// let mut reporter = MyReporter::default();
67/// let report = engine::run_preflight(&ws, &opts, &mut reporter)?;
68/// println!("Finishability: {:?}", report.finishability);
69/// ```
70fn init_registry_client(registry: Registry, state_dir: &Path) -> Result<RegistryClient> {
71    let cache_dir = state_dir.join("cache");
72    RegistryClient::new(registry).map(|c| c.with_cache_dir(cache_dir))
73}
74
75pub fn run_preflight(
76    ws: &PlannedWorkspace,
77    opts: &RuntimeOptions,
78    reporter: &mut dyn Reporter,
79) -> Result<PreflightReport> {
80    let workspace_root = &ws.workspace_root;
81    let effects = policy_effects(opts);
82    let state_dir = resolve_state_dir(workspace_root, &opts.state_dir);
83    let events_path = events::events_path(&state_dir);
84    let mut event_log = events::EventLog::new();
85
86    event_log.record(PublishEvent {
87        timestamp: Utc::now(),
88        event_type: EventType::PreflightStarted,
89        package: "all".to_string(),
90    });
91    event_log.write_to_file(&events_path)?;
92    event_log.clear();
93
94    if !opts.allow_dirty {
95        reporter.info("checking git cleanliness...");
96        git::ensure_git_clean(workspace_root)?;
97    }
98
99    reporter.info("initializing registry client...");
100    let reg = init_registry_client(ws.plan.registry.clone(), &state_dir)?;
101
102    let token = auth::resolve_token(&ws.plan.registry.name)?;
103    let token_detected = token.as_ref().map(|s| !s.is_empty()).unwrap_or(false);
104    let auth_type = auth::detect_auth_type_from_token(token.as_deref());
105
106    if effects.strict_ownership && !token_detected {
107        event_log.record(PublishEvent {
108            timestamp: Utc::now(),
109            event_type: EventType::PreflightComplete {
110                finishability: Finishability::Failed,
111            },
112            package: "all".to_string(),
113        });
114        event_log.write_to_file(&events_path)?;
115        bail!(
116            "strict ownership requested but no token found (set CARGO_REGISTRY_TOKEN or run cargo login)"
117        );
118    }
119
120    // Run dry-run verification based on VerifyMode and policy
121    use crate::types::VerifyMode;
122
123    // Workspace-level dry-run result (used for Workspace mode)
124    //
125    // Event-payload handling (#92): the raw dry-run stderr is cargo's
126    // human-facing log with embedded ANSI escapes — historically ~2KB per
127    // event and not useful in a structured log. We now:
128    //   1. Strip ANSI from the full captured output,
129    //   2. Write the full stripped output to a sidecar at
130    //      <state_dir>/preflight_workspace_verify.txt,
131    //   3. Put only a short summary (exit_code + last ~200 chars tail) into
132    //      the event's `output` field, preserving the field shape for
133    //      backward compatibility.
134    let (workspace_dry_run_passed, workspace_dry_run_output) = if effects.run_dry_run
135        && opts.verify_mode == VerifyMode::Workspace
136    {
137        reporter.info("running workspace dry-run verification...");
138        let dry_run_result = cargo::cargo_publish_dry_run_workspace(
139            workspace_root,
140            &ws.plan.registry.name,
141            opts.allow_dirty,
142            opts.output_lines,
143        );
144        match &dry_run_result {
145            Ok(output) => {
146                let passed = output.exit_code == 0;
147                let full_stripped = format!(
148                    "workspace dry-run: exit_code={}\n\n--- stdout ---\n{}\n\n--- stderr ---\n{}\n",
149                    output.exit_code,
150                    shipper_output_sanitizer::strip_ansi(&output.stdout_tail),
151                    shipper_output_sanitizer::strip_ansi(&output.stderr_tail),
152                );
153                let sidecar_path = state_dir.join("preflight_workspace_verify.txt");
154                if let Some(parent) = sidecar_path.parent() {
155                    let _ = std::fs::create_dir_all(parent);
156                }
157                if let Err(e) = std::fs::write(&sidecar_path, &full_stripped) {
158                    reporter.warn(&format!(
159                        "failed to write preflight workspace-verify sidecar at {}: {e}",
160                        sidecar_path.display()
161                    ));
162                }
163                // The sidecar path is deterministic (<state_dir>/preflight_
164                // workspace_verify.txt); documented in the runbook. Keeping
165                // the success path quiet avoids churn in operator output
166                // snapshots and byte-count variability across platforms.
167                // Slim summary for the event log: exit code + tail of
168                // ANSI-stripped stderr (the interesting signal).
169                let tail_summary = shipper_output_sanitizer::tail_lines(
170                    &shipper_output_sanitizer::strip_ansi(&output.stderr_tail),
171                    6,
172                );
173                let summary = format!(
174                    "workspace dry-run: exit_code={}; sidecar={}; stderr_tail_summary={:?}",
175                    output.exit_code,
176                    sidecar_path.display(),
177                    tail_summary
178                );
179                (passed, summary)
180            }
181            Err(err) => (false, format!("workspace dry-run failed: {err:#}")),
182        }
183    } else if !effects.run_dry_run || opts.verify_mode == VerifyMode::None {
184        reporter.info("skipping dry-run (policy, --no-verify, or verify_mode=none)");
185        (
186            true,
187            "workspace dry-run skipped (policy, --no-verify, or verify_mode=none)".to_string(),
188        )
189    } else {
190        // Package mode — handled per-package below
191        (
192            true,
193            "workspace dry-run skipped (verify_mode=package)".to_string(),
194        )
195    };
196
197    event_log.record(PublishEvent {
198        timestamp: Utc::now(),
199        event_type: EventType::PreflightWorkspaceVerify {
200            passed: workspace_dry_run_passed,
201            output: workspace_dry_run_output.clone(),
202        },
203        package: "all".to_string(),
204    });
205
206    // Per-package dry-run results (used for Package mode)
207    let per_package_dry_run: std::collections::BTreeMap<String, (bool, Option<String>)> =
208        if effects.run_dry_run && opts.verify_mode == VerifyMode::Package {
209            reporter.info("running per-package dry-run verification...");
210            let mut results = std::collections::BTreeMap::new();
211            for p in &ws.plan.packages {
212                let result = cargo::cargo_publish_dry_run_package(
213                    workspace_root,
214                    &p.name,
215                    &ws.plan.registry.name,
216                    opts.allow_dirty,
217                    opts.output_lines,
218                );
219                let (passed, output) = match &result {
220                    Ok(out) => (
221                        out.exit_code == 0,
222                        Some(format!(
223                            "exit_code={}; stdout_tail={:?}; stderr_tail={:?}",
224                            out.exit_code, out.stdout_tail, out.stderr_tail
225                        )),
226                    ),
227                    Err(e) => (false, Some(format!("dry-run failed: {e:#}"))),
228                };
229                if !passed {
230                    reporter.warn(&format!("{}@{}: dry-run failed", p.name, p.version));
231                }
232                results.insert(p.name.clone(), (passed, output));
233            }
234            results
235        } else {
236            std::collections::BTreeMap::new()
237        };
238
239    // Check each package
240    reporter.info("checking packages against registry...");
241    let mut packages: Vec<PreflightPackage> = Vec::new();
242    let mut any_ownership_unverified = false;
243
244    for p in &ws.plan.packages {
245        let already_published = reg.version_exists(&p.name, &p.version)?;
246        let is_new_crate = reg.check_new_crate(&p.name)?;
247        if is_new_crate {
248            event_log.record(PublishEvent {
249                timestamp: Utc::now(),
250                event_type: EventType::PreflightNewCrateDetected {
251                    crate_name: p.name.clone(),
252                },
253                package: format!("{}@{}", p.name, p.version),
254            });
255        }
256
257        // Determine dry-run result for this package
258        let (dry_run_passed, dry_run_output) = if opts.verify_mode == VerifyMode::Package {
259            per_package_dry_run
260                .get(&p.name)
261                .cloned()
262                .unwrap_or((true, None))
263        } else {
264            (
265                workspace_dry_run_passed,
266                Some(workspace_dry_run_output.clone()),
267            )
268        };
269
270        // Ownership verification (best-effort), gated by policy
271        let ownership_verified = if token_detected && effects.check_ownership {
272            if effects.strict_ownership {
273                if is_new_crate {
274                    // New crates have no owners endpoint; skip ownership check
275                    reporter.info(&format!("{}: new crate, skipping ownership check", p.name));
276                    false
277                } else {
278                    // In strict mode, ownership errors are fatal
279                    reg.list_owners(&p.name, token.as_deref().unwrap())?;
280                    true
281                }
282            } else {
283                let result = reg
284                    .verify_ownership(&p.name, token.as_deref().unwrap())
285                    .unwrap_or_default();
286                if !result {
287                    reporter.warn(&format!(
288                        "owners preflight failed for {}; continuing (non-strict mode)",
289                        p.name
290                    ));
291                }
292                result
293            }
294        } else {
295            // No token, ownership check skipped, or policy disabled it
296            false
297        };
298
299        event_log.record(PublishEvent {
300            timestamp: Utc::now(),
301            event_type: EventType::PreflightOwnershipCheck {
302                crate_name: p.name.clone(),
303                verified: ownership_verified,
304            },
305            package: format!("{}@{}", p.name, p.version),
306        });
307
308        if !ownership_verified {
309            any_ownership_unverified = true;
310        }
311
312        packages.push(PreflightPackage {
313            name: p.name.clone(),
314            version: p.version.clone(),
315            already_published,
316            is_new_crate,
317            auth_type: auth_type.clone(),
318            ownership_verified,
319            dry_run_passed,
320            dry_run_output,
321        });
322    }
323
324    // For finishability: all packages must pass dry-run
325    let all_dry_run_passed = packages.iter().all(|p| p.dry_run_passed);
326
327    // Determine finishability
328    let finishability = if !all_dry_run_passed {
329        Finishability::Failed
330    } else if any_ownership_unverified {
331        Finishability::NotProven
332    } else {
333        Finishability::Proven
334    };
335
336    event_log.record(PublishEvent {
337        timestamp: Utc::now(),
338        event_type: EventType::PreflightComplete {
339            finishability: finishability.clone(),
340        },
341        package: "all".to_string(),
342    });
343    event_log.write_to_file(&events_path)?;
344
345    Ok(PreflightReport {
346        plan_id: ws.plan.plan_id.clone(),
347        token_detected,
348        finishability,
349        packages,
350        timestamp: Utc::now(),
351        dry_run_output: if opts.verify_mode == VerifyMode::Workspace {
352            Some(workspace_dry_run_output)
353        } else {
354            None
355        },
356    })
357}
358
359/// Enforce the rehearsal hard gate (#97 PR 3).
360///
361/// Rules, evaluated in order:
362///
363/// 1. **Rehearsal not configured** (`opts.rehearsal_registry` is `None`) →
364///    gate is dormant; publish proceeds. Rehearsal is opt-in; existing
365///    workflows that never set a rehearsal registry are unaffected.
366///
367/// 2. **Operator override** (`opts.rehearsal_skip` is `true`) → publish
368///    proceeds with a loud warning logged to the reporter. Use sparingly
369///    (incident response, bootstrap runs). The skip decision is
370///    operator-visible in stderr; it does *not* synthesize a passing
371///    rehearsal receipt, so the audit trail still shows "no rehearsal
372///    ran."
373///
374/// 3. **No rehearsal receipt** (`rehearsal.json` is missing) → refuse.
375///    The operator needs to run `shipper rehearse` first.
376///
377/// 4. **Stale receipt** (receipt exists but `plan_id` mismatches the
378///    current workspace's plan) → refuse. A workspace change between
379///    rehearse and publish invalidates the rehearsal.
380///
381/// 5. **Failing receipt** (`passed: false`) → refuse.
382///
383/// 6. **Fresh passing receipt for current plan** → publish proceeds.
384fn enforce_rehearsal_gate(
385    ws: &PlannedWorkspace,
386    opts: &RuntimeOptions,
387    state_dir: &Path,
388    reporter: &mut dyn Reporter,
389) -> Result<()> {
390    let Some(rehearsal_name) = opts.rehearsal_registry.as_deref() else {
391        return Ok(());
392    };
393
394    if opts.rehearsal_skip {
395        reporter.warn(&format!(
396            "--skip-rehearsal was set; publish is proceeding without a rehearsal against '{rehearsal_name}'. \
397             This is an operator-authorized bypass; auditors reading events.jsonl will see no RehearsalComplete event for this plan_id."
398        ));
399        return Ok(());
400    }
401
402    let receipt = crate::state::rehearsal::load_rehearsal(state_dir)
403        .context("failed to read rehearsal receipt while enforcing hard gate")?;
404
405    let rehearsal_path = crate::state::rehearsal::rehearsal_path(state_dir);
406
407    let receipt = match receipt {
408        Some(r) => r,
409        None => bail!(
410            "rehearsal is required (rehearsal registry '{rehearsal_name}' is configured) but no rehearsal receipt was found at {}. \
411             Run `shipper rehearse --rehearsal-registry {rehearsal_name}` first, \
412             or pass --skip-rehearsal to override (not recommended).",
413            rehearsal_path.display()
414        ),
415    };
416
417    if receipt.plan_id != ws.plan.plan_id {
418        bail!(
419            "rehearsal receipt is stale: rehearsal ran for plan_id {} but the current plan_id is {}. \
420             The workspace changed between rehearse and publish; re-run `shipper rehearse` against the current plan.",
421            receipt.plan_id,
422            ws.plan.plan_id
423        );
424    }
425
426    if !receipt.passed {
427        bail!(
428            "rehearsal against '{}' did NOT pass for plan_id {}: {}. \
429             Fix the cause and re-run `shipper rehearse` before publishing.",
430            receipt.registry,
431            receipt.plan_id,
432            receipt.summary
433        );
434    }
435
436    reporter.info(&format!(
437        "rehearsal gate: passing receipt found ({} packages against '{}', plan_id {})",
438        receipt.packages_published, receipt.registry, receipt.plan_id
439    ));
440    Ok(())
441}
442
443/// Execute the publish operation for all packages in the workspace.
444///
445/// This is the main publishing function that:
446/// 1. Acquires a distributed lock to prevent concurrent publishes
447/// 2. Checks git cleanliness (if configured)
448/// 3. Initializes or resumes from existing state
449/// 4. Publishes each package in dependency order
450/// 5. Verifies visibility on the registry after each publish
451/// 6. Writes a receipt with full evidence upon completion
452///
453/// # Arguments
454///
455/// * `ws` - The planned workspace containing packages to publish
456/// * `opts` - Runtime options controlling retry, readiness, policy, etc.
457/// * `reporter` - A reporter for outputting progress and warnings
458///
459/// # Returns
460///
461/// Returns a [`Receipt`] containing:
462/// - The plan ID and registry
463/// - Start and finish timestamps
464/// - Per-package receipts with evidence
465/// - Git context and environment fingerprint
466/// - Path to the event log
467///
468/// # Behavior
469///
470/// - **Resumability**: If interrupted, the state is persisted and `run_resume` can continue
471/// - **Parallel publishing**: If `opts.parallel.enabled` is true, uses parallel publishing
472/// - **Readiness checks**: Verifies crate visibility after publishing (configurable)
473/// - **Retry logic**: Retries transient failures with exponential backoff
474///
475/// # Error Handling
476///
477/// Returns an error if:
478/// - Lock acquisition fails
479/// - Git check fails (when required)
480/// - A permanent error occurs (e.g., authentication failure)
481/// - All retry attempts are exhausted
482pub fn run_publish(
483    ws: &PlannedWorkspace,
484    opts: &RuntimeOptions,
485    reporter: &mut dyn Reporter,
486) -> Result<Receipt> {
487    let workspace_root = &ws.workspace_root;
488    let state_dir = resolve_state_dir(workspace_root, &opts.state_dir);
489    let effects = policy_effects(opts);
490
491    // Validate resume_from if specified
492    if let Some(ref target) = opts.resume_from
493        && !ws.plan.packages.iter().any(|p| &p.name == target)
494    {
495        bail!("resume-from package '{}' not found in publish plan", target);
496    }
497
498    // #97 PR 3: rehearsal hard gate. Only fires when a rehearsal registry
499    // is configured; opt-in until rehearsal phase-2 is stable.
500    enforce_rehearsal_gate(ws, opts, &state_dir, reporter)?;
501
502    // Acquire lock
503    let lock_timeout = if opts.force {
504        Duration::ZERO
505    } else {
506        opts.lock_timeout
507    };
508    let _lock =
509        lock::LockFile::acquire_with_timeout(&state_dir, Some(workspace_root), lock_timeout)
510            .context("failed to acquire publish lock")?;
511    _lock.set_plan_id(&ws.plan.plan_id)?;
512
513    // Collect git context and environment fingerprint at start of execution
514    let git_context = git::collect_git_context();
515    let environment = environment::collect_environment_fingerprint();
516
517    if !opts.allow_dirty {
518        git::ensure_git_clean(workspace_root)?;
519    }
520
521    let reg = init_registry_client(ws.plan.registry.clone(), &state_dir)?;
522
523    // Initialize event log
524    let events_path = events::events_path(&state_dir);
525    let mut event_log = events::EventLog::new();
526
527    // Load existing state (if any), or initialize.
528    let mut st = match state::load_state(&state_dir)? {
529        Some(existing) => {
530            if existing.plan_id != ws.plan.plan_id {
531                if !opts.force_resume {
532                    bail!(
533                        "existing state plan_id {} does not match current plan_id {}; delete state or use --force-resume",
534                        existing.plan_id,
535                        ws.plan.plan_id
536                    );
537                }
538                reporter.warn("forcing resume with mismatched plan_id (unsafe)");
539            }
540            existing
541        }
542        None => init_state(ws, &state_dir)?,
543    };
544
545    reporter.info(&format!("state dir: {}", state_dir.as_path().display()));
546
547    let mut receipts: Vec<PackageReceipt> = Vec::new();
548    let run_started = Utc::now();
549
550    // Event: ExecutionStarted
551    event_log.record(PublishEvent {
552        timestamp: run_started,
553        event_type: EventType::ExecutionStarted,
554        package: "all".to_string(),
555    });
556    // Send webhook notification: publish started
557    webhook::maybe_send_event(
558        &opts.webhook,
559        WebhookEvent::PublishStarted {
560            plan_id: ws.plan.plan_id.clone(),
561            package_count: ws.plan.packages.len(),
562            registry: ws.plan.registry.name.clone(),
563        },
564    );
565    // Event: PlanCreated
566    event_log.record(PublishEvent {
567        timestamp: run_started,
568        event_type: EventType::PlanCreated {
569            plan_id: ws.plan.plan_id.clone(),
570            package_count: ws.plan.packages.len(),
571        },
572        package: "all".to_string(),
573    });
574    event_log.write_to_file(&events_path)?;
575    event_log.clear();
576
577    // Ensure we have entries for all packages in plan.
578    for p in &ws.plan.packages {
579        let key = pkg_key(&p.name, &p.version);
580        st.packages.entry(key).or_insert_with(|| PackageProgress {
581            name: p.name.clone(),
582            version: p.version.clone(),
583            attempts: 0,
584            state: PackageState::Pending,
585            last_updated_at: Utc::now(),
586        });
587    }
588    st.updated_at = Utc::now();
589    state::save_state(&state_dir, &st)?;
590
591    // Track if we've reached the resume point if one was specified
592    let mut reached_resume_point = opts.resume_from.is_none();
593
594    // Check for parallel mode
595    if opts.parallel.enabled {
596        let parallel_receipts = crate::engine::parallel::run_publish_parallel(
597            ws, opts, &mut st, &state_dir, &reg, reporter,
598        )?;
599
600        // End-of-run events-as-truth consistency check. Events are
601        // authoritative; state.json is a projection. Any drift means either
602        // the projection got stale or something bypassed the event log —
603        // surface it loudly rather than trust a bad resume. See #93 and
604        // docs/INVARIANTS.md.
605        match crate::state::consistency::verify_events_state_consistency(&events_path, &st) {
606            Ok(drift) if !drift.is_consistent() => {
607                reporter.warn(&crate::state::consistency::format_drift_summary(&drift));
608                event_log.record(PublishEvent {
609                    timestamp: Utc::now(),
610                    event_type: EventType::StateEventDriftDetected { drift },
611                    package: "all".to_string(),
612                });
613            }
614            Ok(_) => {}
615            Err(e) => reporter.warn(&format!("end-of-run consistency check failed: {e}")),
616        }
617
618        // Event: ExecutionFinished
619        let exec_result = if parallel_receipts.iter().all(|r| {
620            matches!(
621                r.state,
622                PackageState::Published | PackageState::Skipped { .. }
623            )
624        }) {
625            ExecutionResult::Success
626        } else {
627            ExecutionResult::PartialFailure
628        };
629        event_log.record(PublishEvent {
630            timestamp: Utc::now(),
631            event_type: EventType::ExecutionFinished {
632                result: exec_result,
633            },
634            package: "all".to_string(),
635        });
636        event_log.write_to_file(&events_path)?;
637
638        let receipt = Receipt {
639            receipt_version: "shipper.receipt.v2".to_string(),
640            plan_id: ws.plan.plan_id.clone(),
641            registry: ws.plan.registry.clone(),
642            started_at: run_started,
643            finished_at: Utc::now(),
644            packages: parallel_receipts,
645            event_log_path: state_dir.join("events.jsonl"),
646            git_context,
647            environment,
648        };
649
650        state::write_receipt(&state_dir, &receipt)?;
651        return Ok(receipt);
652    }
653
654    for p in &ws.plan.packages {
655        let key = pkg_key(&p.name, &p.version);
656        let pkg_label = format!("{}@{}", p.name, p.version);
657        let progress = st
658            .packages
659            .get(&key)
660            .context("missing package progress in state")?
661            .clone();
662
663        // Check if we've reached the resume point
664        if !reached_resume_point {
665            if Some(&p.name) == opts.resume_from.as_ref() {
666                reached_resume_point = true;
667            } else {
668                // If it's already done, just skip it silently
669                if matches!(
670                    progress.state,
671                    PackageState::Published | PackageState::Skipped { .. }
672                ) {
673                    reporter.info(&format!(
674                        "{}@{}: already complete (skipping)",
675                        p.name, p.version
676                    ));
677                    continue;
678                } else {
679                    // It's not done, but we're skipping it because of resume_from
680                    reporter.warn(&format!(
681                        "{}@{}: skipping (before resume point {})",
682                        p.name,
683                        p.version,
684                        opts.resume_from.as_ref().unwrap()
685                    ));
686                    continue;
687                }
688            }
689        }
690
691        // Track whether cargo publish already succeeded (e.g. from Uploaded state on resume)
692        let mut cargo_succeeded = false;
693
694        match progress.state.clone() {
695            PackageState::Published | PackageState::Skipped { .. } => {
696                let short = short_state(&progress.state);
697                reporter.info(&format!(
698                    "{}@{}: already complete ({})",
699                    p.name, p.version, short
700                ));
701                // #125: emit a PackageSkipped event so the audit trail
702                // explicitly records resume's "state already terminal,
703                // trusting it" decision. Without this, events.jsonl is
704                // silent about the skip and an auditor reading only
705                // events can't distinguish "resume recognized and
706                // skipped" from "resume never touched this package."
707                //
708                // Deliberately NOT pushing a PackageReceipt here: the
709                // receipt vector has always excluded already-terminal
710                // packages in the resume path, and callers depend on
711                // that shape (see e.g. `run_resume_runs_publish_when_state_exists`).
712                // The new event is the observable fix; receipt shape
713                // is preserved.
714                event_log.record(PublishEvent {
715                    timestamp: Utc::now(),
716                    event_type: EventType::PackageSkipped {
717                        reason: format!("resume: state already {short}"),
718                    },
719                    package: pkg_label.clone(),
720                });
721                event_log.write_to_file(&events_path)?;
722                event_log.clear();
723                continue;
724            }
725            PackageState::Uploaded => {
726                reporter.info(&format!(
727                    "{}@{}: resuming from uploaded (skipping cargo publish)",
728                    p.name, p.version
729                ));
730                cargo_succeeded = true;
731            }
732            PackageState::Ambiguous {
733                message: prior_reason,
734            } => {
735                // Resume-path reconciliation (#99 follow-on). A prior run left
736                // this package in Ambiguous state (reconciliation inconclusive).
737                // Before doing ANY further work, reconcile against the
738                // registry so we never re-upload a crate whose prior upload
739                // may have actually succeeded.
740                reporter.warn(&format!(
741                    "{}@{}: resume found ambiguous state ({}); reconciling against registry",
742                    p.name, p.version, prior_reason
743                ));
744
745                let readiness_config = crate::types::ReadinessConfig {
746                    enabled: effects.readiness_enabled,
747                    ..opts.readiness.clone()
748                };
749
750                event_log.record(PublishEvent {
751                    timestamp: Utc::now(),
752                    event_type: EventType::PublishReconciling {
753                        method: readiness_config.method,
754                    },
755                    package: pkg_label.clone(),
756                });
757
758                let (outcome, _evidence) =
759                    sequential_reconcile(&reg, &p.name, &p.version, &readiness_config);
760
761                event_log.record(PublishEvent {
762                    timestamp: Utc::now(),
763                    event_type: EventType::PublishReconciled {
764                        outcome: outcome.clone(),
765                    },
766                    package: pkg_label.clone(),
767                });
768                event_log.write_to_file(&events_path)?;
769                event_log.clear();
770
771                match outcome {
772                    ReconciliationOutcome::Published { .. } => {
773                        update_state(&mut st, &state_dir, &key, PackageState::Published)?;
774                        reporter.info(&format!(
775                            "{}@{}: reconciled as published on resume (no republish)",
776                            p.name, p.version
777                        ));
778                        continue;
779                    }
780                    ReconciliationOutcome::NotPublished { .. } => {
781                        // Clear the Ambiguous state — the registry confirms
782                        // no prior upload succeeded, so falling through to
783                        // the normal publish flow is safe.
784                        update_state(&mut st, &state_dir, &key, PackageState::Pending)?;
785                        reporter.info(&format!(
786                            "{}@{}: reconciled as not published; proceeding with publish",
787                            p.name, p.version
788                        ));
789                        // fall through to normal flow
790                    }
791                    ReconciliationOutcome::StillUnknown { reason, .. } => {
792                        reporter.error(&format!(
793                            "{}@{}: resume reconciliation still inconclusive: {}",
794                            p.name, p.version, reason
795                        ));
796                        webhook::maybe_send_event(
797                            &opts.webhook,
798                            WebhookEvent::PublishFailed {
799                                plan_id: ws.plan.plan_id.clone(),
800                                package_name: p.name.clone(),
801                                package_version: p.version.clone(),
802                                error_class: format!("{:?}", ErrorClass::Ambiguous),
803                                message: format!(
804                                    "resume reconciliation still inconclusive: {reason}"
805                                ),
806                            },
807                        );
808                        bail!(
809                            "{}@{}: resume reconciliation still inconclusive; operator action required. Prior reason: {}",
810                            p.name,
811                            p.version,
812                            reason
813                        );
814                    }
815                }
816            }
817            _ => {}
818        }
819
820        // Event: PackageStarted
821        event_log.record(PublishEvent {
822            timestamp: Utc::now(),
823            event_type: EventType::PackageStarted {
824                name: p.name.clone(),
825                version: p.version.clone(),
826            },
827            package: pkg_label.clone(),
828        });
829
830        let started_at = Utc::now();
831        let start_instant = Instant::now();
832
833        // First, check if the version is already present.
834        if reg.version_exists(&p.name, &p.version)? {
835            reporter.info(&format!(
836                "{}@{}: already published (skipping)",
837                p.name, p.version
838            ));
839            let skipped = PackageState::Skipped {
840                reason: "already published".into(),
841            };
842            update_state(&mut st, &state_dir, &key, skipped)?;
843
844            // Event: PackageSkipped
845            event_log.record(PublishEvent {
846                timestamp: Utc::now(),
847                event_type: EventType::PackageSkipped {
848                    reason: "already published".to_string(),
849                },
850                package: pkg_label.clone(),
851            });
852            event_log.write_to_file(&events_path)?;
853            event_log.clear();
854
855            let progress = st
856                .packages
857                .get(&key)
858                .context("missing package progress in state for skipped package")?;
859            receipts.push(PackageReceipt {
860                name: p.name.clone(),
861                version: p.version.clone(),
862                attempts: progress.attempts,
863                state: progress.state.clone(),
864                started_at,
865                finished_at: Utc::now(),
866                duration_ms: start_instant.elapsed().as_millis(),
867                evidence: crate::types::PackageEvidence {
868                    attempts: vec![],
869                    readiness_checks: vec![],
870                },
871                compromised_at: None,
872                compromised_by: None,
873                superseded_by: None,
874            });
875            continue;
876        }
877
878        reporter.info(&format!("{}@{}: publishing...", p.name, p.version));
879
880        // Registry-aware backoff (#94): lazy-cached "is this a new crate?"
881        // Only queried when a retry's error message looks like a rate limit,
882        // so the happy path costs zero extra registry calls.
883        let mut is_new_crate_cached: Option<bool> = None;
884
885        let mut attempt = st
886            .packages
887            .get(&key)
888            .context("missing package progress in state for publish")?
889            .attempts;
890        let mut last_err: Option<(ErrorClass, String)> = None;
891        let mut attempt_evidence: Vec<AttemptEvidence> = Vec::new();
892        let mut readiness_evidence: Vec<ReadinessEvidence> = Vec::new();
893
894        while attempt < opts.max_attempts {
895            attempt += 1;
896            {
897                let pr = st
898                    .packages
899                    .get_mut(&key)
900                    .context("missing package progress in state during attempt")?;
901                pr.attempts = attempt;
902                pr.last_updated_at = Utc::now();
903                state::save_state(&state_dir, &st)?;
904            }
905
906            let command = format!(
907                "cargo publish -p {} --registry {}",
908                p.name, ws.plan.registry.name
909            );
910
911            reporter.info(&format!(
912                "{}@{}: attempt {}/{}",
913                p.name, p.version, attempt, opts.max_attempts
914            ));
915
916            if !cargo_succeeded {
917                // Event: PackageAttempted
918                event_log.record(PublishEvent {
919                    timestamp: Utc::now(),
920                    event_type: EventType::PackageAttempted {
921                        attempt,
922                        command: command.clone(),
923                    },
924                    package: pkg_label.clone(),
925                });
926
927                let out = cargo::cargo_publish(
928                    workspace_root,
929                    &p.name,
930                    &ws.plan.registry.name,
931                    opts.allow_dirty,
932                    opts.no_verify,
933                    opts.output_lines,
934                    None, // sequential mode: no per-package timeout
935                )?;
936
937                // Collect attempt evidence
938                attempt_evidence.push(AttemptEvidence {
939                    attempt_number: attempt,
940                    command: command.clone(),
941                    exit_code: out.exit_code,
942                    stdout_tail: out.stdout_tail.clone(),
943                    stderr_tail: out.stderr_tail.clone(),
944                    timestamp: Utc::now(),
945                    duration: out.duration,
946                });
947
948                // Event: PackageOutput
949                event_log.record(PublishEvent {
950                    timestamp: Utc::now(),
951                    event_type: EventType::PackageOutput {
952                        stdout_tail: out.stdout_tail.clone(),
953                        stderr_tail: out.stderr_tail.clone(),
954                    },
955                    package: pkg_label.clone(),
956                });
957
958                if out.exit_code == 0 {
959                    cargo_succeeded = true;
960                    // Persist Uploaded state so resume skips cargo publish
961                    update_state(&mut st, &state_dir, &key, PackageState::Uploaded)?;
962                } else {
963                    // Even if cargo fails, the publish may have succeeded (timeouts, network splits).
964                    // Always check the registry before deciding.
965                    reporter.warn(&format!(
966                        "{}@{}: cargo publish failed (exit={:?}); checking registry...",
967                        p.name, p.version, out.exit_code
968                    ));
969
970                    if reg.version_exists(&p.name, &p.version)? {
971                        reporter.info(&format!(
972                            "{}@{}: version is present on registry; treating as published",
973                            p.name, p.version
974                        ));
975                        update_state(&mut st, &state_dir, &key, PackageState::Published)?;
976                        last_err = None;
977                        break;
978                    }
979
980                    let (class, msg) = classify_cargo_failure(&out.stderr_tail, &out.stdout_tail);
981                    last_err = Some((class.clone(), msg.clone()));
982
983                    match class {
984                        ErrorClass::Permanent => {
985                            let failed = PackageState::Failed {
986                                class: class.clone(),
987                                message: msg.clone(),
988                            };
989                            update_state(&mut st, &state_dir, &key, failed)?;
990
991                            // Event: PackageFailed
992                            event_log.record(PublishEvent {
993                                timestamp: Utc::now(),
994                                event_type: EventType::PackageFailed {
995                                    class,
996                                    message: msg,
997                                },
998                                package: pkg_label.clone(),
999                            });
1000                            event_log.write_to_file(&events_path)?;
1001                            event_log.clear();
1002
1003                            return Err(anyhow::anyhow!(
1004                                "{}@{}: permanent failure: {}",
1005                                p.name,
1006                                p.version,
1007                                last_err.unwrap().1
1008                            ));
1009                        }
1010                        ErrorClass::Retryable | ErrorClass::Ambiguous => {
1011                            let is_new_crate =
1012                                if crate::runtime::execution::looks_like_rate_limit(&msg) {
1013                                    *is_new_crate_cached.get_or_insert_with(|| {
1014                                        reg.check_new_crate(&p.name).unwrap_or(false)
1015                                    })
1016                                } else {
1017                                    false
1018                                };
1019                            let delay = registry_aware_backoff(
1020                                opts.base_delay,
1021                                opts.max_delay,
1022                                attempt,
1023                                opts.retry_strategy,
1024                                opts.retry_jitter,
1025                                is_new_crate,
1026                                &msg,
1027                            );
1028                            emit_retry_backoff_event(
1029                                &mut event_log,
1030                                &events_path,
1031                                reporter,
1032                                &pkg_label,
1033                                &p.name,
1034                                &p.version,
1035                                attempt,
1036                                opts.max_attempts,
1037                                delay,
1038                                class.clone(),
1039                                &msg,
1040                            )?;
1041                        }
1042                    }
1043                    continue;
1044                }
1045            }
1046
1047            // Readiness verification (runs after first cargo success + all retries)
1048            reporter.info(&format!(
1049                "{}@{}: cargo publish exited successfully; verifying...",
1050                p.name, p.version
1051            ));
1052            let readiness_config = crate::types::ReadinessConfig {
1053                enabled: effects.readiness_enabled,
1054                ..opts.readiness.clone()
1055            };
1056            let (visible, checks) =
1057                verify_published(&reg, &p.name, &p.version, &readiness_config, reporter)?;
1058            readiness_evidence = checks;
1059            if visible {
1060                update_state(&mut st, &state_dir, &key, PackageState::Published)?;
1061                last_err = None;
1062
1063                // Event: PackagePublished
1064                event_log.record(PublishEvent {
1065                    timestamp: Utc::now(),
1066                    event_type: EventType::PackagePublished {
1067                        duration_ms: start_instant.elapsed().as_millis() as u64,
1068                    },
1069                    package: pkg_label.clone(),
1070                });
1071                event_log.write_to_file(&events_path)?;
1072                event_log.clear();
1073
1074                // Send webhook notification: package succeeded
1075                webhook::maybe_send_event(
1076                    &opts.webhook,
1077                    WebhookEvent::PublishSucceeded {
1078                        plan_id: ws.plan.plan_id.clone(),
1079                        package_name: p.name.clone(),
1080                        package_version: p.version.clone(),
1081                        duration_ms: start_instant.elapsed().as_millis() as u64,
1082                    },
1083                );
1084
1085                break;
1086            } else {
1087                let message =
1088                    "published locally, but version not observed on registry within timeout";
1089                last_err = Some((ErrorClass::Ambiguous, message.to_string()));
1090                let delay = backoff_delay(
1091                    opts.base_delay,
1092                    opts.max_delay,
1093                    attempt,
1094                    opts.retry_strategy,
1095                    opts.retry_jitter,
1096                );
1097                emit_retry_backoff_event(
1098                    &mut event_log,
1099                    &events_path,
1100                    reporter,
1101                    &pkg_label,
1102                    &p.name,
1103                    &p.version,
1104                    attempt,
1105                    opts.max_attempts,
1106                    delay,
1107                    ErrorClass::Ambiguous,
1108                    message,
1109                )?;
1110            }
1111        }
1112
1113        // If package is still Uploaded (loop didn't run or readiness never checked), force a final check
1114        if last_err.is_none() {
1115            let current_state = st.packages.get(&key).map(|p| &p.state);
1116            if matches!(current_state, Some(PackageState::Uploaded)) {
1117                if reg.version_exists(&p.name, &p.version)? {
1118                    update_state(&mut st, &state_dir, &key, PackageState::Published)?;
1119                } else {
1120                    last_err = Some((
1121                        ErrorClass::Ambiguous,
1122                        "package was uploaded but not confirmed visible on registry".into(),
1123                    ));
1124                }
1125            }
1126        }
1127
1128        let finished_at = Utc::now();
1129        let duration_ms = start_instant.elapsed().as_millis();
1130
1131        if let Some((class, msg)) = last_err {
1132            // Final chance: maybe it eventually showed up.
1133            if reg.version_exists(&p.name, &p.version)? {
1134                update_state(&mut st, &state_dir, &key, PackageState::Published)?;
1135            } else {
1136                let failed = PackageState::Failed {
1137                    class: class.clone(),
1138                    message: msg.clone(),
1139                };
1140                update_state(&mut st, &state_dir, &key, failed)?;
1141
1142                // Event: PackageFailed
1143                event_log.record(PublishEvent {
1144                    timestamp: Utc::now(),
1145                    event_type: EventType::PackageFailed {
1146                        class: class.clone(),
1147                        message: msg.clone(),
1148                    },
1149                    package: pkg_label.clone(),
1150                });
1151                event_log.write_to_file(&events_path)?;
1152                event_log.clear();
1153
1154                // Send webhook notification: package failed
1155                webhook::maybe_send_event(
1156                    &opts.webhook,
1157                    WebhookEvent::PublishFailed {
1158                        plan_id: ws.plan.plan_id.clone(),
1159                        package_name: p.name.clone(),
1160                        package_version: p.version.clone(),
1161                        error_class: format!("{:?}", class.clone()),
1162                        message: msg.clone(),
1163                    },
1164                );
1165
1166                let progress = st
1167                    .packages
1168                    .get(&key)
1169                    .context("missing package progress in state for failed package")?;
1170                receipts.push(PackageReceipt {
1171                    name: p.name.clone(),
1172                    version: p.version.clone(),
1173                    attempts: progress.attempts,
1174                    state: progress.state.clone(),
1175                    started_at,
1176                    finished_at,
1177                    duration_ms,
1178                    evidence: crate::types::PackageEvidence {
1179                        attempts: attempt_evidence,
1180                        readiness_checks: readiness_evidence,
1181                    },
1182                    compromised_at: None,
1183                    compromised_by: None,
1184                    superseded_by: None,
1185                });
1186                return Err(anyhow::anyhow!("{}@{}: failed: {}", p.name, p.version, msg));
1187            }
1188        }
1189
1190        let progress = st
1191            .packages
1192            .get(&key)
1193            .context("missing package progress in state for completed package")?;
1194        receipts.push(PackageReceipt {
1195            name: p.name.clone(),
1196            version: p.version.clone(),
1197            attempts: progress.attempts,
1198            state: progress.state.clone(),
1199            started_at,
1200            finished_at,
1201            duration_ms,
1202            evidence: crate::types::PackageEvidence {
1203                attempts: attempt_evidence,
1204                readiness_checks: readiness_evidence,
1205            },
1206            compromised_at: None,
1207            compromised_by: None,
1208            superseded_by: None,
1209        });
1210    }
1211
1212    // End-of-run events-as-truth consistency check. Events are authoritative;
1213    // state.json is a projection. Any drift means either the projection got
1214    // stale or something bypassed the event log — surface it loudly rather
1215    // than trust a bad resume. See #93 and docs/INVARIANTS.md.
1216    match crate::state::consistency::verify_events_state_consistency(&events_path, &st) {
1217        Ok(drift) if !drift.is_consistent() => {
1218            reporter.warn(&crate::state::consistency::format_drift_summary(&drift));
1219            event_log.record(PublishEvent {
1220                timestamp: Utc::now(),
1221                event_type: EventType::StateEventDriftDetected { drift },
1222                package: "all".to_string(),
1223            });
1224        }
1225        Ok(_) => {}
1226        Err(e) => reporter.warn(&format!("end-of-run consistency check failed: {e}")),
1227    }
1228
1229    // Event: ExecutionFinished
1230    let exec_result = if receipts.iter().all(|r| {
1231        matches!(
1232            r.state,
1233            PackageState::Published | PackageState::Uploaded | PackageState::Skipped { .. }
1234        )
1235    }) {
1236        ExecutionResult::Success
1237    } else {
1238        ExecutionResult::PartialFailure
1239    };
1240    event_log.record(PublishEvent {
1241        timestamp: Utc::now(),
1242        event_type: EventType::ExecutionFinished {
1243            result: exec_result.clone(),
1244        },
1245        package: "all".to_string(),
1246    });
1247    event_log.write_to_file(&events_path)?;
1248
1249    // Calculate publish completion statistics
1250    let total_packages = receipts.len();
1251    let success_count = receipts
1252        .iter()
1253        .filter(|r| matches!(r.state, PackageState::Published))
1254        .count();
1255    let failure_count = receipts
1256        .iter()
1257        .filter(|r| matches!(r.state, PackageState::Failed { .. }))
1258        .count();
1259    let skipped_count = receipts
1260        .iter()
1261        .filter(|r| matches!(r.state, PackageState::Skipped { .. }))
1262        .count();
1263
1264    // Send webhook notification: all complete
1265    webhook::maybe_send_event(
1266        &opts.webhook,
1267        WebhookEvent::PublishCompleted {
1268            plan_id: ws.plan.plan_id.clone(),
1269            total_packages,
1270            success_count,
1271            failure_count,
1272            skipped_count,
1273            result: match exec_result {
1274                ExecutionResult::Success => "success".to_string(),
1275                ExecutionResult::PartialFailure => "partial_failure".to_string(),
1276                ExecutionResult::CompleteFailure => "complete_failure".to_string(),
1277            },
1278        },
1279    );
1280
1281    let receipt = Receipt {
1282        receipt_version: "shipper.receipt.v2".to_string(),
1283        plan_id: ws.plan.plan_id.clone(),
1284        registry: ws.plan.registry.clone(),
1285        started_at: run_started,
1286        finished_at: Utc::now(),
1287        packages: receipts,
1288        event_log_path: state_dir.join("events.jsonl"),
1289        git_context,
1290        environment,
1291    };
1292
1293    state::write_receipt(&state_dir, &receipt)?;
1294
1295    Ok(receipt)
1296}
1297
1298/// Resume a previously interrupted publish operation.
1299///
1300/// This function loads existing state from the state directory and continues
1301/// publishing from where it left off. It handles:
1302/// - Packages that were never attempted (Pending)
1303/// - Packages that failed and should be retried
1304/// - Packages that were uploaded but not verified (Uploaded)
1305/// - Already-successful packages (Published/Skipped) - skipped automatically
1306///
1307/// # Arguments
1308///
1309/// * `ws` - The planned workspace (should match the original plan)
1310/// * `opts` - Runtime options
1311/// * `reporter` - A reporter for outputting progress
1312///
1313/// # Returns
1314///
1315/// Returns a [`Receipt`] similar to [`run_publish`].
1316///
1317/// # Error Handling
1318///
1319/// Returns an error if:
1320/// - No existing state is found in the state directory
1321/// - The plan ID doesn't match (use `opts.force_resume` to override)
1322/// - Lock acquisition fails
1323///
1324/// # Example
1325///
1326/// ```ignore
1327/// let ws = plan::build_plan(&spec)?;
1328/// let opts = types::RuntimeOptions { /* ... */ };
1329/// let mut reporter = MyReporter::default();
1330/// let receipt = engine::run_resume(&ws, &opts, &mut reporter)?;
1331/// println!("Published {} packages", receipt.packages.len());
1332/// ```
1333pub fn run_resume(
1334    ws: &PlannedWorkspace,
1335    opts: &RuntimeOptions,
1336    reporter: &mut dyn Reporter,
1337) -> Result<Receipt> {
1338    let workspace_root = &ws.workspace_root;
1339    let state_dir = resolve_state_dir(workspace_root, &opts.state_dir);
1340    if state::load_state(&state_dir)?.is_none() {
1341        bail!(
1342            "no existing state found in {}; run shipper publish first",
1343            state_dir.display()
1344        );
1345    }
1346    run_publish(ws, opts, reporter)
1347}
1348
1349/// Outcome of a rehearsal run. Sufficient for callers (CLI, future hard gate)
1350/// to decide whether live dispatch is authorized without re-reading events.
1351///
1352/// #97 PR 2. The hard gate (#97 PR 3) will bind this outcome to a `plan_id`
1353/// so "rehearsal passed" can't be claimed for a different workspace state.
1354#[derive(Debug, Clone)]
1355pub struct RehearsalOutcome {
1356    pub passed: bool,
1357    pub registry_name: String,
1358    pub packages_attempted: usize,
1359    pub packages_published: usize,
1360    pub summary: String,
1361}
1362
1363/// Run a rehearsal publish against an alternate registry (#97 PR 2).
1364///
1365/// Phase-2 preflight: publish every crate in the plan to a non-live
1366/// registry, verify each is visible on that registry, and emit a
1367/// `RehearsalComplete` event summarizing the outcome.
1368///
1369/// **Contract**:
1370/// - Reads `opts.rehearsal_registry` (set via `--rehearsal-registry` or
1371///   `[rehearsal]` config). Must resolve to a [`Registry`] entry in
1372///   `opts.registries`; bails clean otherwise.
1373/// - Refuses to rehearse against the live target (`ws.plan.registry`).
1374///   Rehearsal and live must be different registries.
1375/// - Runs sequentially (no parallel yet); stops at the first failure.
1376/// - Does NOT touch `state.json`. Rehearsal is a pre-publish proof, not
1377///   an execution; it only appends to `events.jsonl` so auditors can
1378///   replay the rehearsal from the event log.
1379/// - Post-publish visibility check uses the SAME readiness mechanism as
1380///   live publish (`reg.version_exists`). A rehearsal artifact that's
1381///   not visible within the readiness window fails the rehearsal.
1382///
1383/// **Not in this PR**:
1384/// - Hard gate wiring into `run_publish` (PR 3).
1385/// - Install/smoke check against the rehearsal registry (PR 3 or 4).
1386/// - Parallel rehearsal (not planned; rehearsal is infrequent and
1387///   correctness > speed).
1388pub fn run_rehearsal(
1389    ws: &PlannedWorkspace,
1390    opts: &RuntimeOptions,
1391    reporter: &mut dyn Reporter,
1392) -> Result<RehearsalOutcome> {
1393    let rehearsal_name = opts
1394        .rehearsal_registry
1395        .as_ref()
1396        .ok_or_else(|| {
1397            anyhow::anyhow!(
1398                "no rehearsal registry configured; set --rehearsal-registry <name> \
1399             or enable [rehearsal] in .shipper.toml"
1400            )
1401        })?
1402        .clone();
1403
1404    if opts.rehearsal_skip {
1405        reporter.warn(&format!(
1406            "--skip-rehearsal set; rehearsal against '{rehearsal_name}' was requested but will not run. \
1407             Once #97 PR 3 lands, live dispatch will refuse without a prior passing rehearsal."
1408        ));
1409        return Ok(RehearsalOutcome {
1410            passed: false,
1411            registry_name: rehearsal_name,
1412            packages_attempted: 0,
1413            packages_published: 0,
1414            summary: "skipped by --skip-rehearsal".to_string(),
1415        });
1416    }
1417
1418    let rehearsal_reg = opts
1419        .registries
1420        .iter()
1421        .find(|r| r.name == rehearsal_name)
1422        .cloned()
1423        .ok_or_else(|| {
1424            anyhow::anyhow!(
1425                "rehearsal registry '{rehearsal_name}' is not configured. \
1426             Add it to [[registries]] in .shipper.toml or pass --registries."
1427            )
1428        })?;
1429
1430    if rehearsal_reg.name == ws.plan.registry.name {
1431        bail!(
1432            "rehearsal registry '{}' must differ from the live target; \
1433             pick a sandbox registry (e.g. kellnr, a fresh crates-io test account, \
1434             or a throwaway alternate-registry entry)",
1435            rehearsal_reg.name
1436        );
1437    }
1438
1439    let workspace_root = &ws.workspace_root;
1440    let state_dir = resolve_state_dir(workspace_root, &opts.state_dir);
1441    std::fs::create_dir_all(&state_dir)
1442        .with_context(|| format!("failed to create state dir {}", state_dir.display()))?;
1443    let events_path = events::events_path(&state_dir);
1444    let mut event_log = events::EventLog::new();
1445    let started_at = Utc::now();
1446
1447    reporter.info(&format!(
1448        "rehearsal starting — {} packages against '{}'",
1449        ws.plan.packages.len(),
1450        rehearsal_name
1451    ));
1452
1453    event_log.record(PublishEvent {
1454        timestamp: Utc::now(),
1455        event_type: EventType::RehearsalStarted {
1456            registry: rehearsal_name.clone(),
1457            plan_id: ws.plan.plan_id.clone(),
1458            package_count: ws.plan.packages.len(),
1459        },
1460        package: "all".to_string(),
1461    });
1462    event_log.write_to_file(&events_path)?;
1463    event_log.clear();
1464
1465    let rehearsal_client = init_registry_client(rehearsal_reg.clone(), &state_dir)?;
1466
1467    let mut packages_published: usize = 0;
1468    let mut first_failure: Option<String> = None;
1469
1470    for p in &ws.plan.packages {
1471        let pkg_label = format!("{}@{}", p.name, p.version);
1472        reporter.info(&format!("rehearsing {pkg_label} → {rehearsal_name}"));
1473        let start = Instant::now();
1474
1475        let out = cargo::cargo_publish(
1476            workspace_root,
1477            &p.name,
1478            &rehearsal_reg.name,
1479            opts.allow_dirty,
1480            opts.no_verify,
1481            opts.output_lines,
1482            None,
1483        )?;
1484
1485        if out.exit_code != 0 {
1486            let (class, msg) = classify_cargo_failure(&out.stderr_tail, &out.stdout_tail);
1487            reporter.error(&format!(
1488                "rehearsal failed for {pkg_label}: {msg}\nstderr tail:\n{}",
1489                out.stderr_tail
1490            ));
1491            event_log.record(PublishEvent {
1492                timestamp: Utc::now(),
1493                event_type: EventType::RehearsalPackageFailed {
1494                    name: p.name.clone(),
1495                    version: p.version.clone(),
1496                    class,
1497                    message: msg.clone(),
1498                },
1499                package: pkg_label.clone(),
1500            });
1501            event_log.write_to_file(&events_path)?;
1502            event_log.clear();
1503            first_failure = Some(format!("{pkg_label}: {msg}"));
1504            break;
1505        }
1506
1507        // Post-publish visibility check on the rehearsal registry. Reuse
1508        // `version_exists` — same mechanism live publish trusts.
1509        if !rehearsal_client.version_exists(&p.name, &p.version)? {
1510            let msg = format!(
1511                "rehearsal: cargo publish succeeded but {pkg_label} is not visible on '{rehearsal_name}'"
1512            );
1513            reporter.error(&msg);
1514            event_log.record(PublishEvent {
1515                timestamp: Utc::now(),
1516                event_type: EventType::RehearsalPackageFailed {
1517                    name: p.name.clone(),
1518                    version: p.version.clone(),
1519                    class: ErrorClass::Ambiguous,
1520                    message: msg.clone(),
1521                },
1522                package: pkg_label.clone(),
1523            });
1524            event_log.write_to_file(&events_path)?;
1525            event_log.clear();
1526            first_failure = Some(msg);
1527            break;
1528        }
1529
1530        let duration_ms = start.elapsed().as_millis();
1531        event_log.record(PublishEvent {
1532            timestamp: Utc::now(),
1533            event_type: EventType::RehearsalPackagePublished {
1534                name: p.name.clone(),
1535                version: p.version.clone(),
1536                duration_ms,
1537            },
1538            package: pkg_label.clone(),
1539        });
1540        event_log.write_to_file(&events_path)?;
1541        event_log.clear();
1542        packages_published += 1;
1543    }
1544
1545    // #97 PR 4 — smoke-install. Opt-in. Runs only if:
1546    //   (a) all packages in the plan were published successfully AND
1547    //   (b) the operator named a crate via --smoke-install / config.
1548    // The named crate must be in the plan; resolves its planned version
1549    // to pass through to `cargo install`.
1550    if first_failure.is_none()
1551        && let Some(ref smoke_name) = opts.rehearsal_smoke_install
1552    {
1553        match ws.plan.packages.iter().find(|p| &p.name == smoke_name) {
1554            Some(smoke_pkg) => {
1555                reporter.info(&format!(
1556                    "smoke-install: {smoke_name}@{} from '{rehearsal_name}'",
1557                    smoke_pkg.version
1558                ));
1559
1560                event_log.record(PublishEvent {
1561                    timestamp: Utc::now(),
1562                    event_type: EventType::RehearsalSmokeCheckStarted {
1563                        name: smoke_pkg.name.clone(),
1564                        version: smoke_pkg.version.clone(),
1565                        registry: rehearsal_name.clone(),
1566                    },
1567                    package: format!("{smoke_name}@{}", smoke_pkg.version),
1568                });
1569                event_log.write_to_file(&events_path)?;
1570                event_log.clear();
1571
1572                let install_root = state_dir.join("smoke-install");
1573                let _ = std::fs::remove_dir_all(&install_root);
1574                let smoke_start = Instant::now();
1575                let out = cargo::cargo_install_smoke(
1576                    workspace_root,
1577                    &smoke_pkg.name,
1578                    &smoke_pkg.version,
1579                    &rehearsal_reg.name,
1580                    &install_root,
1581                    opts.output_lines,
1582                    None,
1583                )?;
1584
1585                if out.exit_code == 0 {
1586                    let duration_ms = smoke_start.elapsed().as_millis();
1587                    event_log.record(PublishEvent {
1588                        timestamp: Utc::now(),
1589                        event_type: EventType::RehearsalSmokeCheckSucceeded {
1590                            name: smoke_pkg.name.clone(),
1591                            version: smoke_pkg.version.clone(),
1592                            duration_ms,
1593                        },
1594                        package: format!("{smoke_name}@{}", smoke_pkg.version),
1595                    });
1596                    reporter.info(&format!(
1597                        "smoke-install OK for {smoke_name}@{}",
1598                        smoke_pkg.version
1599                    ));
1600                } else {
1601                    let msg = format!(
1602                        "cargo install exited {} for {smoke_name}@{}. stderr tail:\n{}",
1603                        out.exit_code, smoke_pkg.version, out.stderr_tail
1604                    );
1605                    reporter.error(&msg);
1606                    event_log.record(PublishEvent {
1607                        timestamp: Utc::now(),
1608                        event_type: EventType::RehearsalSmokeCheckFailed {
1609                            name: smoke_pkg.name.clone(),
1610                            version: smoke_pkg.version.clone(),
1611                            message: msg.clone(),
1612                        },
1613                        package: format!("{smoke_name}@{}", smoke_pkg.version),
1614                    });
1615                    first_failure = Some(format!(
1616                        "smoke-install of {smoke_name}@{} failed: cargo exit {}",
1617                        smoke_pkg.version, out.exit_code
1618                    ));
1619                }
1620                event_log.write_to_file(&events_path)?;
1621                event_log.clear();
1622            }
1623            None => {
1624                // Operator named a crate that isn't in the plan. Warn,
1625                // don't fail — their intent is clear but the workspace
1626                // shape disagrees, and failing the whole rehearsal over
1627                // a typo would be overkill.
1628                reporter.warn(&format!(
1629                    "smoke-install target '{smoke_name}' is not in the rehearsal plan; skipping. \
1630                     Available crates: {}",
1631                    ws.plan
1632                        .packages
1633                        .iter()
1634                        .map(|p| p.name.as_str())
1635                        .collect::<Vec<_>>()
1636                        .join(", ")
1637                ));
1638            }
1639        }
1640    }
1641
1642    let passed = first_failure.is_none();
1643    let summary = if passed {
1644        format!("rehearsed {packages_published} packages against '{rehearsal_name}' successfully")
1645    } else {
1646        format!(
1647            "rehearsal stopped at {}/{}: {}",
1648            packages_published + 1,
1649            ws.plan.packages.len(),
1650            first_failure.as_deref().unwrap_or("")
1651        )
1652    };
1653
1654    let completed_at = Utc::now();
1655    event_log.record(PublishEvent {
1656        timestamp: completed_at,
1657        event_type: EventType::RehearsalComplete {
1658            passed,
1659            registry: rehearsal_name.clone(),
1660            plan_id: ws.plan.plan_id.clone(),
1661            summary: summary.clone(),
1662        },
1663        package: "all".to_string(),
1664    });
1665    event_log.write_to_file(&events_path)?;
1666
1667    // Persist the sidecar receipt so the hard gate in `run_publish`
1668    // can consult it without parsing events.jsonl. Best-effort — a
1669    // write failure here doesn't invalidate the events log, which is
1670    // the authoritative source; the gate will just act as if no
1671    // rehearsal happened and block, which is the safe default.
1672    let packages_attempted = packages_published + if passed { 0 } else { 1 };
1673    if let Err(err) = crate::state::rehearsal::save_rehearsal(
1674        &state_dir,
1675        &crate::state::rehearsal::RehearsalReceipt {
1676            schema_version: crate::state::rehearsal::CURRENT_REHEARSAL_VERSION.to_string(),
1677            plan_id: ws.plan.plan_id.clone(),
1678            registry: rehearsal_name.clone(),
1679            passed,
1680            packages_attempted,
1681            packages_published,
1682            summary: summary.clone(),
1683            started_at,
1684            completed_at,
1685        },
1686    ) {
1687        reporter.warn(&format!(
1688            "rehearsal outcome event was written, but sidecar receipt could not be persisted: {err:#}. \
1689             The hard gate may not recognize this rehearsal — check {}.",
1690            crate::state::rehearsal::rehearsal_path(&state_dir).display()
1691        ));
1692    }
1693
1694    if passed {
1695        reporter.info(&summary);
1696    } else {
1697        reporter.error(&summary);
1698    }
1699
1700    Ok(RehearsalOutcome {
1701        passed,
1702        registry_name: rehearsal_name,
1703        packages_attempted,
1704        packages_published,
1705        summary,
1706    })
1707}
1708
1709pub(crate) fn init_state(ws: &PlannedWorkspace, state_dir: &Path) -> Result<ExecutionState> {
1710    let mut packages: BTreeMap<String, PackageProgress> = BTreeMap::new();
1711    for p in &ws.plan.packages {
1712        packages.insert(
1713            pkg_key(&p.name, &p.version),
1714            PackageProgress {
1715                name: p.name.clone(),
1716                version: p.version.clone(),
1717                attempts: 0,
1718                state: PackageState::Pending,
1719                last_updated_at: Utc::now(),
1720            },
1721        );
1722    }
1723
1724    let st = ExecutionState {
1725        state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
1726        plan_id: ws.plan.plan_id.clone(),
1727        registry: ws.plan.registry.clone(),
1728        created_at: Utc::now(),
1729        updated_at: Utc::now(),
1730        packages,
1731    };
1732
1733    state::save_state(state_dir, &st)?;
1734    Ok(st)
1735}
1736
1737/// Reconcile an ambiguous publish outcome against registry truth (sequential
1738/// path mirror of `engine::parallel::reconcile::reconcile_ambiguous_upload`).
1739///
1740/// Returns the same [`ReconciliationOutcome`] enum + accumulated
1741/// [`ReadinessEvidence`], wrapping the sequential path's registry client
1742/// (`crate::registry::RegistryClient`) rather than the parallel path's
1743/// `HttpRegistryClient`. Used by the resume-path branch that handles packages
1744/// found in `PackageState::Ambiguous` (#99 follow-on).
1745fn sequential_reconcile(
1746    reg: &RegistryClient,
1747    crate_name: &str,
1748    version: &str,
1749    config: &crate::types::ReadinessConfig,
1750) -> (
1751    crate::types::ReconciliationOutcome,
1752    Vec<crate::types::ReadinessEvidence>,
1753) {
1754    let start = Instant::now();
1755    match reg.is_version_visible_with_backoff(crate_name, version, config) {
1756        Ok((true, evidence)) => (
1757            crate::types::ReconciliationOutcome::Published {
1758                attempts: evidence.len() as u32,
1759                elapsed_ms: start.elapsed().as_millis() as u64,
1760            },
1761            evidence,
1762        ),
1763        Ok((false, evidence)) => (
1764            crate::types::ReconciliationOutcome::NotPublished {
1765                attempts: evidence.len() as u32,
1766                elapsed_ms: start.elapsed().as_millis() as u64,
1767            },
1768            evidence,
1769        ),
1770        Err(e) => (
1771            crate::types::ReconciliationOutcome::StillUnknown {
1772                attempts: 0,
1773                elapsed_ms: start.elapsed().as_millis() as u64,
1774                reason: format!("reconciliation query failed: {e}"),
1775            },
1776            Vec::new(),
1777        ),
1778    }
1779}
1780
1781/// Emit a [`EventType::RetryBackoffStarted`] event + a human-readable warn
1782/// line, then `thread::sleep(delay)`. Used at every retry-backoff site in the
1783/// sequential publish loop so operators never stare at a silent CI log during
1784/// the wait window. See #91. (The parallel path has a mirror helper in
1785/// `engine::parallel::publish::emit_retry_backoff` that handles its
1786/// `Arc<Mutex<_>>` wrapping.)
1787#[allow(clippy::too_many_arguments)]
1788fn emit_retry_backoff_event(
1789    event_log: &mut events::EventLog,
1790    events_path: &Path,
1791    reporter: &mut dyn Reporter,
1792    pkg_label: &str,
1793    pkg_name: &str,
1794    pkg_version: &str,
1795    attempt: u32,
1796    max_attempts: u32,
1797    delay: std::time::Duration,
1798    reason: ErrorClass,
1799    message: &str,
1800) -> Result<()> {
1801    let next_attempt_at =
1802        Utc::now() + chrono::Duration::from_std(delay).unwrap_or_else(|_| chrono::Duration::zero());
1803
1804    event_log.record(PublishEvent {
1805        timestamp: Utc::now(),
1806        event_type: EventType::RetryBackoffStarted {
1807            attempt,
1808            max_attempts,
1809            delay_ms: delay.as_millis() as u64,
1810            next_attempt_at,
1811            reason: reason.clone(),
1812            message: message.to_string(),
1813        },
1814        package: pkg_label.to_string(),
1815    });
1816    event_log.write_to_file(events_path)?;
1817    event_log.clear();
1818
1819    reporter.warn(&format!(
1820        "{}@{}: {} ({:?}); next attempt in {} (attempt {}/{})",
1821        pkg_name,
1822        pkg_version,
1823        message,
1824        reason,
1825        humantime::format_duration(delay),
1826        attempt.saturating_add(1),
1827        max_attempts,
1828    ));
1829
1830    thread::sleep(delay);
1831    Ok(())
1832}
1833
1834fn verify_published(
1835    reg: &RegistryClient,
1836    crate_name: &str,
1837    version: &str,
1838    config: &crate::types::ReadinessConfig,
1839    reporter: &mut dyn Reporter,
1840) -> Result<(bool, Vec<ReadinessEvidence>)> {
1841    reporter.info(&format!(
1842        "{}@{}: readiness check ({:?})...",
1843        crate_name, version, config.method
1844    ));
1845    let (visible, evidence) = reg.is_version_visible_with_backoff(crate_name, version, config)?;
1846    if visible {
1847        reporter.info(&format!(
1848            "{}@{}: visible after {} checks",
1849            crate_name,
1850            version,
1851            evidence.len()
1852        ));
1853    } else {
1854        reporter.warn(&format!(
1855            "{}@{}: not visible after {} checks",
1856            crate_name,
1857            version,
1858            evidence.len()
1859        ));
1860    }
1861    Ok((visible, evidence))
1862}
1863
1864#[cfg(test)]
1865mod tests {
1866    use std::fs;
1867    use std::path::{Path, PathBuf};
1868    use std::sync::{Arc, Mutex};
1869    use std::thread;
1870    use std::time::Duration;
1871
1872    use chrono::Utc;
1873    use serial_test::serial;
1874    use tempfile::tempdir;
1875    use tiny_http::{Header, Response, Server, StatusCode};
1876
1877    use super::*;
1878    use crate::plan::PlannedWorkspace;
1879    use crate::types::{AuthType, PlannedPackage, Registry, ReleasePlan};
1880
1881    #[derive(Default)]
1882    struct CollectingReporter {
1883        infos: Vec<String>,
1884        warns: Vec<String>,
1885        errors: Vec<String>,
1886    }
1887
1888    impl Reporter for CollectingReporter {
1889        fn info(&mut self, msg: &str) {
1890            self.infos.push(msg.to_string());
1891        }
1892
1893        fn warn(&mut self, msg: &str) {
1894            self.warns.push(msg.to_string());
1895        }
1896
1897        fn error(&mut self, msg: &str) {
1898            self.errors.push(msg.to_string());
1899        }
1900    }
1901
1902    #[cfg(windows)]
1903    fn fake_cargo_path(bin_dir: &Path) -> PathBuf {
1904        bin_dir.join("cargo.cmd")
1905    }
1906
1907    #[cfg(not(windows))]
1908    fn fake_cargo_path(bin_dir: &Path) -> PathBuf {
1909        bin_dir.join("cargo")
1910    }
1911
1912    #[cfg(windows)]
1913    fn fake_git_path(bin_dir: &Path) -> PathBuf {
1914        bin_dir.join("git.cmd")
1915    }
1916
1917    #[cfg(not(windows))]
1918    fn fake_git_path(bin_dir: &Path) -> PathBuf {
1919        bin_dir.join("git")
1920    }
1921
1922    fn fake_program_env_vars(bin_dir: &Path) -> Vec<(&'static str, Option<String>)> {
1923        vec![
1924            (
1925                "SHIPPER_CARGO_BIN",
1926                Some(fake_cargo_path(bin_dir).to_str().expect("utf8").to_string()),
1927            ),
1928            (
1929                "SHIPPER_GIT_BIN",
1930                Some(fake_git_path(bin_dir).to_str().expect("utf8").to_string()),
1931            ),
1932        ]
1933    }
1934
1935    /// Build a combined env var list from fake programs + additional vars, then run closure.
1936    fn with_test_env<F, R>(bin_dir: &Path, extra: Vec<(&'static str, Option<String>)>, f: F) -> R
1937    where
1938        F: FnOnce() -> R,
1939    {
1940        let mut vars = fake_program_env_vars(bin_dir);
1941        vars.extend(extra);
1942        temp_env::with_vars(vars, f)
1943    }
1944
1945    fn write_fake_cargo(bin_dir: &Path) {
1946        #[cfg(windows)]
1947        {
1948            fs::write(
1949                bin_dir.join("cargo.cmd"),
1950                "@echo off\r\nif not \"%SHIPPER_CARGO_ARGS_LOG%\"==\"\" echo %*>>\"%SHIPPER_CARGO_ARGS_LOG%\"\r\nif not \"%SHIPPER_CARGO_STDOUT%\"==\"\" echo %SHIPPER_CARGO_STDOUT%\r\nif not \"%SHIPPER_CARGO_STDERR%\"==\"\" echo %SHIPPER_CARGO_STDERR% 1>&2\r\nif \"%SHIPPER_CARGO_EXIT%\"==\"\" (exit /b 0) else (exit /b %SHIPPER_CARGO_EXIT%)\r\n",
1951            )
1952            .expect("write fake cargo");
1953        }
1954
1955        #[cfg(not(windows))]
1956        {
1957            use std::os::unix::fs::PermissionsExt;
1958
1959            let path = bin_dir.join("cargo");
1960            fs::write(
1961                &path,
1962                "#!/usr/bin/env sh\nif [ -n \"$SHIPPER_CARGO_ARGS_LOG\" ]; then\n  echo \"$*\" >>\"$SHIPPER_CARGO_ARGS_LOG\"\nfi\nif [ -n \"$SHIPPER_CARGO_STDOUT\" ]; then\n  echo \"$SHIPPER_CARGO_STDOUT\"\nfi\nif [ -n \"$SHIPPER_CARGO_STDERR\" ]; then\n  echo \"$SHIPPER_CARGO_STDERR\" >&2\nfi\nexit \"${SHIPPER_CARGO_EXIT:-0}\"\n",
1963            )
1964            .expect("write fake cargo");
1965            let mut perms = fs::metadata(&path).expect("meta").permissions();
1966            perms.set_mode(0o755);
1967            fs::set_permissions(&path, perms).expect("chmod");
1968        }
1969    }
1970
1971    fn write_fake_git(bin_dir: &Path) {
1972        #[cfg(windows)]
1973        {
1974            fs::write(
1975                bin_dir.join("git.cmd"),
1976                "@echo off\r\nif \"%SHIPPER_GIT_FAIL%\"==\"1\" (\r\n  echo fatal: git failed 1>&2\r\n  exit /b 1\r\n)\r\nif \"%SHIPPER_GIT_CLEAN%\"==\"0\" (\r\n  echo M src/lib.rs\r\n  exit /b 0\r\n)\r\nexit /b 0\r\n",
1977            )
1978            .expect("write fake git");
1979        }
1980
1981        #[cfg(not(windows))]
1982        {
1983            use std::os::unix::fs::PermissionsExt;
1984
1985            let path = bin_dir.join("git");
1986            fs::write(
1987                &path,
1988                "#!/usr/bin/env sh\nif [ \"${SHIPPER_GIT_FAIL:-0}\" = \"1\" ]; then\n  echo 'fatal: git failed' >&2\n  exit 1\nfi\nif [ \"${SHIPPER_GIT_CLEAN:-1}\" = \"0\" ]; then\n  echo 'M src/lib.rs'\nfi\nexit 0\n",
1989            )
1990            .expect("write fake git");
1991            let mut perms = fs::metadata(&path).expect("meta").permissions();
1992            perms.set_mode(0o755);
1993            fs::set_permissions(&path, perms).expect("chmod");
1994        }
1995    }
1996
1997    fn write_fake_tools(bin_dir: &Path) {
1998        fs::create_dir_all(bin_dir).expect("mkdir");
1999        write_fake_cargo(bin_dir);
2000        write_fake_git(bin_dir);
2001    }
2002
2003    struct TestRegistryServer {
2004        base_url: String,
2005        #[allow(clippy::type_complexity)]
2006        seen: Arc<Mutex<Vec<(String, Option<String>)>>>,
2007        handle: thread::JoinHandle<()>,
2008    }
2009
2010    impl TestRegistryServer {
2011        fn join(self) {
2012            self.handle.join().expect("join server");
2013        }
2014    }
2015
2016    fn spawn_registry_server(
2017        mut routes: std::collections::BTreeMap<String, Vec<(u16, String)>>,
2018        expected_requests: usize,
2019    ) -> TestRegistryServer {
2020        let server = Server::http("127.0.0.1:0").expect("server");
2021        let base_url = format!("http://{}", server.server_addr());
2022        let seen = Arc::new(Mutex::new(Vec::<(String, Option<String>)>::new()));
2023        let seen_thread = Arc::clone(&seen);
2024
2025        let handle = thread::spawn(move || {
2026            for _ in 0..expected_requests {
2027                let req = match server.recv_timeout(Duration::from_secs(30)) {
2028                    Ok(Some(r)) => r,
2029                    _ => break,
2030                };
2031                let path = req.url().to_string();
2032                let auth = req
2033                    .headers()
2034                    .iter()
2035                    .find(|h| h.field.equiv("Authorization"))
2036                    .map(|h| h.value.as_str().to_string());
2037                seen_thread.lock().expect("lock").push((path.clone(), auth));
2038
2039                let response = if let Some(list) = routes.get_mut(&path) {
2040                    if list.is_empty() {
2041                        (404, "{}".to_string())
2042                    } else if list.len() == 1 {
2043                        list[0].clone()
2044                    } else {
2045                        list.remove(0)
2046                    }
2047                } else {
2048                    (404, "{}".to_string())
2049                };
2050
2051                let resp = Response::from_string(response.1)
2052                    .with_status_code(StatusCode(response.0))
2053                    .with_header(
2054                        Header::from_bytes("Content-Type", "application/json").expect("header"),
2055                    );
2056                req.respond(resp).expect("respond");
2057            }
2058        });
2059
2060        TestRegistryServer {
2061            base_url,
2062            seen,
2063            handle,
2064        }
2065    }
2066
2067    fn planned_workspace(workspace_root: &Path, api_base: String) -> PlannedWorkspace {
2068        PlannedWorkspace {
2069            workspace_root: workspace_root.to_path_buf(),
2070            plan: ReleasePlan {
2071                plan_version: "1".to_string(),
2072                plan_id: "plan-demo".to_string(),
2073                created_at: Utc::now(),
2074                registry: Registry {
2075                    name: "crates-io".to_string(),
2076                    api_base,
2077                    index_base: None,
2078                },
2079                packages: vec![PlannedPackage {
2080                    name: "demo".to_string(),
2081                    version: "0.1.0".to_string(),
2082                    manifest_path: workspace_root.join("demo").join("Cargo.toml"),
2083                }],
2084                dependencies: std::collections::BTreeMap::new(),
2085            },
2086            skipped: vec![],
2087        }
2088    }
2089
2090    fn default_opts(state_dir: PathBuf) -> RuntimeOptions {
2091        RuntimeOptions {
2092            allow_dirty: true,
2093            skip_ownership_check: true,
2094            strict_ownership: false,
2095            no_verify: false,
2096            max_attempts: 2,
2097            base_delay: Duration::from_millis(1),
2098            max_delay: Duration::from_millis(2),
2099            verify_timeout: Duration::from_millis(20),
2100            verify_poll_interval: Duration::from_millis(1),
2101            state_dir,
2102            force_resume: false,
2103            policy: crate::types::PublishPolicy::default(),
2104            verify_mode: crate::types::VerifyMode::default(),
2105            readiness: crate::types::ReadinessConfig {
2106                enabled: true,
2107                method: crate::types::ReadinessMethod::Api,
2108                initial_delay: Duration::from_millis(0),
2109                max_delay: Duration::from_millis(20),
2110                max_total_wait: Duration::from_millis(200),
2111                poll_interval: Duration::from_millis(1),
2112                jitter_factor: 0.0,
2113                index_path: None,
2114                prefer_index: false,
2115            },
2116            output_lines: 100,
2117            force: false,
2118            lock_timeout: Duration::from_secs(3600),
2119            parallel: crate::types::ParallelConfig::default(),
2120            webhook: crate::webhook::WebhookConfig::default(),
2121            retry_strategy: crate::retry::RetryStrategyType::Exponential,
2122            retry_jitter: 0.0,
2123            retry_per_error: crate::retry::PerErrorConfig::default(),
2124            encryption: crate::encryption::EncryptionConfig::default(),
2125            registries: vec![],
2126            resume_from: None,
2127            rehearsal_registry: None,
2128            rehearsal_skip: false,
2129            rehearsal_smoke_install: None,
2130        }
2131    }
2132
2133    #[test]
2134    fn classify_cargo_failure_covers_retryable_permanent_and_ambiguous() {
2135        let retryable = classify_cargo_failure("HTTP 429 too many requests", "");
2136        assert_eq!(retryable.0, ErrorClass::Retryable);
2137
2138        let permanent = classify_cargo_failure("permission denied", "");
2139        assert_eq!(permanent.0, ErrorClass::Permanent);
2140
2141        let ambiguous = classify_cargo_failure("strange output", "");
2142        assert_eq!(ambiguous.0, ErrorClass::Ambiguous);
2143    }
2144
2145    #[test]
2146    fn collecting_reporter_error_method_records_message() {
2147        let mut reporter = CollectingReporter::default();
2148        reporter.error("boom");
2149        assert_eq!(reporter.errors, vec!["boom".to_string()]);
2150    }
2151
2152    #[test]
2153    fn helper_functions_return_expected_values() {
2154        let root = PathBuf::from("root");
2155        let rel = resolve_state_dir(&root, &PathBuf::from(".shipper"));
2156        assert_eq!(rel, root.join(".shipper"));
2157
2158        #[cfg(windows)]
2159        {
2160            let abs = PathBuf::from(r"C:\x\state");
2161            assert_eq!(resolve_state_dir(&root, &abs), abs);
2162        }
2163        #[cfg(not(windows))]
2164        {
2165            let abs = PathBuf::from("/x/state");
2166            assert_eq!(resolve_state_dir(&root, &abs), abs);
2167        }
2168
2169        assert_eq!(pkg_key("a", "1.2.3"), "a@1.2.3");
2170        assert_eq!(short_state(&PackageState::Pending), "pending");
2171        assert_eq!(short_state(&PackageState::Uploaded), "uploaded");
2172        assert_eq!(short_state(&PackageState::Published), "published");
2173        assert_eq!(
2174            short_state(&PackageState::Skipped {
2175                reason: "r".to_string()
2176            }),
2177            "skipped"
2178        );
2179        assert_eq!(
2180            short_state(&PackageState::Failed {
2181                class: ErrorClass::Permanent,
2182                message: "m".to_string()
2183            }),
2184            "failed"
2185        );
2186        assert_eq!(
2187            short_state(&PackageState::Ambiguous {
2188                message: "m".to_string()
2189            }),
2190            "ambiguous"
2191        );
2192    }
2193
2194    #[test]
2195    fn backoff_delay_is_bounded_with_jitter() {
2196        let base = Duration::from_millis(100);
2197        let max = Duration::from_millis(500);
2198        let d1 = backoff_delay(
2199            base,
2200            max,
2201            1,
2202            crate::retry::RetryStrategyType::Exponential,
2203            0.5,
2204        );
2205        let d20 = backoff_delay(
2206            base,
2207            max,
2208            20,
2209            crate::retry::RetryStrategyType::Exponential,
2210            0.5,
2211        );
2212
2213        assert!(d1 >= Duration::from_millis(50));
2214        assert!(d1 <= Duration::from_millis(150));
2215
2216        assert!(d20 >= Duration::from_millis(250));
2217        assert!(d20 <= Duration::from_millis(750));
2218    }
2219
2220    #[test]
2221    fn verify_published_returns_true_when_registry_visibility_appears() {
2222        let server = spawn_registry_server(
2223            std::collections::BTreeMap::from([(
2224                "/api/v1/crates/demo/0.1.0".to_string(),
2225                vec![(404, "{}".to_string()), (200, "{}".to_string())],
2226            )]),
2227            2,
2228        );
2229
2230        let reg = RegistryClient::new(Registry {
2231            name: "crates-io".to_string(),
2232            api_base: server.base_url.clone(),
2233            index_base: None,
2234        })
2235        .expect("client");
2236
2237        let config = crate::types::ReadinessConfig {
2238            enabled: true,
2239            method: crate::types::ReadinessMethod::Api,
2240            initial_delay: Duration::from_millis(0),
2241            max_delay: Duration::from_millis(50),
2242            // Keep this generous to avoid timing flakes under highly parallel test execution.
2243            max_total_wait: Duration::from_secs(2),
2244            poll_interval: Duration::from_millis(1),
2245            jitter_factor: 0.0,
2246            index_path: None,
2247            prefer_index: false,
2248        };
2249
2250        let mut reporter = CollectingReporter::default();
2251        let (ok, evidence) =
2252            verify_published(&reg, "demo", "0.1.0", &config, &mut reporter).expect("verify");
2253        assert!(ok);
2254        assert!(!reporter.infos.is_empty());
2255        assert!(!evidence.is_empty());
2256        server.join();
2257    }
2258
2259    #[test]
2260    fn verify_published_returns_false_on_timeout() {
2261        let reg = RegistryClient::new(Registry {
2262            name: "crates-io".to_string(),
2263            api_base: "http://127.0.0.1:9".to_string(),
2264            index_base: None,
2265        })
2266        .expect("client");
2267
2268        let config = crate::types::ReadinessConfig {
2269            enabled: true,
2270            method: crate::types::ReadinessMethod::Api,
2271            initial_delay: Duration::from_millis(0),
2272            max_delay: Duration::from_millis(10),
2273            max_total_wait: Duration::from_millis(0),
2274            poll_interval: Duration::from_millis(1),
2275            jitter_factor: 0.0,
2276            index_path: None,
2277            prefer_index: false,
2278        };
2279
2280        let mut reporter = CollectingReporter::default();
2281        let (ok, _evidence) =
2282            verify_published(&reg, "demo", "0.1.0", &config, &mut reporter).expect("verify");
2283        assert!(!ok);
2284    }
2285
2286    #[test]
2287    fn registry_server_helper_returns_404_for_unknown_or_empty_routes() {
2288        let server_unknown = spawn_registry_server(std::collections::BTreeMap::new(), 1);
2289        let reg_unknown = RegistryClient::new(Registry {
2290            name: "crates-io".to_string(),
2291            api_base: server_unknown.base_url.clone(),
2292            index_base: None,
2293        })
2294        .expect("client");
2295        let exists_unknown = reg_unknown
2296            .version_exists("demo", "0.1.0")
2297            .expect("version exists");
2298        assert!(!exists_unknown);
2299        server_unknown.join();
2300
2301        let server_empty = spawn_registry_server(
2302            std::collections::BTreeMap::from([("/api/v1/crates/demo/0.1.0".to_string(), vec![])]),
2303            1,
2304        );
2305        let reg_empty = RegistryClient::new(Registry {
2306            name: "crates-io".to_string(),
2307            api_base: server_empty.base_url.clone(),
2308            index_base: None,
2309        })
2310        .expect("client");
2311        let exists_empty = reg_empty
2312            .version_exists("demo", "0.1.0")
2313            .expect("version exists");
2314        assert!(!exists_empty);
2315        server_empty.join();
2316    }
2317
2318    #[test]
2319    #[serial]
2320    fn run_preflight_errors_in_strict_mode_without_token() {
2321        let td = tempdir().expect("tempdir");
2322        let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
2323        let mut opts = default_opts(PathBuf::from(".shipper"));
2324        opts.strict_ownership = true;
2325        opts.skip_ownership_check = false;
2326        temp_env::with_vars(
2327            [
2328                (
2329                    "CARGO_HOME",
2330                    Some(td.path().to_str().expect("utf8").to_string()),
2331                ),
2332                ("CARGO_REGISTRY_TOKEN", None::<String>),
2333                ("CARGO_REGISTRIES_CRATES_IO_TOKEN", None::<String>),
2334            ],
2335            || {
2336                let mut reporter = CollectingReporter::default();
2337                let err = run_preflight(&ws, &opts, &mut reporter).expect_err("must fail");
2338                assert!(
2339                    format!("{err:#}").contains("strict ownership requested but no token found")
2340                );
2341            },
2342        );
2343    }
2344
2345    #[test]
2346    #[serial]
2347    fn run_preflight_warns_on_owners_failure_when_not_strict() {
2348        let td = tempdir().expect("tempdir");
2349        let bin = td.path().join("bin");
2350        write_fake_tools(&bin);
2351        let mut env_vars = fake_program_env_vars(&bin);
2352        env_vars.extend([
2353            ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
2354            (
2355                "CARGO_HOME",
2356                Some(td.path().to_str().expect("utf8").to_string()),
2357            ),
2358            ("CARGO_REGISTRY_TOKEN", Some("token-abc".to_string())),
2359        ]);
2360        temp_env::with_vars(env_vars, || {
2361            let server = spawn_registry_server(
2362                std::collections::BTreeMap::from([
2363                    (
2364                        "/api/v1/crates/demo/0.1.0".to_string(),
2365                        vec![(404, "{}".to_string())],
2366                    ),
2367                    (
2368                        "/api/v1/crates/demo".to_string(),
2369                        vec![(404, "{}".to_string())],
2370                    ),
2371                    (
2372                        "/api/v1/crates/demo/owners".to_string(),
2373                        vec![(403, "{}".to_string())],
2374                    ),
2375                ]),
2376                3,
2377            );
2378
2379            let ws = planned_workspace(td.path(), server.base_url.clone());
2380            let mut opts = default_opts(PathBuf::from(".shipper"));
2381            opts.skip_ownership_check = false;
2382            opts.strict_ownership = false;
2383
2384            let mut reporter = CollectingReporter::default();
2385            let rep = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
2386            assert!(rep.token_detected);
2387            assert_eq!(rep.packages.len(), 1);
2388            assert!(!rep.packages[0].already_published);
2389            assert!(!rep.packages[0].ownership_verified);
2390            assert!(rep.packages[0].dry_run_passed);
2391            assert_eq!(rep.finishability, Finishability::NotProven);
2392            assert!(
2393                reporter
2394                    .warns
2395                    .iter()
2396                    .any(|w| w.contains("owners preflight failed"))
2397            );
2398
2399            let seen = server.seen.lock().expect("lock");
2400            assert_eq!(seen.len(), 3);
2401            drop(seen);
2402            server.join();
2403        });
2404    }
2405
2406    #[test]
2407    #[serial]
2408    fn run_preflight_owners_success_path() {
2409        let td = tempdir().expect("tempdir");
2410        let bin = td.path().join("bin");
2411        write_fake_tools(&bin);
2412        let mut env_vars = fake_program_env_vars(&bin);
2413        env_vars.extend([
2414            ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
2415            (
2416                "CARGO_HOME",
2417                Some(td.path().to_str().expect("utf8").to_string()),
2418            ),
2419            ("CARGO_REGISTRY_TOKEN", Some("token-abc".to_string())),
2420        ]);
2421        temp_env::with_vars(env_vars, || {
2422            let server = spawn_registry_server(
2423                std::collections::BTreeMap::from([
2424                    (
2425                        "/api/v1/crates/demo/0.1.0".to_string(),
2426                        vec![(404, "{}".to_string())],
2427                    ),
2428                    (
2429                        "/api/v1/crates/demo".to_string(),
2430                        vec![(200, "{}".to_string())],
2431                    ),
2432                    (
2433                        "/api/v1/crates/demo/owners".to_string(),
2434                        vec![(
2435                            200,
2436                            r#"{"users":[{"id":1,"login":"alice","name":"Alice"}]}"#.to_string(),
2437                        )],
2438                    ),
2439                ]),
2440                3,
2441            );
2442
2443            let ws = planned_workspace(td.path(), server.base_url.clone());
2444            let mut opts = default_opts(PathBuf::from(".shipper"));
2445            opts.skip_ownership_check = false;
2446            opts.strict_ownership = false;
2447
2448            let mut reporter = CollectingReporter::default();
2449            let rep = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
2450            assert_eq!(rep.packages.len(), 1);
2451            assert!(reporter.warns.is_empty());
2452            server.join();
2453        });
2454    }
2455
2456    #[test]
2457    #[serial]
2458    fn run_preflight_returns_error_when_strict_ownership_check_fails() {
2459        let td = tempdir().expect("tempdir");
2460        let bin = td.path().join("bin");
2461        write_fake_tools(&bin);
2462        let mut env_vars = fake_program_env_vars(&bin);
2463        env_vars.extend([
2464            ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
2465            (
2466                "CARGO_HOME",
2467                Some(td.path().to_str().expect("utf8").to_string()),
2468            ),
2469            ("CARGO_REGISTRY_TOKEN", Some("token-abc".to_string())),
2470        ]);
2471        temp_env::with_vars(env_vars, || {
2472            // Crate must exist (200) so ownership check is actually attempted;
2473            // 404 would mean new crate -> ownership check skipped.
2474            let server = spawn_registry_server(
2475                std::collections::BTreeMap::from([
2476                    (
2477                        "/api/v1/crates/demo/0.1.0".to_string(),
2478                        vec![(404, "{}".to_string())],
2479                    ),
2480                    (
2481                        "/api/v1/crates/demo".to_string(),
2482                        vec![(200, "{}".to_string())],
2483                    ),
2484                    (
2485                        "/api/v1/crates/demo/owners".to_string(),
2486                        vec![(403, "{}".to_string())],
2487                    ),
2488                ]),
2489                3,
2490            );
2491
2492            let ws = planned_workspace(td.path(), server.base_url.clone());
2493            let mut opts = default_opts(PathBuf::from(".shipper"));
2494            opts.skip_ownership_check = false;
2495            opts.strict_ownership = true;
2496
2497            let mut reporter = CollectingReporter::default();
2498            let err = run_preflight(&ws, &opts, &mut reporter).expect_err("must fail");
2499            assert!(format!("{err:#}").contains("forbidden when querying owners"));
2500            server.join();
2501        });
2502    }
2503
2504    #[test]
2505    #[serial]
2506    fn run_preflight_strict_skips_ownership_for_new_crate() {
2507        let td = tempdir().expect("tempdir");
2508        let bin = td.path().join("bin");
2509        write_fake_tools(&bin);
2510        let mut env_vars = fake_program_env_vars(&bin);
2511        env_vars.extend([
2512            ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
2513            (
2514                "CARGO_HOME",
2515                Some(td.path().to_str().expect("utf8").to_string()),
2516            ),
2517            ("CARGO_REGISTRY_TOKEN", Some("token-abc".to_string())),
2518        ]);
2519        temp_env::with_vars(env_vars, || {
2520            // Crate returns 404 (new crate) -- ownership check should be skipped.
2521            // No /owners endpoint needed.
2522            let server = spawn_registry_server(
2523                std::collections::BTreeMap::from([
2524                    (
2525                        "/api/v1/crates/demo/0.1.0".to_string(),
2526                        vec![(404, "{}".to_string())],
2527                    ),
2528                    (
2529                        "/api/v1/crates/demo".to_string(),
2530                        vec![(404, "{}".to_string())],
2531                    ),
2532                ]),
2533                2,
2534            );
2535
2536            let ws = planned_workspace(td.path(), server.base_url.clone());
2537            let mut opts = default_opts(PathBuf::from(".shipper"));
2538            opts.skip_ownership_check = false;
2539            opts.strict_ownership = true;
2540
2541            let mut reporter = CollectingReporter::default();
2542            let rep = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
2543            assert_eq!(rep.packages.len(), 1);
2544            assert!(!rep.packages[0].ownership_verified);
2545            assert!(rep.packages[0].is_new_crate);
2546            assert!(
2547                reporter
2548                    .infos
2549                    .iter()
2550                    .any(|i| i.contains("new crate, skipping ownership check"))
2551            );
2552            server.join();
2553        });
2554    }
2555
2556    #[test]
2557    #[serial]
2558    fn run_preflight_writes_preflight_events() {
2559        let td = tempdir().expect("tempdir");
2560        let bin = td.path().join("bin");
2561        write_fake_tools(&bin);
2562        let mut env_vars = fake_program_env_vars(&bin);
2563        env_vars.extend([("SHIPPER_CARGO_EXIT", Some("0".to_string()))]);
2564        temp_env::with_vars(env_vars, || {
2565            let server = spawn_registry_server(
2566                std::collections::BTreeMap::from([
2567                    (
2568                        "/api/v1/crates/demo/0.1.0".to_string(),
2569                        vec![(404, "{}".to_string())],
2570                    ),
2571                    (
2572                        "/api/v1/crates/demo".to_string(),
2573                        vec![(404, "{}".to_string())],
2574                    ),
2575                ]),
2576                2,
2577            );
2578
2579            let ws = planned_workspace(td.path(), server.base_url.clone());
2580            let mut opts = default_opts(PathBuf::from(".shipper"));
2581            opts.allow_dirty = true;
2582            opts.skip_ownership_check = true;
2583
2584            let mut reporter = CollectingReporter::default();
2585            let _ = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
2586
2587            let events_path = td.path().join(".shipper").join("events.jsonl");
2588            let log =
2589                crate::state::events::EventLog::read_from_file(&events_path).expect("read events");
2590            let events = log.all_events();
2591
2592            assert!(
2593                events
2594                    .iter()
2595                    .any(|e| matches!(e.event_type, EventType::PreflightStarted))
2596            );
2597            assert!(
2598                events
2599                    .iter()
2600                    .any(|e| matches!(e.event_type, EventType::PreflightWorkspaceVerify { .. }))
2601            );
2602            assert!(
2603                events.iter().any(|e| {
2604                    matches!(e.event_type, EventType::PreflightNewCrateDetected { .. })
2605                })
2606            );
2607            assert!(
2608                events
2609                    .iter()
2610                    .any(|e| matches!(e.event_type, EventType::PreflightOwnershipCheck { .. }))
2611            );
2612            assert!(
2613                events
2614                    .iter()
2615                    .any(|e| matches!(e.event_type, EventType::PreflightComplete { .. }))
2616            );
2617            server.join();
2618        });
2619    }
2620
2621    #[test]
2622    #[serial]
2623    fn run_preflight_detects_trusted_publishing_auth_type() {
2624        let td = tempdir().expect("tempdir");
2625        let bin = td.path().join("bin");
2626        write_fake_tools(&bin);
2627        let mut env_vars = fake_program_env_vars(&bin);
2628        env_vars.extend([
2629            ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
2630            (
2631                "CARGO_HOME",
2632                Some(td.path().to_str().expect("utf8").to_string()),
2633            ),
2634            ("CARGO_REGISTRY_TOKEN", None::<String>),
2635            ("CARGO_REGISTRIES_CRATES_IO_TOKEN", None::<String>),
2636            (
2637                "ACTIONS_ID_TOKEN_REQUEST_URL",
2638                Some("https://example.invalid/oidc".to_string()),
2639            ),
2640            (
2641                "ACTIONS_ID_TOKEN_REQUEST_TOKEN",
2642                Some("oidc-token".to_string()),
2643            ),
2644        ]);
2645        temp_env::with_vars(env_vars, || {
2646            let server = spawn_registry_server(
2647                std::collections::BTreeMap::from([
2648                    (
2649                        "/api/v1/crates/demo/0.1.0".to_string(),
2650                        vec![(404, "{}".to_string())],
2651                    ),
2652                    (
2653                        "/api/v1/crates/demo".to_string(),
2654                        vec![(404, "{}".to_string())],
2655                    ),
2656                ]),
2657                2,
2658            );
2659
2660            let ws = planned_workspace(td.path(), server.base_url.clone());
2661            let mut opts = default_opts(PathBuf::from(".shipper"));
2662            opts.allow_dirty = true;
2663            opts.skip_ownership_check = true;
2664
2665            let mut reporter = CollectingReporter::default();
2666            let report = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
2667
2668            assert!(!report.token_detected);
2669            assert_eq!(
2670                report.packages[0].auth_type,
2671                Some(crate::types::AuthType::TrustedPublishing)
2672            );
2673            server.join();
2674        });
2675    }
2676
2677    #[test]
2678    #[serial]
2679    fn run_preflight_checks_git_when_allow_dirty_is_false() {
2680        let td = tempdir().expect("tempdir");
2681        let bin = td.path().join("bin");
2682        write_fake_tools(&bin);
2683        let mut env_vars = fake_program_env_vars(&bin);
2684        env_vars.extend([("SHIPPER_GIT_CLEAN", Some("1".to_string()))]);
2685        temp_env::with_vars(env_vars, || {
2686            let server = spawn_registry_server(
2687                std::collections::BTreeMap::from([
2688                    (
2689                        "/api/v1/crates/demo/0.1.0".to_string(),
2690                        vec![(404, "{}".to_string())],
2691                    ),
2692                    (
2693                        "/api/v1/crates/demo".to_string(),
2694                        vec![(404, "{}".to_string())],
2695                    ),
2696                ]),
2697                2,
2698            );
2699
2700            let ws = planned_workspace(td.path(), server.base_url.clone());
2701            let mut opts = default_opts(PathBuf::from(".shipper"));
2702            opts.allow_dirty = false;
2703            opts.skip_ownership_check = true;
2704
2705            let mut reporter = CollectingReporter::default();
2706            let rep = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
2707            assert_eq!(rep.packages.len(), 1);
2708            server.join();
2709        });
2710    }
2711
2712    #[test]
2713    #[serial]
2714    fn run_publish_skips_when_version_already_exists() {
2715        let td = tempdir().expect("tempdir");
2716        let bin = td.path().join("bin");
2717        write_fake_tools(&bin);
2718        let env_vars = fake_program_env_vars(&bin);
2719        temp_env::with_vars(env_vars, || {
2720            let server = spawn_registry_server(
2721                std::collections::BTreeMap::from([(
2722                    "/api/v1/crates/demo/0.1.0".to_string(),
2723                    vec![(200, "{}".to_string())],
2724                )]),
2725                1,
2726            );
2727            let ws = planned_workspace(td.path(), server.base_url.clone());
2728            let opts = default_opts(PathBuf::from(".shipper"));
2729
2730            let mut reporter = CollectingReporter::default();
2731            let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
2732            assert_eq!(receipt.packages.len(), 1);
2733            assert!(matches!(
2734                receipt.packages[0].state,
2735                PackageState::Skipped { .. }
2736            ));
2737
2738            let state_dir = td.path().join(".shipper");
2739            assert!(state::state_path(&state_dir).exists());
2740            assert!(state::receipt_path(&state_dir).exists());
2741            server.join();
2742        });
2743    }
2744
2745    /// Regression for #125: resume encountering a `Published` state in
2746    /// state.json must emit a `PackageSkipped` event, not silently move
2747    /// on. events.jsonl is the authoritative audit log; a silent skip
2748    /// makes "did resume look at this package at all?" unanswerable from
2749    /// the log alone.
2750    #[test]
2751    #[serial]
2752    fn resume_emits_package_skipped_event_for_already_published_state() {
2753        let td = tempdir().expect("tempdir");
2754        let bin = td.path().join("bin");
2755        write_fake_tools(&bin);
2756        let env_vars = fake_program_env_vars(&bin);
2757        temp_env::with_vars(env_vars, || {
2758            let server = spawn_registry_server(
2759                std::collections::BTreeMap::from([(
2760                    "/api/v1/crates/demo/0.1.0".to_string(),
2761                    vec![(404, "{}".to_string())],
2762                )]),
2763                1,
2764            );
2765            let ws = planned_workspace(td.path(), server.base_url.clone());
2766            let state_dir = td.path().join(".shipper");
2767
2768            // Seed: existing state says demo@0.1.0 is Published. Resume
2769            // should recognize it and skip — but crucially, emit the event.
2770            let mut packages = BTreeMap::new();
2771            packages.insert(
2772                "demo@0.1.0".to_string(),
2773                PackageProgress {
2774                    name: "demo".to_string(),
2775                    version: "0.1.0".to_string(),
2776                    attempts: 1,
2777                    state: PackageState::Published,
2778                    last_updated_at: Utc::now(),
2779                },
2780            );
2781            let seeded = ExecutionState {
2782                state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
2783                plan_id: ws.plan.plan_id.clone(),
2784                registry: ws.plan.registry.clone(),
2785                created_at: Utc::now(),
2786                updated_at: Utc::now(),
2787                packages,
2788            };
2789            state::save_state(&state_dir, &seeded).expect("seed state");
2790
2791            let opts = default_opts(PathBuf::from(".shipper"));
2792            let mut reporter = CollectingReporter::default();
2793            let _receipt = run_publish(&ws, &opts, &mut reporter).expect("publish resumes");
2794
2795            // Read events.jsonl and assert a PackageSkipped event exists.
2796            let events_path = events::events_path(&state_dir);
2797            let raw = std::fs::read_to_string(&events_path).expect("events.jsonl");
2798            let skipped_count = raw
2799                .lines()
2800                .filter(|l| !l.trim().is_empty())
2801                .filter(|l| l.contains(r#""type":"package_skipped""#))
2802                .count();
2803            assert!(
2804                skipped_count >= 1,
2805                "expected at least one PackageSkipped event for the already-Published package; \
2806                 events.jsonl was:\n{raw}"
2807            );
2808        });
2809    }
2810
2811    /// Regression for #126: when resume encounters a `Failed` state and
2812    /// the registry confirms the version is visible, `state.json` must
2813    /// transition that package from Failed to Skipped. A stale `failed`
2814    /// flag misleads downstream tools (e.g. plan-yank) into thinking
2815    /// remediation is needed when it isn't.
2816    #[test]
2817    #[serial]
2818    fn resume_from_failed_ambiguous_updates_state_to_skipped_when_registry_visible() {
2819        let td = tempdir().expect("tempdir");
2820        let bin = td.path().join("bin");
2821        write_fake_tools(&bin);
2822        let env_vars = fake_program_env_vars(&bin);
2823        temp_env::with_vars(env_vars, || {
2824            // Registry returns 200 for the version check — the crate IS
2825            // visible, even though run 1 left us with Failed/Ambiguous.
2826            let server = spawn_registry_server(
2827                std::collections::BTreeMap::from([(
2828                    "/api/v1/crates/demo/0.1.0".to_string(),
2829                    vec![(200, "{}".to_string())],
2830                )]),
2831                1,
2832            );
2833            let ws = planned_workspace(td.path(), server.base_url.clone());
2834            let state_dir = td.path().join(".shipper");
2835
2836            let mut packages = BTreeMap::new();
2837            packages.insert(
2838                "demo@0.1.0".to_string(),
2839                PackageProgress {
2840                    name: "demo".to_string(),
2841                    version: "0.1.0".to_string(),
2842                    attempts: 1,
2843                    state: PackageState::Failed {
2844                        class: ErrorClass::Ambiguous,
2845                        message: "prior run: publish outcome ambiguous".to_string(),
2846                    },
2847                    last_updated_at: Utc::now(),
2848                },
2849            );
2850            let seeded = ExecutionState {
2851                state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
2852                plan_id: ws.plan.plan_id.clone(),
2853                registry: ws.plan.registry.clone(),
2854                created_at: Utc::now(),
2855                updated_at: Utc::now(),
2856                packages,
2857            };
2858            state::save_state(&state_dir, &seeded).expect("seed state");
2859
2860            let opts = default_opts(PathBuf::from(".shipper"));
2861            let mut reporter = CollectingReporter::default();
2862            let _receipt = run_publish(&ws, &opts, &mut reporter).expect("publish resumes");
2863
2864            // State.json on disk must now say Skipped, not Failed.
2865            let reloaded = state::load_state(&state_dir)
2866                .expect("load")
2867                .expect("state exists");
2868            let pkg_state = &reloaded
2869                .packages
2870                .get("demo@0.1.0")
2871                .expect("package in state")
2872                .state;
2873            assert!(
2874                matches!(pkg_state, PackageState::Skipped { .. }),
2875                "expected state.json to show Skipped after resume reconciled against registry; got {pkg_state:?}"
2876            );
2877            server.join();
2878        });
2879    }
2880
2881    #[test]
2882    #[serial]
2883    fn run_publish_checks_git_when_allow_dirty_is_false() {
2884        let td = tempdir().expect("tempdir");
2885        let bin = td.path().join("bin");
2886        write_fake_tools(&bin);
2887        let mut env_vars = fake_program_env_vars(&bin);
2888        env_vars.extend([("SHIPPER_GIT_CLEAN", Some("1".to_string()))]);
2889        temp_env::with_vars(env_vars, || {
2890            let server = spawn_registry_server(
2891                std::collections::BTreeMap::from([(
2892                    "/api/v1/crates/demo/0.1.0".to_string(),
2893                    vec![(200, "{}".to_string())],
2894                )]),
2895                1,
2896            );
2897            let ws = planned_workspace(td.path(), server.base_url.clone());
2898            let mut opts = default_opts(PathBuf::from(".shipper"));
2899            opts.allow_dirty = false;
2900
2901            let mut reporter = CollectingReporter::default();
2902            let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
2903            assert_eq!(receipt.packages.len(), 1);
2904            server.join();
2905        });
2906    }
2907
2908    #[test]
2909    #[serial]
2910    fn run_publish_adds_missing_package_entries_to_existing_state() {
2911        let td = tempdir().expect("tempdir");
2912        let bin = td.path().join("bin");
2913        write_fake_tools(&bin);
2914        let env_vars = fake_program_env_vars(&bin);
2915        temp_env::with_vars(env_vars, || {
2916            let server = spawn_registry_server(
2917                std::collections::BTreeMap::from([(
2918                    "/api/v1/crates/demo/0.1.0".to_string(),
2919                    vec![(200, "{}".to_string())],
2920                )]),
2921                1,
2922            );
2923
2924            let ws = planned_workspace(td.path(), server.base_url.clone());
2925            let state_dir = td.path().join(".shipper");
2926            let existing = ExecutionState {
2927                state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
2928                plan_id: ws.plan.plan_id.clone(),
2929                registry: ws.plan.registry.clone(),
2930                created_at: Utc::now(),
2931                updated_at: Utc::now(),
2932                packages: BTreeMap::new(),
2933            };
2934            state::save_state(&state_dir, &existing).expect("save");
2935
2936            let opts = default_opts(PathBuf::from(".shipper"));
2937            let mut reporter = CollectingReporter::default();
2938            let _ = run_publish(&ws, &opts, &mut reporter).expect("publish");
2939
2940            let st = state::load_state(&state_dir)
2941                .expect("load")
2942                .expect("exists");
2943            assert!(st.packages.contains_key("demo@0.1.0"));
2944            server.join();
2945        });
2946    }
2947
2948    #[test]
2949    #[serial]
2950    fn run_publish_marks_published_after_successful_verify() {
2951        let td = tempdir().expect("tempdir");
2952        let bin = td.path().join("bin");
2953        write_fake_tools(&bin);
2954        let mut env_vars = fake_program_env_vars(&bin);
2955        env_vars.extend([("SHIPPER_CARGO_EXIT", Some("0".to_string()))]);
2956        temp_env::with_vars(env_vars, || {
2957            let server = spawn_registry_server(
2958                std::collections::BTreeMap::from([(
2959                    "/api/v1/crates/demo/0.1.0".to_string(),
2960                    vec![(404, "{}".to_string()), (200, "{}".to_string())],
2961                )]),
2962                2,
2963            );
2964            let ws = planned_workspace(td.path(), server.base_url.clone());
2965            let mut opts = default_opts(PathBuf::from(".shipper"));
2966            opts.verify_timeout = Duration::from_millis(200);
2967            opts.verify_poll_interval = Duration::from_millis(1);
2968
2969            let mut reporter = CollectingReporter::default();
2970            let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
2971            assert!(matches!(receipt.packages[0].state, PackageState::Published));
2972            server.join();
2973        });
2974    }
2975
2976    #[test]
2977    #[serial]
2978    fn run_publish_treats_500_as_not_visible_during_readiness() {
2979        // With the readiness-driven verify, 500 errors are treated as "not visible"
2980        // (graceful degradation). The publish succeeds via cargo but readiness times out,
2981        // leading to an ambiguous failure on the final registry check.
2982        let td = tempdir().expect("tempdir");
2983        let bin = td.path().join("bin");
2984        write_fake_tools(&bin);
2985        let mut env_vars = fake_program_env_vars(&bin);
2986        env_vars.extend([("SHIPPER_CARGO_EXIT", Some("0".to_string()))]);
2987        temp_env::with_vars(env_vars, || {
2988            let server = spawn_registry_server(
2989                std::collections::BTreeMap::from([(
2990                    "/api/v1/crates/demo/0.1.0".to_string(),
2991                    vec![
2992                        (404, "{}".to_string()),
2993                        (500, "{}".to_string()),
2994                        (404, "{}".to_string()),
2995                    ],
2996                )]),
2997                3,
2998            );
2999            let ws = planned_workspace(td.path(), server.base_url.clone());
3000            let mut opts = default_opts(PathBuf::from(".shipper"));
3001            opts.max_attempts = 1;
3002            opts.readiness.max_total_wait = Duration::from_millis(0);
3003
3004            let mut reporter = CollectingReporter::default();
3005            let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
3006            assert!(format!("{err:#}").contains("failed"));
3007            server.join();
3008        });
3009    }
3010
3011    #[test]
3012    #[serial]
3013    fn run_publish_treats_failed_cargo_as_published_if_registry_shows_version() {
3014        let td = tempdir().expect("tempdir");
3015        let bin = td.path().join("bin");
3016        write_fake_tools(&bin);
3017        with_test_env(
3018            &bin,
3019            vec![
3020                ("SHIPPER_CARGO_EXIT", Some("1".to_string())),
3021                (
3022                    "SHIPPER_CARGO_STDERR",
3023                    Some("timeout while uploading".to_string()),
3024                ),
3025            ],
3026            || {
3027                let server = spawn_registry_server(
3028                    std::collections::BTreeMap::from([(
3029                        "/api/v1/crates/demo/0.1.0".to_string(),
3030                        vec![(404, "{}".to_string()), (200, "{}".to_string())],
3031                    )]),
3032                    2,
3033                );
3034                let ws = planned_workspace(td.path(), server.base_url.clone());
3035                let mut opts = default_opts(PathBuf::from(".shipper"));
3036                opts.base_delay = Duration::from_millis(0);
3037                opts.max_delay = Duration::from_millis(0);
3038
3039                let mut reporter = CollectingReporter::default();
3040                let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
3041                assert_eq!(receipt.packages.len(), 1);
3042                assert!(matches!(receipt.packages[0].state, PackageState::Published));
3043                assert_eq!(receipt.packages[0].attempts, 1);
3044                server.join();
3045            },
3046        );
3047    }
3048
3049    #[test]
3050    #[serial]
3051    fn run_publish_retries_on_retryable_failures() {
3052        let td = tempdir().expect("tempdir");
3053        let bin = td.path().join("bin");
3054        write_fake_tools(&bin);
3055        with_test_env(
3056            &bin,
3057            vec![
3058                ("SHIPPER_CARGO_EXIT", Some("1".to_string())),
3059                (
3060                    "SHIPPER_CARGO_STDERR",
3061                    Some("timeout talking to server".to_string()),
3062                ),
3063            ],
3064            || {
3065                let server = spawn_registry_server(
3066                    std::collections::BTreeMap::from([(
3067                        "/api/v1/crates/demo/0.1.0".to_string(),
3068                        vec![
3069                            (404, "{}".to_string()),
3070                            (404, "{}".to_string()),
3071                            (200, "{}".to_string()),
3072                        ],
3073                    )]),
3074                    3,
3075                );
3076                let ws = planned_workspace(td.path(), server.base_url.clone());
3077                let mut opts = default_opts(PathBuf::from(".shipper"));
3078                opts.max_attempts = 2;
3079                opts.base_delay = Duration::from_millis(0);
3080                opts.max_delay = Duration::from_millis(0);
3081
3082                let mut reporter = CollectingReporter::default();
3083                let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
3084                assert!(matches!(receipt.packages[0].state, PackageState::Published));
3085                assert_eq!(receipt.packages[0].attempts, 2);
3086                assert!(reporter.warns.iter().any(|w| w.contains("next attempt in")));
3087                server.join();
3088            },
3089        );
3090    }
3091
3092    #[test]
3093    #[serial]
3094    fn run_publish_errors_when_cargo_command_cannot_start() {
3095        let td = tempdir().expect("tempdir");
3096        let server = spawn_registry_server(
3097            std::collections::BTreeMap::from([(
3098                "/api/v1/crates/demo/0.1.0".to_string(),
3099                vec![(404, "{}".to_string())],
3100            )]),
3101            1,
3102        );
3103
3104        let ws = planned_workspace(td.path(), server.base_url.clone());
3105        let missing = td.path().join("no-cargo-here");
3106        temp_env::with_vars(
3107            vec![(
3108                "SHIPPER_CARGO_BIN",
3109                Some(missing.to_str().expect("utf8").to_string()),
3110            )],
3111            || {
3112                let opts = default_opts(PathBuf::from(".shipper"));
3113                let mut reporter = CollectingReporter::default();
3114                let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
3115                assert!(format!("{err:#}").contains("failed to execute cargo publish"));
3116            },
3117        );
3118        server.join();
3119    }
3120
3121    #[test]
3122    #[serial]
3123    fn run_publish_returns_error_on_permanent_failure() {
3124        let td = tempdir().expect("tempdir");
3125        let bin = td.path().join("bin");
3126        write_fake_tools(&bin);
3127        with_test_env(
3128            &bin,
3129            vec![
3130                ("SHIPPER_CARGO_EXIT", Some("1".to_string())),
3131                (
3132                    "SHIPPER_CARGO_STDERR",
3133                    Some("permission denied".to_string()),
3134                ),
3135            ],
3136            || {
3137                let server = spawn_registry_server(
3138                    std::collections::BTreeMap::from([(
3139                        "/api/v1/crates/demo/0.1.0".to_string(),
3140                        vec![(404, "{}".to_string()), (404, "{}".to_string())],
3141                    )]),
3142                    2,
3143                );
3144                let ws = planned_workspace(td.path(), server.base_url.clone());
3145                let mut opts = default_opts(PathBuf::from(".shipper"));
3146                opts.base_delay = Duration::from_millis(0);
3147                opts.max_delay = Duration::from_millis(0);
3148
3149                let mut reporter = CollectingReporter::default();
3150                let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
3151                assert!(format!("{err:#}").contains("permanent failure"));
3152
3153                let st = state::load_state(&td.path().join(".shipper"))
3154                    .expect("load")
3155                    .expect("exists");
3156                let pkg = st.packages.get("demo@0.1.0").expect("pkg");
3157                assert!(matches!(
3158                    pkg.state,
3159                    PackageState::Failed {
3160                        class: ErrorClass::Permanent,
3161                        ..
3162                    }
3163                ));
3164                server.join();
3165            },
3166        );
3167    }
3168
3169    #[test]
3170    #[serial]
3171    fn run_publish_marks_ambiguous_failure_after_success_without_registry_visibility() {
3172        let td = tempdir().expect("tempdir");
3173        let bin = td.path().join("bin");
3174        write_fake_tools(&bin);
3175        with_test_env(
3176            &bin,
3177            vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
3178            || {
3179                // 3 requests: initial version_exists, readiness check, final chance check
3180                let server = spawn_registry_server(
3181                    std::collections::BTreeMap::from([(
3182                        "/api/v1/crates/demo/0.1.0".to_string(),
3183                        vec![(404, "{}".to_string()), (404, "{}".to_string())],
3184                    )]),
3185                    3,
3186                );
3187                let ws = planned_workspace(td.path(), server.base_url.clone());
3188                let mut opts = default_opts(PathBuf::from(".shipper"));
3189                opts.max_attempts = 1;
3190                opts.readiness.max_total_wait = Duration::from_millis(0);
3191
3192                let mut reporter = CollectingReporter::default();
3193                let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
3194                assert!(format!("{err:#}").contains("failed"));
3195
3196                let st = state::load_state(&td.path().join(".shipper"))
3197                    .expect("load")
3198                    .expect("exists");
3199                let pkg = st.packages.get("demo@0.1.0").expect("pkg");
3200                assert!(matches!(
3201                    pkg.state,
3202                    PackageState::Failed {
3203                        class: ErrorClass::Ambiguous,
3204                        ..
3205                    }
3206                ));
3207                server.join();
3208            },
3209        );
3210    }
3211
3212    #[test]
3213    #[serial]
3214    fn run_publish_recovers_on_final_registry_check_after_ambiguous_verify() {
3215        let td = tempdir().expect("tempdir");
3216        let bin = td.path().join("bin");
3217        write_fake_tools(&bin);
3218        with_test_env(
3219            &bin,
3220            vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
3221            || {
3222                let server = spawn_registry_server(
3223                    std::collections::BTreeMap::from([(
3224                        "/api/v1/crates/demo/0.1.0".to_string(),
3225                        vec![(404, "{}".to_string()), (200, "{}".to_string())],
3226                    )]),
3227                    2,
3228                );
3229                let ws = planned_workspace(td.path(), server.base_url.clone());
3230                let mut opts = default_opts(PathBuf::from(".shipper"));
3231                opts.max_attempts = 1;
3232                opts.readiness.max_total_wait = Duration::from_millis(0);
3233
3234                let mut reporter = CollectingReporter::default();
3235                let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
3236                assert!(matches!(receipt.packages[0].state, PackageState::Published));
3237                server.join();
3238            },
3239        );
3240    }
3241
3242    #[test]
3243    fn run_publish_errors_on_plan_mismatch_without_force_resume() {
3244        let td = tempdir().expect("tempdir");
3245        let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
3246        let state_dir = td.path().join(".shipper");
3247
3248        let mut packages = std::collections::BTreeMap::new();
3249        packages.insert(
3250            "demo@0.1.0".to_string(),
3251            PackageProgress {
3252                name: "demo".to_string(),
3253                version: "0.1.0".to_string(),
3254                attempts: 0,
3255                state: PackageState::Pending,
3256                last_updated_at: Utc::now(),
3257            },
3258        );
3259        let st = ExecutionState {
3260            state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
3261            plan_id: "different-plan".to_string(),
3262            registry: ws.plan.registry.clone(),
3263            created_at: Utc::now(),
3264            updated_at: Utc::now(),
3265            packages,
3266        };
3267        state::save_state(&state_dir, &st).expect("save");
3268
3269        let opts = default_opts(PathBuf::from(".shipper"));
3270        let mut reporter = CollectingReporter::default();
3271        let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
3272        assert!(format!("{err:#}").contains("does not match current plan_id"));
3273    }
3274
3275    #[test]
3276    fn run_publish_allows_forced_resume_with_plan_mismatch() {
3277        let td = tempdir().expect("tempdir");
3278        let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
3279        let state_dir = td.path().join(".shipper");
3280
3281        let mut packages = std::collections::BTreeMap::new();
3282        packages.insert(
3283            "demo@0.1.0".to_string(),
3284            PackageProgress {
3285                name: "demo".to_string(),
3286                version: "0.1.0".to_string(),
3287                attempts: 1,
3288                state: PackageState::Published,
3289                last_updated_at: Utc::now(),
3290            },
3291        );
3292        let st = ExecutionState {
3293            state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
3294            plan_id: "different-plan".to_string(),
3295            registry: ws.plan.registry.clone(),
3296            created_at: Utc::now(),
3297            updated_at: Utc::now(),
3298            packages,
3299        };
3300        state::save_state(&state_dir, &st).expect("save");
3301
3302        let mut opts = default_opts(PathBuf::from(".shipper"));
3303        opts.force_resume = true;
3304
3305        let mut reporter = CollectingReporter::default();
3306        let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
3307        assert!(receipt.packages.is_empty());
3308        assert!(
3309            reporter
3310                .warns
3311                .iter()
3312                .any(|w| w.contains("forcing resume with mismatched plan_id"))
3313        );
3314    }
3315
3316    #[test]
3317    fn run_resume_errors_when_state_is_missing() {
3318        let td = tempdir().expect("tempdir");
3319        let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
3320        let opts = default_opts(PathBuf::from(".shipper"));
3321
3322        let mut reporter = CollectingReporter::default();
3323        let err = run_resume(&ws, &opts, &mut reporter).expect_err("must fail");
3324        assert!(format!("{err:#}").contains("no existing state found"));
3325    }
3326
3327    #[test]
3328    fn run_resume_runs_publish_when_state_exists() {
3329        let td = tempdir().expect("tempdir");
3330        let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
3331        let state_dir = td.path().join(".shipper");
3332
3333        let mut packages = std::collections::BTreeMap::new();
3334        packages.insert(
3335            "demo@0.1.0".to_string(),
3336            PackageProgress {
3337                name: "demo".to_string(),
3338                version: "0.1.0".to_string(),
3339                attempts: 1,
3340                state: PackageState::Published,
3341                last_updated_at: Utc::now(),
3342            },
3343        );
3344        let st = ExecutionState {
3345            state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
3346            plan_id: ws.plan.plan_id.clone(),
3347            registry: ws.plan.registry.clone(),
3348            created_at: Utc::now(),
3349            updated_at: Utc::now(),
3350            packages,
3351        };
3352        state::save_state(&state_dir, &st).expect("save");
3353
3354        let opts = default_opts(PathBuf::from(".shipper"));
3355        let mut reporter = CollectingReporter::default();
3356        let receipt = run_resume(&ws, &opts, &mut reporter).expect("resume");
3357        assert!(receipt.packages.is_empty());
3358    }
3359
3360    // Preflight-specific tests
3361
3362    #[test]
3363    fn preflight_report_serializes_correctly() {
3364        let report = PreflightReport {
3365            plan_id: "test-plan".to_string(),
3366            token_detected: true,
3367            finishability: Finishability::Proven,
3368            packages: vec![PreflightPackage {
3369                name: "demo".to_string(),
3370                version: "0.1.0".to_string(),
3371                already_published: false,
3372                is_new_crate: false,
3373                auth_type: Some(AuthType::Token),
3374                ownership_verified: true,
3375                dry_run_passed: true,
3376                dry_run_output: None,
3377            }],
3378            timestamp: Utc::now(),
3379            dry_run_output: None,
3380        };
3381
3382        let json = serde_json::to_string(&report).expect("serialize");
3383        let parsed: PreflightReport = serde_json::from_str(&json).expect("deserialize");
3384        assert_eq!(parsed.plan_id, report.plan_id);
3385        assert_eq!(parsed.token_detected, report.token_detected);
3386        assert_eq!(parsed.finishability, report.finishability);
3387        assert_eq!(parsed.packages.len(), 1);
3388    }
3389
3390    #[test]
3391    fn finishability_proven_when_all_checks_pass() {
3392        let report = PreflightReport {
3393            plan_id: "test-plan".to_string(),
3394            token_detected: true,
3395            finishability: Finishability::Proven,
3396            packages: vec![PreflightPackage {
3397                name: "demo".to_string(),
3398                version: "0.1.0".to_string(),
3399                already_published: false,
3400                is_new_crate: false,
3401                auth_type: Some(AuthType::Token),
3402                ownership_verified: true,
3403                dry_run_passed: true,
3404                dry_run_output: None,
3405            }],
3406            timestamp: Utc::now(),
3407            dry_run_output: None,
3408        };
3409
3410        assert_eq!(report.finishability, Finishability::Proven);
3411    }
3412
3413    #[test]
3414    fn finishability_not_proven_when_ownership_unverified() {
3415        let report = PreflightReport {
3416            plan_id: "test-plan".to_string(),
3417            token_detected: true,
3418            finishability: Finishability::NotProven,
3419            packages: vec![PreflightPackage {
3420                name: "demo".to_string(),
3421                version: "0.1.0".to_string(),
3422                already_published: false,
3423                is_new_crate: true,
3424                auth_type: Some(AuthType::Token),
3425                ownership_verified: false,
3426                dry_run_passed: true,
3427                dry_run_output: None,
3428            }],
3429            timestamp: Utc::now(),
3430            dry_run_output: None,
3431        };
3432
3433        assert_eq!(report.finishability, Finishability::NotProven);
3434    }
3435
3436    #[test]
3437    fn finishability_failed_when_dry_run_fails() {
3438        let report = PreflightReport {
3439            plan_id: "test-plan".to_string(),
3440            token_detected: true,
3441            finishability: Finishability::Failed,
3442            packages: vec![PreflightPackage {
3443                name: "demo".to_string(),
3444                version: "0.1.0".to_string(),
3445                already_published: false,
3446                is_new_crate: false,
3447                auth_type: Some(AuthType::Token),
3448                ownership_verified: true,
3449                dry_run_passed: false,
3450                dry_run_output: None,
3451            }],
3452            timestamp: Utc::now(),
3453            dry_run_output: None,
3454        };
3455
3456        assert_eq!(report.finishability, Finishability::Failed);
3457    }
3458
3459    #[test]
3460    fn preflight_package_serializes_correctly() {
3461        let pkg = PreflightPackage {
3462            name: "demo".to_string(),
3463            version: "0.1.0".to_string(),
3464            already_published: false,
3465            is_new_crate: true,
3466            auth_type: Some(AuthType::Token),
3467            ownership_verified: true,
3468            dry_run_passed: true,
3469            dry_run_output: None,
3470        };
3471
3472        let json = serde_json::to_string(&pkg).expect("serialize");
3473        let parsed: PreflightPackage = serde_json::from_str(&json).expect("deserialize");
3474        assert_eq!(parsed.name, pkg.name);
3475        assert_eq!(parsed.version, pkg.version);
3476        assert_eq!(parsed.already_published, pkg.already_published);
3477        assert_eq!(parsed.is_new_crate, pkg.is_new_crate);
3478        assert_eq!(parsed.auth_type, pkg.auth_type);
3479        assert_eq!(parsed.ownership_verified, pkg.ownership_verified);
3480        assert_eq!(parsed.dry_run_passed, pkg.dry_run_passed);
3481    }
3482
3483    #[test]
3484    fn auth_type_serializes_correctly() {
3485        let token_auth = AuthType::Token;
3486        let tp_auth = AuthType::TrustedPublishing;
3487        let unknown_auth = AuthType::Unknown;
3488
3489        let json_token = serde_json::to_string(&token_auth).expect("serialize");
3490        let parsed_token: AuthType = serde_json::from_str(&json_token).expect("deserialize");
3491        assert_eq!(parsed_token, AuthType::Token);
3492
3493        let json_tp = serde_json::to_string(&tp_auth).expect("serialize");
3494        let parsed_tp: AuthType = serde_json::from_str(&json_tp).expect("deserialize");
3495        assert_eq!(parsed_tp, AuthType::TrustedPublishing);
3496
3497        let json_unknown = serde_json::to_string(&unknown_auth).expect("serialize");
3498        let parsed_unknown: AuthType = serde_json::from_str(&json_unknown).expect("deserialize");
3499        assert_eq!(parsed_unknown, AuthType::Unknown);
3500    }
3501
3502    // Integration tests for preflight scenarios
3503
3504    #[test]
3505    #[serial]
3506    fn preflight_with_all_packages_already_published() {
3507        let td = tempdir().expect("tempdir");
3508        let bin = td.path().join("bin");
3509        write_fake_tools(&bin);
3510        with_test_env(
3511            &bin,
3512            vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
3513            || {
3514                // Mock registry: version already exists (200)
3515                let server = spawn_registry_server(
3516                    std::collections::BTreeMap::from([
3517                        (
3518                            "/api/v1/crates/demo/0.1.0".to_string(),
3519                            vec![(200, "{}".to_string())],
3520                        ),
3521                        (
3522                            "/api/v1/crates/demo".to_string(),
3523                            vec![(200, "{}".to_string())],
3524                        ),
3525                    ]),
3526                    2,
3527                );
3528                let ws = planned_workspace(td.path(), server.base_url.clone());
3529                let mut opts = default_opts(PathBuf::from(".shipper"));
3530                opts.allow_dirty = true;
3531                opts.skip_ownership_check = true;
3532
3533                let mut reporter = CollectingReporter::default();
3534                let report = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
3535
3536                assert_eq!(report.packages.len(), 1);
3537                assert!(report.packages[0].already_published);
3538                assert!(!report.packages[0].is_new_crate);
3539                assert!(report.packages[0].dry_run_passed);
3540                server.join();
3541            },
3542        );
3543    }
3544
3545    #[test]
3546    #[serial]
3547    fn preflight_with_new_crates() {
3548        let td = tempdir().expect("tempdir");
3549        let bin = td.path().join("bin");
3550        write_fake_tools(&bin);
3551        with_test_env(
3552            &bin,
3553            vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
3554            || {
3555                // Mock registry: crate doesn't exist (404 for both crate and version)
3556                let server = spawn_registry_server(
3557                    std::collections::BTreeMap::from([
3558                        (
3559                            "/api/v1/crates/demo".to_string(),
3560                            vec![(404, "{}".to_string())],
3561                        ),
3562                        (
3563                            "/api/v1/crates/demo/0.1.0".to_string(),
3564                            vec![(404, "{}".to_string())],
3565                        ),
3566                    ]),
3567                    2,
3568                );
3569                let ws = planned_workspace(td.path(), server.base_url.clone());
3570                let mut opts = default_opts(PathBuf::from(".shipper"));
3571                opts.allow_dirty = true;
3572                opts.skip_ownership_check = true;
3573
3574                let mut reporter = CollectingReporter::default();
3575                let report = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
3576
3577                assert_eq!(report.packages.len(), 1);
3578                assert!(!report.packages[0].already_published);
3579                assert!(report.packages[0].is_new_crate);
3580                assert!(report.packages[0].dry_run_passed);
3581                server.join();
3582            },
3583        );
3584    }
3585
3586    #[test]
3587    #[serial]
3588    fn preflight_with_ownership_verification_failure() {
3589        let td = tempdir().expect("tempdir");
3590        let bin = td.path().join("bin");
3591        write_fake_tools(&bin);
3592        with_test_env(
3593            &bin,
3594            vec![
3595                ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
3596                ("CARGO_REGISTRY_TOKEN", Some("fake-token".to_string())),
3597            ],
3598            || {
3599                // Mock registry: version doesn't exist, crate exists, ownership check fails with 403
3600                let server = spawn_registry_server(
3601                    std::collections::BTreeMap::from([
3602                        (
3603                            "/api/v1/crates/demo".to_string(),
3604                            vec![(200, "{}".to_string())],
3605                        ),
3606                        (
3607                            "/api/v1/crates/demo/0.1.0".to_string(),
3608                            vec![(404, "{}".to_string())],
3609                        ),
3610                        (
3611                            "/api/v1/crates/demo/owners".to_string(),
3612                            vec![(403, "{}".to_string())],
3613                        ),
3614                    ]),
3615                    3,
3616                );
3617                let ws = planned_workspace(td.path(), server.base_url.clone());
3618                let mut opts = default_opts(PathBuf::from(".shipper"));
3619                opts.allow_dirty = true;
3620                opts.skip_ownership_check = false;
3621
3622                let mut reporter = CollectingReporter::default();
3623                let report = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
3624
3625                assert_eq!(report.packages.len(), 1);
3626                assert!(!report.packages[0].ownership_verified);
3627                // Should be NotProven because ownership is unverified
3628                assert_eq!(report.finishability, Finishability::NotProven);
3629                server.join();
3630            },
3631        );
3632    }
3633
3634    #[test]
3635    #[serial]
3636    fn preflight_with_dry_run_failure() {
3637        let td = tempdir().expect("tempdir");
3638        let bin = td.path().join("bin");
3639        write_fake_tools(&bin);
3640        with_test_env(
3641            &bin,
3642            vec![
3643                ("SHIPPER_CARGO_EXIT", Some("1".to_string())),
3644                ("SHIPPER_CARGO_STDERR", Some("dry-run failed".to_string())),
3645            ],
3646            || {
3647                let server = spawn_registry_server(
3648                    std::collections::BTreeMap::from([
3649                        (
3650                            "/api/v1/crates/demo/0.1.0".to_string(),
3651                            vec![(404, "{}".to_string())],
3652                        ),
3653                        (
3654                            "/api/v1/crates/demo".to_string(),
3655                            vec![(404, "{}".to_string())],
3656                        ),
3657                    ]),
3658                    2,
3659                );
3660                let ws = planned_workspace(td.path(), server.base_url.clone());
3661                let mut opts = default_opts(PathBuf::from(".shipper"));
3662                opts.allow_dirty = true;
3663                opts.skip_ownership_check = true;
3664
3665                let mut reporter = CollectingReporter::default();
3666                let report = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
3667
3668                assert_eq!(report.packages.len(), 1);
3669                assert!(!report.packages[0].dry_run_passed);
3670                // Should be Failed because dry-run failed
3671                assert_eq!(report.finishability, Finishability::Failed);
3672                server.join();
3673            },
3674        );
3675    }
3676
3677    #[test]
3678    #[serial]
3679    fn preflight_strict_ownership_requires_token() {
3680        let td = tempdir().expect("tempdir");
3681        let bin = td.path().join("bin");
3682        write_fake_tools(&bin);
3683        with_test_env(
3684            &bin,
3685            vec![
3686                ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
3687                (
3688                    "CARGO_HOME",
3689                    Some(td.path().to_str().expect("utf8").to_string()),
3690                ),
3691                ("CARGO_REGISTRY_TOKEN", None),
3692                ("CARGO_REGISTRIES_CRATES_IO_TOKEN", None),
3693            ],
3694            || {
3695                let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
3696                let mut opts = default_opts(PathBuf::from(".shipper"));
3697                opts.allow_dirty = true;
3698                opts.strict_ownership = true;
3699                // No token set
3700
3701                let mut reporter = CollectingReporter::default();
3702                let err = run_preflight(&ws, &opts, &mut reporter).expect_err("must fail");
3703                assert!(
3704                    format!("{err:#}").contains("strict ownership requested but no token found")
3705                );
3706            },
3707        );
3708    }
3709
3710    #[test]
3711    #[serial]
3712    fn preflight_finishability_proven_with_all_checks_pass() {
3713        let td = tempdir().expect("tempdir");
3714        let bin = td.path().join("bin");
3715        write_fake_tools(&bin);
3716        with_test_env(
3717            &bin,
3718            vec![
3719                ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
3720                ("CARGO_REGISTRY_TOKEN", Some("fake-token".to_string())),
3721            ],
3722            || {
3723                // Mock registry: version doesn't exist, crate exists, ownership succeeds
3724                let server = spawn_registry_server(
3725                    std::collections::BTreeMap::from([
3726                        (
3727                            "/api/v1/crates/demo".to_string(),
3728                            vec![(200, "{}".to_string())],
3729                        ),
3730                        (
3731                            "/api/v1/crates/demo/0.1.0".to_string(),
3732                            vec![(404, "{}".to_string())],
3733                        ),
3734                        (
3735                            "/api/v1/crates/demo/owners".to_string(),
3736                            vec![(200, r#"{"users":[]}"#.to_string())],
3737                        ),
3738                    ]),
3739                    3,
3740                );
3741                let ws = planned_workspace(td.path(), server.base_url.clone());
3742                let mut opts = default_opts(PathBuf::from(".shipper"));
3743                opts.allow_dirty = true;
3744                opts.skip_ownership_check = false;
3745
3746                let mut reporter = CollectingReporter::default();
3747                let report = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
3748
3749                assert_eq!(report.packages.len(), 1);
3750                assert!(report.packages[0].ownership_verified);
3751                assert!(report.packages[0].dry_run_passed);
3752                assert_eq!(report.finishability, Finishability::Proven);
3753                server.join();
3754            },
3755        );
3756    }
3757
3758    #[test]
3759    #[serial]
3760    fn test_fast_policy_skips_dry_run() {
3761        let td = tempdir().expect("tempdir");
3762        let bin = td.path().join("bin");
3763        write_fake_tools(&bin);
3764        // Deliberately set cargo to fail — if dry-run runs, it would fail
3765        with_test_env(
3766            &bin,
3767            vec![("SHIPPER_CARGO_EXIT", Some("1".to_string()))],
3768            || {
3769                // Only need version_exists + check_new_crate
3770                let server = spawn_registry_server(
3771                    std::collections::BTreeMap::from([
3772                        (
3773                            "/api/v1/crates/demo/0.1.0".to_string(),
3774                            vec![(404, "{}".to_string())],
3775                        ),
3776                        (
3777                            "/api/v1/crates/demo".to_string(),
3778                            vec![(404, "{}".to_string())],
3779                        ),
3780                    ]),
3781                    2,
3782                );
3783                let ws = planned_workspace(td.path(), server.base_url.clone());
3784                let mut opts = default_opts(PathBuf::from(".shipper"));
3785                opts.policy = crate::types::PublishPolicy::Fast;
3786
3787                let mut reporter = CollectingReporter::default();
3788                let report = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
3789
3790                // dry_run_passed should be true (skipped), not false (cargo would have failed)
3791                assert!(report.packages[0].dry_run_passed);
3792                // ownership_verified should be false (skipped by Fast policy)
3793                assert!(!report.packages[0].ownership_verified);
3794                // Finishability is NotProven because ownership unverified
3795                assert_eq!(report.finishability, Finishability::NotProven);
3796                assert!(
3797                    reporter
3798                        .infos
3799                        .iter()
3800                        .any(|i| i.contains("skipping dry-run"))
3801                );
3802                server.join();
3803            },
3804        );
3805    }
3806
3807    #[test]
3808    #[serial]
3809    fn test_balanced_policy_skips_ownership() {
3810        let td = tempdir().expect("tempdir");
3811        let bin = td.path().join("bin");
3812        write_fake_tools(&bin);
3813        with_test_env(
3814            &bin,
3815            vec![
3816                ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
3817                ("CARGO_REGISTRY_TOKEN", Some("fake-token".to_string())),
3818            ],
3819            || {
3820                // Only need version_exists + check_new_crate (no ownership endpoint)
3821                let server = spawn_registry_server(
3822                    std::collections::BTreeMap::from([
3823                        (
3824                            "/api/v1/crates/demo/0.1.0".to_string(),
3825                            vec![(404, "{}".to_string())],
3826                        ),
3827                        (
3828                            "/api/v1/crates/demo".to_string(),
3829                            vec![(404, "{}".to_string())],
3830                        ),
3831                    ]),
3832                    2,
3833                );
3834                let ws = planned_workspace(td.path(), server.base_url.clone());
3835                let mut opts = default_opts(PathBuf::from(".shipper"));
3836                opts.policy = crate::types::PublishPolicy::Balanced;
3837                opts.skip_ownership_check = false; // would check in Safe, but Balanced overrides
3838
3839                let mut reporter = CollectingReporter::default();
3840                let report = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
3841
3842                // ownership_verified false (Balanced skips ownership)
3843                assert!(!report.packages[0].ownership_verified);
3844                // dry_run_passed true (Balanced still runs dry-run)
3845                assert!(report.packages[0].dry_run_passed);
3846                server.join();
3847            },
3848        );
3849    }
3850
3851    #[test]
3852    #[serial]
3853    fn test_safe_policy_runs_all_checks() {
3854        let td = tempdir().expect("tempdir");
3855        let bin = td.path().join("bin");
3856        write_fake_tools(&bin);
3857        with_test_env(
3858            &bin,
3859            vec![
3860                ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
3861                ("CARGO_REGISTRY_TOKEN", Some("fake-token".to_string())),
3862            ],
3863            || {
3864                // Need version_exists + check_new_crate + ownership
3865                let server = spawn_registry_server(
3866                    std::collections::BTreeMap::from([
3867                        (
3868                            "/api/v1/crates/demo/0.1.0".to_string(),
3869                            vec![(404, "{}".to_string())],
3870                        ),
3871                        (
3872                            "/api/v1/crates/demo".to_string(),
3873                            vec![(200, "{}".to_string())],
3874                        ),
3875                        (
3876                            "/api/v1/crates/demo/owners".to_string(),
3877                            vec![(200, r#"{"users":[]}"#.to_string())],
3878                        ),
3879                    ]),
3880                    3,
3881                );
3882                let ws = planned_workspace(td.path(), server.base_url.clone());
3883                let mut opts = default_opts(PathBuf::from(".shipper"));
3884                opts.policy = crate::types::PublishPolicy::Safe;
3885                opts.skip_ownership_check = false;
3886
3887                let mut reporter = CollectingReporter::default();
3888                let report = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
3889
3890                // All checks ran
3891                assert!(report.packages[0].dry_run_passed);
3892                assert!(report.packages[0].ownership_verified);
3893                assert_eq!(report.finishability, Finishability::Proven);
3894                server.join();
3895            },
3896        );
3897    }
3898
3899    #[test]
3900    #[serial]
3901    fn test_verify_mode_none_skips_dry_run() {
3902        let td = tempdir().expect("tempdir");
3903        let bin = td.path().join("bin");
3904        write_fake_tools(&bin);
3905        // Set cargo to fail — if dry-run ran, it would fail
3906        with_test_env(
3907            &bin,
3908            vec![("SHIPPER_CARGO_EXIT", Some("1".to_string()))],
3909            || {
3910                let server = spawn_registry_server(
3911                    std::collections::BTreeMap::from([
3912                        (
3913                            "/api/v1/crates/demo/0.1.0".to_string(),
3914                            vec![(404, "{}".to_string())],
3915                        ),
3916                        (
3917                            "/api/v1/crates/demo".to_string(),
3918                            vec![(404, "{}".to_string())],
3919                        ),
3920                    ]),
3921                    2,
3922                );
3923                let ws = planned_workspace(td.path(), server.base_url.clone());
3924                let mut opts = default_opts(PathBuf::from(".shipper"));
3925                opts.verify_mode = crate::types::VerifyMode::None;
3926
3927                let mut reporter = CollectingReporter::default();
3928                let report = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
3929
3930                // dry_run_passed is true because verify_mode=None skips it
3931                assert!(report.packages[0].dry_run_passed);
3932                assert!(
3933                    reporter
3934                        .infos
3935                        .iter()
3936                        .any(|i| i.contains("skipping dry-run"))
3937                );
3938                server.join();
3939            },
3940        );
3941    }
3942
3943    #[test]
3944    #[serial]
3945    fn test_verify_mode_package_runs_per_package() {
3946        let td = tempdir().expect("tempdir");
3947        let bin = td.path().join("bin");
3948        write_fake_tools(&bin);
3949        with_test_env(
3950            &bin,
3951            vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
3952            || {
3953                let server = spawn_registry_server(
3954                    std::collections::BTreeMap::from([
3955                        (
3956                            "/api/v1/crates/demo/0.1.0".to_string(),
3957                            vec![(404, "{}".to_string())],
3958                        ),
3959                        (
3960                            "/api/v1/crates/demo".to_string(),
3961                            vec![(404, "{}".to_string())],
3962                        ),
3963                    ]),
3964                    2,
3965                );
3966                let ws = planned_workspace(td.path(), server.base_url.clone());
3967                let mut opts = default_opts(PathBuf::from(".shipper"));
3968                opts.verify_mode = crate::types::VerifyMode::Package;
3969
3970                let mut reporter = CollectingReporter::default();
3971                let report = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
3972
3973                assert!(report.packages[0].dry_run_passed);
3974                assert!(
3975                    reporter
3976                        .infos
3977                        .iter()
3978                        .any(|i| i.contains("per-package dry-run"))
3979                );
3980                server.join();
3981            },
3982        );
3983    }
3984
3985    #[test]
3986    #[serial]
3987    fn resume_from_uploaded_skips_cargo_publish_and_reaches_published() {
3988        let td = tempdir().expect("tempdir");
3989        let bin = td.path().join("bin");
3990        write_fake_tools(&bin);
3991        let args_log = td.path().join("cargo_args.txt");
3992        let mut env_vars = fake_program_env_vars(&bin);
3993        env_vars.extend([
3994            ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
3995            (
3996                "SHIPPER_CARGO_ARGS_LOG",
3997                Some(args_log.to_str().expect("utf8").to_string()),
3998            ),
3999        ]);
4000        temp_env::with_vars(env_vars, || {
4001            // First request (early check) returns 404, second (readiness) returns 200
4002            let server = spawn_registry_server(
4003                std::collections::BTreeMap::from([(
4004                    "/api/v1/crates/demo/0.1.0".to_string(),
4005                    vec![(404, "{}".to_string()), (200, "{}".to_string())],
4006                )]),
4007                2,
4008            );
4009
4010            let ws = planned_workspace(td.path(), server.base_url.clone());
4011            let state_dir = td.path().join(".shipper");
4012
4013            // Pre-create state with Uploaded + attempts=1
4014            let mut packages = std::collections::BTreeMap::new();
4015            packages.insert(
4016                "demo@0.1.0".to_string(),
4017                PackageProgress {
4018                    name: "demo".to_string(),
4019                    version: "0.1.0".to_string(),
4020                    attempts: 1,
4021                    state: PackageState::Uploaded,
4022                    last_updated_at: Utc::now(),
4023                },
4024            );
4025            let st = ExecutionState {
4026                state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
4027                plan_id: ws.plan.plan_id.clone(),
4028                registry: ws.plan.registry.clone(),
4029                created_at: Utc::now(),
4030                updated_at: Utc::now(),
4031                packages,
4032            };
4033            state::save_state(&state_dir, &st).expect("save");
4034
4035            let opts = default_opts(PathBuf::from(".shipper"));
4036            let mut reporter = CollectingReporter::default();
4037            let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
4038
4039            // Package should reach Published via the readiness verification path
4040            assert_eq!(receipt.packages.len(), 1);
4041            assert!(
4042                matches!(receipt.packages[0].state, PackageState::Published),
4043                "expected Published, got {:?}",
4044                receipt.packages[0].state
4045            );
4046
4047            // Cargo publish should NOT have been invoked
4048            // (args_log should not exist or be empty — no cargo publish calls)
4049            let cargo_invoked = args_log.exists()
4050                && fs::read_to_string(&args_log)
4051                    .unwrap_or_default()
4052                    .contains("publish");
4053            assert!(
4054                !cargo_invoked,
4055                "cargo publish should not have been invoked on resume from Uploaded"
4056            );
4057
4058            // Verify reporter got the resume message
4059            assert!(
4060                reporter
4061                    .infos
4062                    .iter()
4063                    .any(|i| i.contains("resuming from uploaded")
4064                        || i.contains("already published")
4065                        || i.contains("already complete"))
4066            );
4067
4068            // Verify the readiness path was exercised
4069            assert!(
4070                reporter.infos.iter().any(|i| i.contains("verifying")
4071                    || i.contains("visible")
4072                    || i.contains("readiness")),
4073                "expected readiness verification to be exercised, reporter infos: {:?}",
4074                reporter.infos
4075            );
4076
4077            server.join();
4078        });
4079    }
4080
4081    #[test]
4082    #[serial]
4083    fn test_resume_from_skips_initial_packages() {
4084        let td = tempdir().expect("tempdir");
4085        let bin = td.path().join("bin");
4086        write_fake_tools(&bin);
4087        let args_log = td.path().join("cargo_args.txt");
4088        let mut env_vars = fake_program_env_vars(&bin);
4089        env_vars.extend([
4090            ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
4091            (
4092                "SHIPPER_CARGO_ARGS_LOG",
4093                Some(args_log.to_str().expect("utf8").to_string()),
4094            ),
4095        ]);
4096        temp_env::with_vars(env_vars, || {
4097            // Mock registry for two packages
4098            // We expect 2 requests total (pkg2 exists? then pkg2 readiness)
4099            // pkg1 is skipped because of resume_from
4100            let server = spawn_registry_server(
4101                std::collections::BTreeMap::from([
4102                    (
4103                        "/api/v1/crates/pkg1/0.1.0".to_string(),
4104                        vec![(404, "{}".to_string()), (200, "{}".to_string())],
4105                    ),
4106                    (
4107                        "/api/v1/crates/pkg2/0.1.0".to_string(),
4108                        vec![(404, "{}".to_string()), (200, "{}".to_string())],
4109                    ),
4110                ]),
4111                2,
4112            );
4113
4114            let mut ws = planned_workspace(td.path(), server.base_url.clone());
4115            // Update plan to have two packages
4116            ws.plan.packages = vec![
4117                PlannedPackage {
4118                    name: "pkg1".to_string(),
4119                    version: "0.1.0".to_string(),
4120                    manifest_path: td.path().join("pkg1/Cargo.toml"),
4121                },
4122                PlannedPackage {
4123                    name: "pkg2".to_string(),
4124                    version: "0.1.0".to_string(),
4125                    manifest_path: td.path().join("pkg2/Cargo.toml"),
4126                },
4127            ];
4128
4129            let mut opts = default_opts(PathBuf::from(".shipper"));
4130            // Resume from second package
4131            opts.resume_from = Some("pkg2".to_string());
4132
4133            let mut reporter = CollectingReporter::default();
4134            let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
4135
4136            // Should only have 1 package in receipt (pkg2)
4137            assert_eq!(receipt.packages.len(), 1);
4138            assert_eq!(receipt.packages[0].name, "pkg2");
4139
4140            // Cargo log should only contain publish for pkg2
4141            let log = std::fs::read_to_string(&args_log).expect("read log");
4142            assert!(!log.contains("pkg1"));
4143            assert!(log.contains("pkg2"));
4144
4145            server.join();
4146        });
4147    }
4148
4149    // ── Additional coverage tests ──────────────────────────────────────
4150
4151    #[test]
4152    fn init_state_creates_pending_entries_for_all_packages() {
4153        let td = tempdir().expect("tempdir");
4154        let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
4155        let state_dir = td.path().join(".shipper");
4156
4157        let st = init_state(&ws, &state_dir).expect("init");
4158        assert_eq!(st.plan_id, "plan-demo");
4159        assert_eq!(st.packages.len(), 1);
4160        let progress = st.packages.get("demo@0.1.0").expect("pkg");
4161        assert_eq!(progress.name, "demo");
4162        assert_eq!(progress.version, "0.1.0");
4163        assert_eq!(progress.attempts, 0);
4164        assert!(matches!(progress.state, PackageState::Pending));
4165    }
4166
4167    #[test]
4168    fn init_state_persists_state_to_disk() {
4169        let td = tempdir().expect("tempdir");
4170        let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
4171        let state_dir = td.path().join(".shipper");
4172
4173        let _ = init_state(&ws, &state_dir).expect("init");
4174        let loaded = state::load_state(&state_dir)
4175            .expect("load")
4176            .expect("exists");
4177        assert_eq!(loaded.plan_id, "plan-demo");
4178        assert!(loaded.packages.contains_key("demo@0.1.0"));
4179    }
4180
4181    #[test]
4182    fn init_state_with_multi_package_plan() {
4183        let td = tempdir().expect("tempdir");
4184        let mut ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
4185        ws.plan.packages = vec![
4186            PlannedPackage {
4187                name: "alpha".to_string(),
4188                version: "1.0.0".to_string(),
4189                manifest_path: td.path().join("alpha/Cargo.toml"),
4190            },
4191            PlannedPackage {
4192                name: "beta".to_string(),
4193                version: "2.0.0".to_string(),
4194                manifest_path: td.path().join("beta/Cargo.toml"),
4195            },
4196            PlannedPackage {
4197                name: "gamma".to_string(),
4198                version: "0.3.0".to_string(),
4199                manifest_path: td.path().join("gamma/Cargo.toml"),
4200            },
4201        ];
4202        let state_dir = td.path().join(".shipper");
4203
4204        let st = init_state(&ws, &state_dir).expect("init");
4205        assert_eq!(st.packages.len(), 3);
4206        assert!(st.packages.contains_key("alpha@1.0.0"));
4207        assert!(st.packages.contains_key("beta@2.0.0"));
4208        assert!(st.packages.contains_key("gamma@0.3.0"));
4209        for progress in st.packages.values() {
4210            assert_eq!(progress.attempts, 0);
4211            assert!(matches!(progress.state, PackageState::Pending));
4212        }
4213    }
4214
4215    #[test]
4216    fn run_publish_errors_on_invalid_resume_from_target() {
4217        let td = tempdir().expect("tempdir");
4218        let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
4219        let mut opts = default_opts(PathBuf::from(".shipper"));
4220        opts.resume_from = Some("nonexistent-package".to_string());
4221
4222        let mut reporter = CollectingReporter::default();
4223        let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
4224        assert!(format!("{err:#}").contains("resume-from package"));
4225        assert!(format!("{err:#}").contains("not found in publish plan"));
4226    }
4227
4228    #[test]
4229    #[serial]
4230    fn run_publish_writes_execution_events() {
4231        let td = tempdir().expect("tempdir");
4232        let bin = td.path().join("bin");
4233        write_fake_tools(&bin);
4234        let env_vars = fake_program_env_vars(&bin);
4235        temp_env::with_vars(env_vars, || {
4236            let server = spawn_registry_server(
4237                std::collections::BTreeMap::from([(
4238                    "/api/v1/crates/demo/0.1.0".to_string(),
4239                    vec![(200, "{}".to_string())],
4240                )]),
4241                1,
4242            );
4243            let ws = planned_workspace(td.path(), server.base_url.clone());
4244            let opts = default_opts(PathBuf::from(".shipper"));
4245
4246            let mut reporter = CollectingReporter::default();
4247            let _ = run_publish(&ws, &opts, &mut reporter).expect("publish");
4248
4249            let events_path = td.path().join(".shipper").join("events.jsonl");
4250            let log =
4251                crate::state::events::EventLog::read_from_file(&events_path).expect("read events");
4252            let events = log.all_events();
4253
4254            assert!(
4255                events
4256                    .iter()
4257                    .any(|e| matches!(e.event_type, EventType::ExecutionStarted))
4258            );
4259            assert!(
4260                events
4261                    .iter()
4262                    .any(|e| matches!(e.event_type, EventType::PlanCreated { .. }))
4263            );
4264            assert!(
4265                events
4266                    .iter()
4267                    .any(|e| matches!(e.event_type, EventType::PackageSkipped { .. }))
4268            );
4269            assert!(
4270                events
4271                    .iter()
4272                    .any(|e| matches!(e.event_type, EventType::ExecutionFinished { .. }))
4273            );
4274            server.join();
4275        });
4276    }
4277
4278    #[test]
4279    #[serial]
4280    fn run_publish_receipt_contains_evidence_after_success() {
4281        let td = tempdir().expect("tempdir");
4282        let bin = td.path().join("bin");
4283        write_fake_tools(&bin);
4284        let mut env_vars = fake_program_env_vars(&bin);
4285        env_vars.extend([("SHIPPER_CARGO_EXIT", Some("0".to_string()))]);
4286        temp_env::with_vars(env_vars, || {
4287            let server = spawn_registry_server(
4288                std::collections::BTreeMap::from([(
4289                    "/api/v1/crates/demo/0.1.0".to_string(),
4290                    vec![(404, "{}".to_string()), (200, "{}".to_string())],
4291                )]),
4292                2,
4293            );
4294            let ws = planned_workspace(td.path(), server.base_url.clone());
4295            let opts = default_opts(PathBuf::from(".shipper"));
4296
4297            let mut reporter = CollectingReporter::default();
4298            let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
4299
4300            assert_eq!(receipt.receipt_version, "shipper.receipt.v2");
4301            assert_eq!(receipt.plan_id, "plan-demo");
4302            assert_eq!(receipt.registry.name, "crates-io");
4303            assert_eq!(receipt.packages.len(), 1);
4304            assert!(matches!(receipt.packages[0].state, PackageState::Published));
4305            assert_eq!(receipt.packages[0].attempts, 1);
4306            assert!(!receipt.packages[0].evidence.attempts.is_empty());
4307            assert_eq!(receipt.packages[0].evidence.attempts[0].attempt_number, 1);
4308            assert_eq!(receipt.packages[0].evidence.attempts[0].exit_code, 0);
4309            server.join();
4310        });
4311    }
4312
4313    #[test]
4314    #[serial]
4315    fn run_publish_receipt_persisted_to_disk() {
4316        let td = tempdir().expect("tempdir");
4317        let bin = td.path().join("bin");
4318        write_fake_tools(&bin);
4319        let env_vars = fake_program_env_vars(&bin);
4320        temp_env::with_vars(env_vars, || {
4321            let server = spawn_registry_server(
4322                std::collections::BTreeMap::from([(
4323                    "/api/v1/crates/demo/0.1.0".to_string(),
4324                    vec![(200, "{}".to_string())],
4325                )]),
4326                1,
4327            );
4328            let ws = planned_workspace(td.path(), server.base_url.clone());
4329            let opts = default_opts(PathBuf::from(".shipper"));
4330
4331            let mut reporter = CollectingReporter::default();
4332            let _ = run_publish(&ws, &opts, &mut reporter).expect("publish");
4333
4334            let state_dir = td.path().join(".shipper");
4335            let receipt_path = state::receipt_path(&state_dir);
4336            assert!(receipt_path.exists());
4337
4338            let receipt_json = fs::read_to_string(&receipt_path).expect("read receipt");
4339            let parsed: Receipt = serde_json::from_str(&receipt_json).expect("parse receipt");
4340            assert_eq!(parsed.plan_id, "plan-demo");
4341            assert_eq!(parsed.receipt_version, "shipper.receipt.v2");
4342            server.join();
4343        });
4344    }
4345
4346    #[test]
4347    #[serial]
4348    fn run_publish_dirty_git_fails_when_not_allowed() {
4349        let td = tempdir().expect("tempdir");
4350        let bin = td.path().join("bin");
4351        write_fake_tools(&bin);
4352        let mut env_vars = fake_program_env_vars(&bin);
4353        env_vars.extend([("SHIPPER_GIT_CLEAN", Some("0".to_string()))]);
4354        temp_env::with_vars(env_vars, || {
4355            let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
4356            let mut opts = default_opts(PathBuf::from(".shipper"));
4357            opts.allow_dirty = false;
4358
4359            let mut reporter = CollectingReporter::default();
4360            let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
4361            let msg = format!("{err:#}");
4362            assert!(
4363                msg.contains("dirty") || msg.contains("uncommitted") || msg.contains("git"),
4364                "unexpected error: {msg}"
4365            );
4366        });
4367    }
4368
4369    #[test]
4370    #[serial]
4371    fn run_publish_state_attempts_counter_increments_on_retry() {
4372        let td = tempdir().expect("tempdir");
4373        let bin = td.path().join("bin");
4374        write_fake_tools(&bin);
4375        with_test_env(
4376            &bin,
4377            vec![
4378                ("SHIPPER_CARGO_EXIT", Some("1".to_string())),
4379                (
4380                    "SHIPPER_CARGO_STDERR",
4381                    Some("HTTP 503 service unavailable".to_string()),
4382                ),
4383            ],
4384            || {
4385                // All registry checks return 404 so the package is never found
4386                let server = spawn_registry_server(
4387                    std::collections::BTreeMap::from([(
4388                        "/api/v1/crates/demo/0.1.0".to_string(),
4389                        vec![
4390                            (404, "{}".to_string()),
4391                            (404, "{}".to_string()),
4392                            (404, "{}".to_string()),
4393                            (404, "{}".to_string()),
4394                        ],
4395                    )]),
4396                    4,
4397                );
4398                let ws = planned_workspace(td.path(), server.base_url.clone());
4399                let mut opts = default_opts(PathBuf::from(".shipper"));
4400                opts.max_attempts = 2;
4401                opts.base_delay = Duration::from_millis(0);
4402                opts.max_delay = Duration::from_millis(0);
4403
4404                let mut reporter = CollectingReporter::default();
4405                let _ = run_publish(&ws, &opts, &mut reporter);
4406
4407                let st = state::load_state(&td.path().join(".shipper"))
4408                    .expect("load")
4409                    .expect("exists");
4410                let pkg = st.packages.get("demo@0.1.0").expect("pkg");
4411                assert_eq!(pkg.attempts, 2, "expected 2 attempts");
4412                server.join();
4413            },
4414        );
4415    }
4416
4417    #[test]
4418    #[serial]
4419    fn run_publish_permanent_failure_emits_failed_event() {
4420        let td = tempdir().expect("tempdir");
4421        let bin = td.path().join("bin");
4422        write_fake_tools(&bin);
4423        with_test_env(
4424            &bin,
4425            vec![
4426                ("SHIPPER_CARGO_EXIT", Some("1".to_string())),
4427                (
4428                    "SHIPPER_CARGO_STDERR",
4429                    Some("permission denied".to_string()),
4430                ),
4431            ],
4432            || {
4433                let server = spawn_registry_server(
4434                    std::collections::BTreeMap::from([(
4435                        "/api/v1/crates/demo/0.1.0".to_string(),
4436                        vec![(404, "{}".to_string()), (404, "{}".to_string())],
4437                    )]),
4438                    2,
4439                );
4440                let ws = planned_workspace(td.path(), server.base_url.clone());
4441                let mut opts = default_opts(PathBuf::from(".shipper"));
4442                opts.base_delay = Duration::from_millis(0);
4443                opts.max_delay = Duration::from_millis(0);
4444
4445                let mut reporter = CollectingReporter::default();
4446                let _ = run_publish(&ws, &opts, &mut reporter);
4447
4448                let events_path = td.path().join(".shipper").join("events.jsonl");
4449                let log = crate::state::events::EventLog::read_from_file(&events_path)
4450                    .expect("read events");
4451                let events = log.all_events();
4452
4453                assert!(
4454                    events
4455                        .iter()
4456                        .any(|e| matches!(e.event_type, EventType::PackageFailed { .. }))
4457                );
4458                assert!(
4459                    events
4460                        .iter()
4461                        .any(|e| matches!(e.event_type, EventType::PackageAttempted { .. }))
4462                );
4463                server.join();
4464            },
4465        );
4466    }
4467
4468    #[test]
4469    #[serial]
4470    fn run_publish_multi_package_first_published_second_skipped() {
4471        let td = tempdir().expect("tempdir");
4472        let bin = td.path().join("bin");
4473        write_fake_tools(&bin);
4474        let mut env_vars = fake_program_env_vars(&bin);
4475        env_vars.extend([("SHIPPER_CARGO_EXIT", Some("0".to_string()))]);
4476        temp_env::with_vars(env_vars, || {
4477            let server = spawn_registry_server(
4478                std::collections::BTreeMap::from([
4479                    (
4480                        "/api/v1/crates/alpha/1.0.0".to_string(),
4481                        vec![(404, "{}".to_string()), (200, "{}".to_string())],
4482                    ),
4483                    (
4484                        "/api/v1/crates/beta/2.0.0".to_string(),
4485                        vec![(200, "{}".to_string())],
4486                    ),
4487                ]),
4488                3,
4489            );
4490            let mut ws = planned_workspace(td.path(), server.base_url.clone());
4491            ws.plan.packages = vec![
4492                PlannedPackage {
4493                    name: "alpha".to_string(),
4494                    version: "1.0.0".to_string(),
4495                    manifest_path: td.path().join("alpha/Cargo.toml"),
4496                },
4497                PlannedPackage {
4498                    name: "beta".to_string(),
4499                    version: "2.0.0".to_string(),
4500                    manifest_path: td.path().join("beta/Cargo.toml"),
4501                },
4502            ];
4503            let opts = default_opts(PathBuf::from(".shipper"));
4504
4505            let mut reporter = CollectingReporter::default();
4506            let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
4507
4508            assert_eq!(receipt.packages.len(), 2);
4509            assert!(matches!(receipt.packages[0].state, PackageState::Published));
4510            assert!(matches!(
4511                receipt.packages[1].state,
4512                PackageState::Skipped { .. }
4513            ));
4514            assert_eq!(receipt.packages[0].name, "alpha");
4515            assert_eq!(receipt.packages[1].name, "beta");
4516            server.join();
4517        });
4518    }
4519
4520    #[test]
4521    fn backoff_delay_linear_strategy() {
4522        let base = Duration::from_millis(100);
4523        let max = Duration::from_millis(500);
4524        let d1 = backoff_delay(base, max, 1, crate::retry::RetryStrategyType::Linear, 0.0);
4525        let d3 = backoff_delay(base, max, 3, crate::retry::RetryStrategyType::Linear, 0.0);
4526        let d20 = backoff_delay(base, max, 20, crate::retry::RetryStrategyType::Linear, 0.0);
4527
4528        assert_eq!(d1, Duration::from_millis(100));
4529        assert!(d3 > d1);
4530        assert!(d20 <= max, "linear delay should be capped at max");
4531    }
4532
4533    #[test]
4534    fn backoff_delay_constant_strategy() {
4535        let base = Duration::from_millis(200);
4536        let max = Duration::from_millis(1000);
4537        let d1 = backoff_delay(base, max, 1, crate::retry::RetryStrategyType::Constant, 0.0);
4538        let d5 = backoff_delay(base, max, 5, crate::retry::RetryStrategyType::Constant, 0.0);
4539        let d10 = backoff_delay(
4540            base,
4541            max,
4542            10,
4543            crate::retry::RetryStrategyType::Constant,
4544            0.0,
4545        );
4546
4547        assert_eq!(d1, base);
4548        assert_eq!(d5, base);
4549        assert_eq!(d10, base);
4550    }
4551
4552    #[test]
4553    fn backoff_delay_immediate_strategy() {
4554        let base = Duration::from_millis(200);
4555        let max = Duration::from_millis(1000);
4556        let d1 = backoff_delay(
4557            base,
4558            max,
4559            1,
4560            crate::retry::RetryStrategyType::Immediate,
4561            0.0,
4562        );
4563        let d5 = backoff_delay(
4564            base,
4565            max,
4566            5,
4567            crate::retry::RetryStrategyType::Immediate,
4568            0.0,
4569        );
4570
4571        assert_eq!(d1, Duration::ZERO);
4572        assert_eq!(d5, Duration::ZERO);
4573    }
4574
4575    #[test]
4576    fn backoff_delay_exponential_zero_jitter_is_deterministic() {
4577        let base = Duration::from_millis(100);
4578        let max = Duration::from_secs(10);
4579        let d1a = backoff_delay(
4580            base,
4581            max,
4582            1,
4583            crate::retry::RetryStrategyType::Exponential,
4584            0.0,
4585        );
4586        let d1b = backoff_delay(
4587            base,
4588            max,
4589            1,
4590            crate::retry::RetryStrategyType::Exponential,
4591            0.0,
4592        );
4593        assert_eq!(d1a, d1b);
4594        assert_eq!(d1a, base);
4595    }
4596
4597    #[test]
4598    fn classify_cargo_failure_rate_limit() {
4599        let (class, _msg) = classify_cargo_failure("HTTP 429 too many requests", "");
4600        assert_eq!(class, ErrorClass::Retryable);
4601    }
4602
4603    #[test]
4604    fn classify_cargo_failure_timeout() {
4605        let (class, _msg) = classify_cargo_failure("timeout talking to server", "");
4606        assert_eq!(class, ErrorClass::Retryable);
4607    }
4608
4609    #[test]
4610    fn classify_cargo_failure_service_unavailable() {
4611        let (class, _msg) = classify_cargo_failure("HTTP 503 service unavailable", "");
4612        assert_eq!(class, ErrorClass::Retryable);
4613    }
4614
4615    #[test]
4616    fn classify_cargo_failure_auth_failure() {
4617        let (class, _msg) = classify_cargo_failure("permission denied", "");
4618        assert_eq!(class, ErrorClass::Permanent);
4619    }
4620
4621    #[test]
4622    fn classify_cargo_failure_unknown_error_is_ambiguous() {
4623        let (class, _msg) = classify_cargo_failure("something totally unexpected", "");
4624        assert_eq!(class, ErrorClass::Ambiguous);
4625    }
4626
4627    #[test]
4628    fn short_state_covers_all_variants() {
4629        assert_eq!(short_state(&PackageState::Pending), "pending");
4630        assert_eq!(short_state(&PackageState::Uploaded), "uploaded");
4631        assert_eq!(short_state(&PackageState::Published), "published");
4632        assert_eq!(
4633            short_state(&PackageState::Skipped {
4634                reason: "already published".to_string()
4635            }),
4636            "skipped"
4637        );
4638        assert_eq!(
4639            short_state(&PackageState::Failed {
4640                class: ErrorClass::Retryable,
4641                message: "timeout".to_string()
4642            }),
4643            "failed"
4644        );
4645        assert_eq!(
4646            short_state(&PackageState::Failed {
4647                class: ErrorClass::Ambiguous,
4648                message: "unknown".to_string()
4649            }),
4650            "failed"
4651        );
4652        assert_eq!(
4653            short_state(&PackageState::Ambiguous {
4654                message: "not sure".to_string()
4655            }),
4656            "ambiguous"
4657        );
4658    }
4659
4660    #[test]
4661    fn pkg_key_formats_correctly() {
4662        assert_eq!(pkg_key("my-crate", "1.2.3"), "my-crate@1.2.3");
4663        assert_eq!(pkg_key("a", "0.0.1"), "a@0.0.1");
4664        assert_eq!(pkg_key("foo_bar-baz", "10.20.30"), "foo_bar-baz@10.20.30");
4665    }
4666
4667    #[test]
4668    #[serial]
4669    fn run_publish_skipped_package_receipt_has_empty_evidence() {
4670        let td = tempdir().expect("tempdir");
4671        let bin = td.path().join("bin");
4672        write_fake_tools(&bin);
4673        let env_vars = fake_program_env_vars(&bin);
4674        temp_env::with_vars(env_vars, || {
4675            let server = spawn_registry_server(
4676                std::collections::BTreeMap::from([(
4677                    "/api/v1/crates/demo/0.1.0".to_string(),
4678                    vec![(200, "{}".to_string())],
4679                )]),
4680                1,
4681            );
4682            let ws = planned_workspace(td.path(), server.base_url.clone());
4683            let opts = default_opts(PathBuf::from(".shipper"));
4684
4685            let mut reporter = CollectingReporter::default();
4686            let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
4687
4688            assert_eq!(receipt.packages.len(), 1);
4689            assert!(matches!(
4690                receipt.packages[0].state,
4691                PackageState::Skipped { .. }
4692            ));
4693            assert!(
4694                receipt.packages[0].evidence.attempts.is_empty(),
4695                "skipped packages should have no attempt evidence"
4696            );
4697            assert!(
4698                receipt.packages[0].evidence.readiness_checks.is_empty(),
4699                "skipped packages should have no readiness evidence"
4700            );
4701            server.join();
4702        });
4703    }
4704
4705    #[test]
4706    #[serial]
4707    fn run_publish_execution_result_is_success_when_all_published() {
4708        let td = tempdir().expect("tempdir");
4709        let bin = td.path().join("bin");
4710        write_fake_tools(&bin);
4711        let mut env_vars = fake_program_env_vars(&bin);
4712        env_vars.extend([("SHIPPER_CARGO_EXIT", Some("0".to_string()))]);
4713        temp_env::with_vars(env_vars, || {
4714            let server = spawn_registry_server(
4715                std::collections::BTreeMap::from([(
4716                    "/api/v1/crates/demo/0.1.0".to_string(),
4717                    vec![(404, "{}".to_string()), (200, "{}".to_string())],
4718                )]),
4719                2,
4720            );
4721            let ws = planned_workspace(td.path(), server.base_url.clone());
4722            let opts = default_opts(PathBuf::from(".shipper"));
4723
4724            let mut reporter = CollectingReporter::default();
4725            let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
4726
4727            // Check that the receipt event log was written
4728            assert!(receipt.event_log_path.exists());
4729            let log = crate::state::events::EventLog::read_from_file(&receipt.event_log_path)
4730                .expect("events");
4731            let finish_events: Vec<_> = log
4732                .all_events()
4733                .iter()
4734                .filter(|e| matches!(e.event_type, EventType::ExecutionFinished { .. }))
4735                .collect();
4736            assert_eq!(finish_events.len(), 1);
4737            if let EventType::ExecutionFinished { result } = &finish_events[0].event_type {
4738                assert!(
4739                    matches!(result, ExecutionResult::Success),
4740                    "expected Success, got {result:?}"
4741                );
4742            }
4743            server.join();
4744        });
4745    }
4746
4747    #[test]
4748    fn run_publish_force_skips_lock_timeout() {
4749        // When force=true, lock_timeout is set to ZERO. This test verifies
4750        // the opts are respected without blocking.
4751        let td = tempdir().expect("tempdir");
4752        let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
4753        let state_dir = td.path().join(".shipper");
4754
4755        // Pre-create state with all packages published
4756        let mut packages = std::collections::BTreeMap::new();
4757        packages.insert(
4758            "demo@0.1.0".to_string(),
4759            PackageProgress {
4760                name: "demo".to_string(),
4761                version: "0.1.0".to_string(),
4762                attempts: 1,
4763                state: PackageState::Published,
4764                last_updated_at: Utc::now(),
4765            },
4766        );
4767        let st = ExecutionState {
4768            state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
4769            plan_id: ws.plan.plan_id.clone(),
4770            registry: ws.plan.registry.clone(),
4771            created_at: Utc::now(),
4772            updated_at: Utc::now(),
4773            packages,
4774        };
4775        state::save_state(&state_dir, &st).expect("save");
4776
4777        let mut opts = default_opts(PathBuf::from(".shipper"));
4778        opts.force = true;
4779
4780        let mut reporter = CollectingReporter::default();
4781        let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
4782        assert!(receipt.packages.is_empty());
4783    }
4784
4785    #[test]
4786    #[serial]
4787    fn run_publish_resume_from_skips_before_and_warns() {
4788        let td = tempdir().expect("tempdir");
4789        let bin = td.path().join("bin");
4790        write_fake_tools(&bin);
4791        let mut env_vars = fake_program_env_vars(&bin);
4792        env_vars.extend([("SHIPPER_CARGO_EXIT", Some("0".to_string()))]);
4793        temp_env::with_vars(env_vars, || {
4794            let server = spawn_registry_server(
4795                std::collections::BTreeMap::from([
4796                    (
4797                        "/api/v1/crates/alpha/1.0.0".to_string(),
4798                        vec![(404, "{}".to_string())],
4799                    ),
4800                    (
4801                        "/api/v1/crates/beta/2.0.0".to_string(),
4802                        vec![(404, "{}".to_string()), (200, "{}".to_string())],
4803                    ),
4804                ]),
4805                2,
4806            );
4807
4808            let mut ws = planned_workspace(td.path(), server.base_url.clone());
4809            ws.plan.packages = vec![
4810                PlannedPackage {
4811                    name: "alpha".to_string(),
4812                    version: "1.0.0".to_string(),
4813                    manifest_path: td.path().join("alpha/Cargo.toml"),
4814                },
4815                PlannedPackage {
4816                    name: "beta".to_string(),
4817                    version: "2.0.0".to_string(),
4818                    manifest_path: td.path().join("beta/Cargo.toml"),
4819                },
4820            ];
4821            let mut opts = default_opts(PathBuf::from(".shipper"));
4822            opts.resume_from = Some("beta".to_string());
4823
4824            let mut reporter = CollectingReporter::default();
4825            let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
4826
4827            // Only beta should be in receipt
4828            assert_eq!(receipt.packages.len(), 1);
4829            assert_eq!(receipt.packages[0].name, "beta");
4830
4831            // Alpha was pending, so it should produce a warning about skipping
4832            assert!(
4833                reporter
4834                    .warns
4835                    .iter()
4836                    .any(|w| w.contains("skipping") && w.contains("before resume point")),
4837                "expected warning about skipping alpha, got: {:?}",
4838                reporter.warns
4839            );
4840            server.join();
4841        });
4842    }
4843
4844    #[test]
4845    #[serial]
4846    fn run_publish_resume_from_already_done_skipped_silently() {
4847        let td = tempdir().expect("tempdir");
4848        let bin = td.path().join("bin");
4849        write_fake_tools(&bin);
4850        let mut env_vars = fake_program_env_vars(&bin);
4851        env_vars.extend([("SHIPPER_CARGO_EXIT", Some("0".to_string()))]);
4852        temp_env::with_vars(env_vars, || {
4853            let server = spawn_registry_server(
4854                std::collections::BTreeMap::from([(
4855                    "/api/v1/crates/beta/2.0.0".to_string(),
4856                    vec![(404, "{}".to_string()), (200, "{}".to_string())],
4857                )]),
4858                2,
4859            );
4860
4861            let mut ws = planned_workspace(td.path(), server.base_url.clone());
4862            ws.plan.packages = vec![
4863                PlannedPackage {
4864                    name: "alpha".to_string(),
4865                    version: "1.0.0".to_string(),
4866                    manifest_path: td.path().join("alpha/Cargo.toml"),
4867                },
4868                PlannedPackage {
4869                    name: "beta".to_string(),
4870                    version: "2.0.0".to_string(),
4871                    manifest_path: td.path().join("beta/Cargo.toml"),
4872                },
4873            ];
4874
4875            // Pre-create state with alpha already published
4876            let state_dir = td.path().join(".shipper");
4877            let mut packages = std::collections::BTreeMap::new();
4878            packages.insert(
4879                "alpha@1.0.0".to_string(),
4880                PackageProgress {
4881                    name: "alpha".to_string(),
4882                    version: "1.0.0".to_string(),
4883                    attempts: 1,
4884                    state: PackageState::Published,
4885                    last_updated_at: Utc::now(),
4886                },
4887            );
4888            let st = ExecutionState {
4889                state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
4890                plan_id: ws.plan.plan_id.clone(),
4891                registry: ws.plan.registry.clone(),
4892                created_at: Utc::now(),
4893                updated_at: Utc::now(),
4894                packages,
4895            };
4896            state::save_state(&state_dir, &st).expect("save");
4897
4898            let mut opts = default_opts(PathBuf::from(".shipper"));
4899            opts.resume_from = Some("beta".to_string());
4900
4901            let mut reporter = CollectingReporter::default();
4902            let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
4903
4904            // Alpha should be silently skipped (already complete), beta published
4905            assert_eq!(receipt.packages.len(), 1);
4906            assert_eq!(receipt.packages[0].name, "beta");
4907
4908            // Alpha should produce an info about "already complete" not a warning
4909            assert!(
4910                reporter
4911                    .infos
4912                    .iter()
4913                    .any(|i| i.contains("already complete") && i.contains("alpha")),
4914                "expected info about alpha being already complete, got: {:?}",
4915                reporter.infos
4916            );
4917            server.join();
4918        });
4919    }
4920
4921    #[test]
4922    fn update_state_transitions_correctly() {
4923        let td = tempdir().expect("tempdir");
4924        let state_dir = td.path().join(".shipper");
4925        let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
4926
4927        let mut st = init_state(&ws, &state_dir).expect("init");
4928        let key = "demo@0.1.0";
4929
4930        // Pending -> Uploaded
4931        update_state(&mut st, &state_dir, key, PackageState::Uploaded).expect("update");
4932        assert!(matches!(
4933            st.packages.get(key).unwrap().state,
4934            PackageState::Uploaded
4935        ));
4936
4937        // Uploaded -> Published
4938        update_state(&mut st, &state_dir, key, PackageState::Published).expect("update");
4939        assert!(matches!(
4940            st.packages.get(key).unwrap().state,
4941            PackageState::Published
4942        ));
4943
4944        // Verify persisted to disk
4945        let loaded = state::load_state(&state_dir)
4946            .expect("load")
4947            .expect("exists");
4948        assert!(matches!(
4949            loaded.packages.get(key).unwrap().state,
4950            PackageState::Published
4951        ));
4952    }
4953
4954    #[test]
4955    fn update_state_to_failed() {
4956        let td = tempdir().expect("tempdir");
4957        let state_dir = td.path().join(".shipper");
4958        let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
4959
4960        let mut st = init_state(&ws, &state_dir).expect("init");
4961        let key = "demo@0.1.0";
4962
4963        let failed = PackageState::Failed {
4964            class: ErrorClass::Permanent,
4965            message: "auth failure".to_string(),
4966        };
4967        update_state(&mut st, &state_dir, key, failed).expect("update");
4968
4969        let pkg = st.packages.get(key).unwrap();
4970        match &pkg.state {
4971            PackageState::Failed { class, message } => {
4972                assert_eq!(*class, ErrorClass::Permanent);
4973                assert_eq!(message, "auth failure");
4974            }
4975            other => panic!("expected Failed, got {other:?}"),
4976        }
4977    }
4978
4979    #[test]
4980    fn update_state_to_skipped() {
4981        let td = tempdir().expect("tempdir");
4982        let state_dir = td.path().join(".shipper");
4983        let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
4984
4985        let mut st = init_state(&ws, &state_dir).expect("init");
4986        let key = "demo@0.1.0";
4987
4988        let skipped = PackageState::Skipped {
4989            reason: "already published".to_string(),
4990        };
4991        update_state(&mut st, &state_dir, key, skipped).expect("update");
4992
4993        match &st.packages.get(key).unwrap().state {
4994            PackageState::Skipped { reason } => {
4995                assert_eq!(reason, "already published");
4996            }
4997            other => panic!("expected Skipped, got {other:?}"),
4998        }
4999    }
5000
5001    #[test]
5002    fn receipt_serialization_roundtrip() {
5003        let receipt = Receipt {
5004            receipt_version: "shipper.receipt.v2".to_string(),
5005            plan_id: "plan-test-123".to_string(),
5006            registry: Registry {
5007                name: "crates-io".to_string(),
5008                api_base: "https://crates.io".to_string(),
5009                index_base: None,
5010            },
5011            started_at: Utc::now(),
5012            finished_at: Utc::now(),
5013            packages: vec![
5014                PackageReceipt {
5015                    name: "alpha".to_string(),
5016                    version: "1.0.0".to_string(),
5017                    attempts: 1,
5018                    state: PackageState::Published,
5019                    started_at: Utc::now(),
5020                    finished_at: Utc::now(),
5021                    duration_ms: 1234,
5022                    evidence: crate::types::PackageEvidence {
5023                        attempts: vec![AttemptEvidence {
5024                            attempt_number: 1,
5025                            command: "cargo publish -p alpha".to_string(),
5026                            exit_code: 0,
5027                            stdout_tail: "Uploading alpha".to_string(),
5028                            stderr_tail: String::new(),
5029                            timestamp: Utc::now(),
5030                            duration: Duration::from_millis(500),
5031                        }],
5032                        readiness_checks: vec![ReadinessEvidence {
5033                            attempt: 1,
5034                            visible: true,
5035                            timestamp: Utc::now(),
5036                            delay_before: Duration::from_millis(100),
5037                        }],
5038                    },
5039                    compromised_at: None,
5040                    compromised_by: None,
5041                    superseded_by: None,
5042                },
5043                PackageReceipt {
5044                    name: "beta".to_string(),
5045                    version: "2.0.0".to_string(),
5046                    attempts: 0,
5047                    state: PackageState::Skipped {
5048                        reason: "already published".to_string(),
5049                    },
5050                    started_at: Utc::now(),
5051                    finished_at: Utc::now(),
5052                    duration_ms: 10,
5053                    evidence: crate::types::PackageEvidence {
5054                        attempts: vec![],
5055                        readiness_checks: vec![],
5056                    },
5057                    compromised_at: None,
5058                    compromised_by: None,
5059                    superseded_by: None,
5060                },
5061            ],
5062            event_log_path: PathBuf::from(".shipper/events.jsonl"),
5063            git_context: None,
5064            environment: environment::collect_environment_fingerprint(),
5065        };
5066
5067        let json = serde_json::to_string_pretty(&receipt).expect("serialize");
5068        let parsed: Receipt = serde_json::from_str(&json).expect("deserialize");
5069        assert_eq!(parsed.plan_id, receipt.plan_id);
5070        assert_eq!(parsed.receipt_version, receipt.receipt_version);
5071        assert_eq!(parsed.packages.len(), 2);
5072        assert!(matches!(parsed.packages[0].state, PackageState::Published));
5073        assert!(matches!(
5074            parsed.packages[1].state,
5075            PackageState::Skipped { .. }
5076        ));
5077        assert_eq!(parsed.packages[0].evidence.attempts.len(), 1);
5078        assert_eq!(parsed.packages[0].evidence.readiness_checks.len(), 1);
5079    }
5080
5081    #[test]
5082    fn execution_state_serialization_roundtrip() {
5083        let mut packages = BTreeMap::new();
5084        packages.insert(
5085            "demo@0.1.0".to_string(),
5086            PackageProgress {
5087                name: "demo".to_string(),
5088                version: "0.1.0".to_string(),
5089                attempts: 3,
5090                state: PackageState::Failed {
5091                    class: ErrorClass::Retryable,
5092                    message: "timeout".to_string(),
5093                },
5094                last_updated_at: Utc::now(),
5095            },
5096        );
5097        let st = ExecutionState {
5098            state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
5099            plan_id: "plan-serde-test".to_string(),
5100            registry: Registry {
5101                name: "crates-io".to_string(),
5102                api_base: "https://crates.io".to_string(),
5103                index_base: None,
5104            },
5105            created_at: Utc::now(),
5106            updated_at: Utc::now(),
5107            packages,
5108        };
5109
5110        let json = serde_json::to_string(&st).expect("serialize");
5111        let parsed: ExecutionState = serde_json::from_str(&json).expect("deserialize");
5112        assert_eq!(parsed.plan_id, st.plan_id);
5113        assert_eq!(parsed.packages.len(), 1);
5114        let pkg = parsed.packages.get("demo@0.1.0").expect("pkg");
5115        assert_eq!(pkg.attempts, 3);
5116        match &pkg.state {
5117            PackageState::Failed { class, message } => {
5118                assert_eq!(*class, ErrorClass::Retryable);
5119                assert_eq!(message, "timeout");
5120            }
5121            other => panic!("expected Failed, got {other:?}"),
5122        }
5123    }
5124
5125    #[test]
5126    fn collecting_reporter_tracks_all_message_types() {
5127        let mut reporter = CollectingReporter::default();
5128        reporter.info("info-1");
5129        reporter.info("info-2");
5130        reporter.warn("warn-1");
5131        reporter.error("error-1");
5132        reporter.error("error-2");
5133        reporter.error("error-3");
5134
5135        assert_eq!(reporter.infos.len(), 2);
5136        assert_eq!(reporter.warns.len(), 1);
5137        assert_eq!(reporter.errors.len(), 3);
5138        assert_eq!(reporter.infos[0], "info-1");
5139        assert_eq!(reporter.warns[0], "warn-1");
5140        assert_eq!(reporter.errors[2], "error-3");
5141    }
5142
5143    #[test]
5144    fn verify_published_disabled_does_single_check() {
5145        // When readiness is disabled, it still does one version_exists check
5146        let server = spawn_registry_server(
5147            std::collections::BTreeMap::from([(
5148                "/api/v1/crates/demo/0.1.0".to_string(),
5149                vec![(200, "{}".to_string())],
5150            )]),
5151            1,
5152        );
5153        let reg = RegistryClient::new(Registry {
5154            name: "crates-io".to_string(),
5155            api_base: server.base_url.clone(),
5156            index_base: None,
5157        })
5158        .expect("client");
5159
5160        let config = crate::types::ReadinessConfig {
5161            enabled: false,
5162            method: crate::types::ReadinessMethod::Api,
5163            initial_delay: Duration::from_millis(0),
5164            max_delay: Duration::from_millis(10),
5165            max_total_wait: Duration::from_millis(0),
5166            poll_interval: Duration::from_millis(1),
5167            jitter_factor: 0.0,
5168            index_path: None,
5169            prefer_index: false,
5170        };
5171
5172        let mut reporter = CollectingReporter::default();
5173        let (ok, evidence) =
5174            verify_published(&reg, "demo", "0.1.0", &config, &mut reporter).expect("verify");
5175        assert!(
5176            ok,
5177            "disabled readiness with 200 response should return true"
5178        );
5179        assert_eq!(
5180            evidence.len(),
5181            1,
5182            "disabled readiness does exactly one check"
5183        );
5184        assert!(evidence[0].visible);
5185        server.join();
5186    }
5187
5188    #[test]
5189    #[serial]
5190    fn run_publish_already_published_packages_skipped_in_state() {
5191        let td = tempdir().expect("tempdir");
5192        let bin = td.path().join("bin");
5193        write_fake_tools(&bin);
5194        let env_vars = fake_program_env_vars(&bin);
5195        temp_env::with_vars(env_vars, || {
5196            let server = spawn_registry_server(
5197                std::collections::BTreeMap::from([(
5198                    "/api/v1/crates/demo/0.1.0".to_string(),
5199                    vec![(200, "{}".to_string())],
5200                )]),
5201                1,
5202            );
5203            let ws = planned_workspace(td.path(), server.base_url.clone());
5204            let opts = default_opts(PathBuf::from(".shipper"));
5205
5206            let mut reporter = CollectingReporter::default();
5207            let _ = run_publish(&ws, &opts, &mut reporter).expect("publish");
5208
5209            // Verify state file reflects skipped
5210            let st = state::load_state(&td.path().join(".shipper"))
5211                .expect("load")
5212                .expect("exists");
5213            let pkg = st.packages.get("demo@0.1.0").expect("pkg");
5214            assert!(
5215                matches!(pkg.state, PackageState::Skipped { .. }),
5216                "expected Skipped in state, got {:?}",
5217                pkg.state
5218            );
5219            server.join();
5220        });
5221    }
5222
5223    #[test]
5224    fn policy_effects_default_options() {
5225        let opts = default_opts(PathBuf::from(".shipper"));
5226        let effects = policy_effects(&opts);
5227        // Default policy is Balanced, which runs dry-run but skips ownership by default
5228        // (skip_ownership_check=true in default_opts)
5229        assert!(effects.run_dry_run);
5230    }
5231
5232    // ── Retry logic edge-case tests ────────────────────────────────────
5233
5234    #[test]
5235    #[serial]
5236    fn run_publish_zero_max_attempts_skips_publish_loop() {
5237        let td = tempdir().expect("tempdir");
5238        let bin = td.path().join("bin");
5239        write_fake_tools(&bin);
5240        with_test_env(
5241            &bin,
5242            vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
5243            || {
5244                // Registry says version doesn't exist, so engine enters the publish loop
5245                // with max_attempts=0 → loop body never executes → package stays Pending
5246                let server = spawn_registry_server(
5247                    std::collections::BTreeMap::from([(
5248                        "/api/v1/crates/demo/0.1.0".to_string(),
5249                        vec![(404, "{}".to_string())],
5250                    )]),
5251                    1,
5252                );
5253                let ws = planned_workspace(td.path(), server.base_url.clone());
5254                let mut opts = default_opts(PathBuf::from(".shipper"));
5255                opts.max_attempts = 0;
5256
5257                let mut reporter = CollectingReporter::default();
5258                let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
5259
5260                // With 0 max_attempts the loop never runs; package stays Pending
5261                assert_eq!(receipt.packages.len(), 1);
5262                assert!(
5263                    matches!(receipt.packages[0].state, PackageState::Pending),
5264                    "expected Pending with 0 max_attempts, got {:?}",
5265                    receipt.packages[0].state
5266                );
5267
5268                let st = state::load_state(&td.path().join(".shipper"))
5269                    .expect("load")
5270                    .expect("exists");
5271                let pkg = st.packages.get("demo@0.1.0").expect("pkg");
5272                assert_eq!(pkg.attempts, 0, "no attempts should have been made");
5273                server.join();
5274            },
5275        );
5276    }
5277
5278    #[test]
5279    #[serial]
5280    fn run_publish_max_retries_exceeded_marks_failed() {
5281        let td = tempdir().expect("tempdir");
5282        let bin = td.path().join("bin");
5283        write_fake_tools(&bin);
5284        with_test_env(
5285            &bin,
5286            vec![
5287                ("SHIPPER_CARGO_EXIT", Some("1".to_string())),
5288                (
5289                    "SHIPPER_CARGO_STDERR",
5290                    Some("HTTP 503 service unavailable".to_string()),
5291                ),
5292            ],
5293            || {
5294                // 3 attempts * (version check + registry fallback) + final check = many 404s
5295                let server = spawn_registry_server(
5296                    std::collections::BTreeMap::from([(
5297                        "/api/v1/crates/demo/0.1.0".to_string(),
5298                        vec![
5299                            (404, "{}".to_string()),
5300                            (404, "{}".to_string()),
5301                            (404, "{}".to_string()),
5302                            (404, "{}".to_string()),
5303                            (404, "{}".to_string()),
5304                        ],
5305                    )]),
5306                    5,
5307                );
5308                let ws = planned_workspace(td.path(), server.base_url.clone());
5309                let mut opts = default_opts(PathBuf::from(".shipper"));
5310                opts.max_attempts = 3;
5311                opts.base_delay = Duration::from_millis(0);
5312                opts.max_delay = Duration::from_millis(0);
5313
5314                let mut reporter = CollectingReporter::default();
5315                let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
5316                assert!(format!("{err:#}").contains("failed"));
5317
5318                let st = state::load_state(&td.path().join(".shipper"))
5319                    .expect("load")
5320                    .expect("exists");
5321                let pkg = st.packages.get("demo@0.1.0").expect("pkg");
5322                assert_eq!(pkg.attempts, 3, "should have exhausted all 3 attempts");
5323                assert!(
5324                    matches!(pkg.state, PackageState::Failed { .. }),
5325                    "expected Failed, got {:?}",
5326                    pkg.state
5327                );
5328                server.join();
5329            },
5330        );
5331    }
5332
5333    #[test]
5334    #[serial]
5335    fn run_publish_single_attempt_succeeds_on_first_try() {
5336        let td = tempdir().expect("tempdir");
5337        let bin = td.path().join("bin");
5338        write_fake_tools(&bin);
5339        with_test_env(
5340            &bin,
5341            vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
5342            || {
5343                let server = spawn_registry_server(
5344                    std::collections::BTreeMap::from([(
5345                        "/api/v1/crates/demo/0.1.0".to_string(),
5346                        vec![(404, "{}".to_string()), (200, "{}".to_string())],
5347                    )]),
5348                    2,
5349                );
5350                let ws = planned_workspace(td.path(), server.base_url.clone());
5351                let mut opts = default_opts(PathBuf::from(".shipper"));
5352                opts.max_attempts = 1;
5353
5354                let mut reporter = CollectingReporter::default();
5355                let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
5356                assert_eq!(receipt.packages[0].attempts, 1);
5357                assert!(matches!(receipt.packages[0].state, PackageState::Published));
5358                server.join();
5359            },
5360        );
5361    }
5362
5363    // ── State persistence: Uploaded vs Published transitions ──────────
5364
5365    #[test]
5366    fn state_transition_pending_to_uploaded_persists() {
5367        let td = tempdir().expect("tempdir");
5368        let state_dir = td.path().join(".shipper");
5369        let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
5370
5371        let mut st = init_state(&ws, &state_dir).expect("init");
5372        let key = "demo@0.1.0";
5373
5374        update_state(&mut st, &state_dir, key, PackageState::Uploaded).expect("update");
5375
5376        // Verify in-memory
5377        assert!(matches!(
5378            st.packages.get(key).unwrap().state,
5379            PackageState::Uploaded
5380        ));
5381
5382        // Verify on disk
5383        let loaded = state::load_state(&state_dir)
5384            .expect("load")
5385            .expect("exists");
5386        assert!(matches!(
5387            loaded.packages.get(key).unwrap().state,
5388            PackageState::Uploaded
5389        ));
5390    }
5391
5392    #[test]
5393    fn state_transition_uploaded_to_published_persists() {
5394        let td = tempdir().expect("tempdir");
5395        let state_dir = td.path().join(".shipper");
5396        let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
5397
5398        let mut st = init_state(&ws, &state_dir).expect("init");
5399        let key = "demo@0.1.0";
5400
5401        // Pending -> Uploaded -> Published
5402        update_state(&mut st, &state_dir, key, PackageState::Uploaded).expect("upload");
5403        update_state(&mut st, &state_dir, key, PackageState::Published).expect("publish");
5404
5405        let loaded = state::load_state(&state_dir)
5406            .expect("load")
5407            .expect("exists");
5408        assert!(matches!(
5409            loaded.packages.get(key).unwrap().state,
5410            PackageState::Published
5411        ));
5412    }
5413
5414    #[test]
5415    fn state_transition_pending_to_failed_persists() {
5416        let td = tempdir().expect("tempdir");
5417        let state_dir = td.path().join(".shipper");
5418        let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
5419
5420        let mut st = init_state(&ws, &state_dir).expect("init");
5421        let key = "demo@0.1.0";
5422
5423        let failed = PackageState::Failed {
5424            class: ErrorClass::Retryable,
5425            message: "service unavailable".to_string(),
5426        };
5427        update_state(&mut st, &state_dir, key, failed).expect("update");
5428
5429        let loaded = state::load_state(&state_dir)
5430            .expect("load")
5431            .expect("exists");
5432        match &loaded.packages.get(key).unwrap().state {
5433            PackageState::Failed { class, message } => {
5434                assert_eq!(*class, ErrorClass::Retryable);
5435                assert_eq!(message, "service unavailable");
5436            }
5437            other => panic!("expected Failed, got {other:?}"),
5438        }
5439    }
5440
5441    #[test]
5442    fn state_updated_at_advances_on_transition() {
5443        let td = tempdir().expect("tempdir");
5444        let state_dir = td.path().join(".shipper");
5445        let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
5446
5447        let mut st = init_state(&ws, &state_dir).expect("init");
5448        let key = "demo@0.1.0";
5449        let initial_updated = st.updated_at;
5450
5451        // Small sleep to ensure time difference
5452        thread::sleep(Duration::from_millis(10));
5453
5454        update_state(&mut st, &state_dir, key, PackageState::Uploaded).expect("update");
5455        assert!(st.updated_at > initial_updated);
5456
5457        let pkg = st.packages.get(key).unwrap();
5458        assert!(pkg.last_updated_at >= initial_updated);
5459    }
5460
5461    // ── Error classification tests ─────────────────────────────────────
5462
5463    #[test]
5464    fn classify_cargo_failure_connection_refused_is_retryable() {
5465        let (class, _) = classify_cargo_failure("connection refused", "");
5466        assert_eq!(class, ErrorClass::Retryable);
5467    }
5468
5469    #[test]
5470    fn classify_cargo_failure_500_is_retryable() {
5471        let (class, _) = classify_cargo_failure("HTTP 500 internal server error", "");
5472        assert_eq!(class, ErrorClass::Retryable);
5473    }
5474
5475    #[test]
5476    fn classify_cargo_failure_version_exists_is_permanent() {
5477        let (class, _) = classify_cargo_failure("crate version `0.1.0` is already uploaded", "");
5478        assert_eq!(class, ErrorClass::Permanent);
5479    }
5480
5481    #[test]
5482    fn classify_cargo_failure_empty_output_is_ambiguous() {
5483        let (class, _) = classify_cargo_failure("", "");
5484        assert_eq!(class, ErrorClass::Ambiguous);
5485    }
5486
5487    #[test]
5488    fn classify_cargo_failure_message_is_nonempty() {
5489        let (_, msg) = classify_cargo_failure("timeout talking to server", "");
5490        assert!(!msg.is_empty(), "error message should be nonempty");
5491    }
5492
5493    #[test]
5494    fn classify_cargo_failure_stderr_vs_stdout() {
5495        // Some errors appear in stdout, some in stderr
5496        let (class_stderr, _) = classify_cargo_failure("HTTP 429 too many requests", "");
5497        let (class_stdout, _) = classify_cargo_failure("", "HTTP 429 too many requests");
5498        assert_eq!(class_stderr, ErrorClass::Retryable);
5499        // stdout-only message should also be classified
5500        assert!(
5501            class_stdout == ErrorClass::Retryable || class_stdout == ErrorClass::Ambiguous,
5502            "expected Retryable or Ambiguous from stdout, got {class_stdout:?}"
5503        );
5504    }
5505
5506    // ── State machine transition tests ────────────────────────────────
5507
5508    /// Helper: build a multi-package workspace for state machine tests.
5509    fn multi_package_workspace(
5510        workspace_root: &Path,
5511        api_base: String,
5512        packages: Vec<(&str, &str)>,
5513    ) -> PlannedWorkspace {
5514        PlannedWorkspace {
5515            workspace_root: workspace_root.to_path_buf(),
5516            plan: ReleasePlan {
5517                plan_version: "1".to_string(),
5518                plan_id: "plan-sm-test".to_string(),
5519                created_at: Utc::now(),
5520                registry: Registry {
5521                    name: "crates-io".to_string(),
5522                    api_base,
5523                    index_base: None,
5524                },
5525                packages: packages
5526                    .iter()
5527                    .map(|(name, ver)| PlannedPackage {
5528                        name: name.to_string(),
5529                        version: ver.to_string(),
5530                        manifest_path: workspace_root.join(*name).join("Cargo.toml"),
5531                    })
5532                    .collect(),
5533                dependencies: std::collections::BTreeMap::new(),
5534            },
5535            skipped: vec![],
5536        }
5537    }
5538
5539    // 1. Pending → Published (happy path, single crate)
5540    #[test]
5541    #[serial]
5542    fn sm_pending_to_published_happy_path() {
5543        let td = tempdir().expect("tempdir");
5544        let bin = td.path().join("bin");
5545        write_fake_tools(&bin);
5546        with_test_env(
5547            &bin,
5548            vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
5549            || {
5550                let server = spawn_registry_server(
5551                    std::collections::BTreeMap::from([(
5552                        "/api/v1/crates/demo/0.1.0".to_string(),
5553                        vec![(404, "{}".to_string()), (200, "{}".to_string())],
5554                    )]),
5555                    2,
5556                );
5557                let ws = planned_workspace(td.path(), server.base_url.clone());
5558                let opts = default_opts(PathBuf::from(".shipper"));
5559
5560                let mut reporter = CollectingReporter::default();
5561                let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
5562
5563                assert_eq!(receipt.packages.len(), 1);
5564                assert!(matches!(receipt.packages[0].state, PackageState::Published));
5565                assert_eq!(receipt.packages[0].attempts, 1);
5566
5567                // Verify state on disk matches
5568                let st = state::load_state(&td.path().join(".shipper"))
5569                    .expect("load")
5570                    .expect("exists");
5571                let pkg = st.packages.get("demo@0.1.0").expect("pkg");
5572                assert!(matches!(pkg.state, PackageState::Published));
5573                server.join();
5574            },
5575        );
5576    }
5577
5578    // 2. Pending → Uploaded → Verified (two-phase with readiness check)
5579    #[test]
5580    #[serial]
5581    fn sm_pending_uploaded_verified_two_phase() {
5582        let td = tempdir().expect("tempdir");
5583        let bin = td.path().join("bin");
5584        write_fake_tools(&bin);
5585        with_test_env(
5586            &bin,
5587            vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
5588            || {
5589                // First 404 = not yet published, second 200 = visible after readiness
5590                let server = spawn_registry_server(
5591                    std::collections::BTreeMap::from([(
5592                        "/api/v1/crates/demo/0.1.0".to_string(),
5593                        vec![(404, "{}".to_string()), (200, "{}".to_string())],
5594                    )]),
5595                    2,
5596                );
5597                let ws = planned_workspace(td.path(), server.base_url.clone());
5598                let opts = default_opts(PathBuf::from(".shipper"));
5599
5600                let mut reporter = CollectingReporter::default();
5601                let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
5602
5603                assert!(matches!(receipt.packages[0].state, PackageState::Published));
5604                // Verify the publish-then-verify flow happened
5605                assert!(reporter.infos.iter().any(|i| i.contains("publishing")));
5606                assert!(
5607                    reporter
5608                        .infos
5609                        .iter()
5610                        .any(|i| i.contains("verifying") || i.contains("visible"))
5611                );
5612                server.join();
5613            },
5614        );
5615    }
5616
5617    // 3. Pending → Failed (permanent error, no retry)
5618    #[test]
5619    #[serial]
5620    fn sm_pending_to_failed_permanent_no_retry() {
5621        let td = tempdir().expect("tempdir");
5622        let bin = td.path().join("bin");
5623        write_fake_tools(&bin);
5624        with_test_env(
5625            &bin,
5626            vec![
5627                ("SHIPPER_CARGO_EXIT", Some("1".to_string())),
5628                (
5629                    "SHIPPER_CARGO_STDERR",
5630                    Some("permission denied".to_string()),
5631                ),
5632            ],
5633            || {
5634                let server = spawn_registry_server(
5635                    std::collections::BTreeMap::from([(
5636                        "/api/v1/crates/demo/0.1.0".to_string(),
5637                        vec![(404, "{}".to_string()), (404, "{}".to_string())],
5638                    )]),
5639                    2,
5640                );
5641                let ws = planned_workspace(td.path(), server.base_url.clone());
5642                let mut opts = default_opts(PathBuf::from(".shipper"));
5643                opts.max_attempts = 5; // should not retry on permanent error
5644                opts.base_delay = Duration::from_millis(0);
5645                opts.max_delay = Duration::from_millis(0);
5646
5647                let mut reporter = CollectingReporter::default();
5648                let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
5649                assert!(format!("{err:#}").contains("permanent failure"));
5650
5651                let st = state::load_state(&td.path().join(".shipper"))
5652                    .expect("load")
5653                    .expect("exists");
5654                let pkg = st.packages.get("demo@0.1.0").expect("pkg");
5655                // Should have only attempted once (permanent = no retry)
5656                assert_eq!(pkg.attempts, 1);
5657                assert!(matches!(
5658                    pkg.state,
5659                    PackageState::Failed {
5660                        class: ErrorClass::Permanent,
5661                        ..
5662                    }
5663                ));
5664                server.join();
5665            },
5666        );
5667    }
5668
5669    // 4. Pending → Uploaded → Failed (upload ok, verify failed)
5670    #[test]
5671    #[serial]
5672    fn sm_uploaded_then_verify_failed() {
5673        let td = tempdir().expect("tempdir");
5674        let bin = td.path().join("bin");
5675        write_fake_tools(&bin);
5676        with_test_env(
5677            &bin,
5678            vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
5679            || {
5680                // cargo succeeds, but registry never shows the version
5681                let server = spawn_registry_server(
5682                    std::collections::BTreeMap::from([(
5683                        "/api/v1/crates/demo/0.1.0".to_string(),
5684                        vec![
5685                            (404, "{}".to_string()), // initial version_exists
5686                            (404, "{}".to_string()), // readiness check
5687                            (404, "{}".to_string()), // final chance
5688                        ],
5689                    )]),
5690                    3,
5691                );
5692                let ws = planned_workspace(td.path(), server.base_url.clone());
5693                let mut opts = default_opts(PathBuf::from(".shipper"));
5694                opts.max_attempts = 1;
5695                opts.readiness.max_total_wait = Duration::from_millis(0);
5696
5697                let mut reporter = CollectingReporter::default();
5698                let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
5699                assert!(format!("{err:#}").contains("failed"));
5700
5701                let st = state::load_state(&td.path().join(".shipper"))
5702                    .expect("load")
5703                    .expect("exists");
5704                let pkg = st.packages.get("demo@0.1.0").expect("pkg");
5705                assert!(matches!(pkg.state, PackageState::Failed { .. }));
5706                server.join();
5707            },
5708        );
5709    }
5710
5711    // 5. Multiple packages: first succeeds, second fails → partial progress saved
5712    #[test]
5713    #[serial]
5714    fn sm_multi_package_partial_progress() {
5715        let td = tempdir().expect("tempdir");
5716        let bin = td.path().join("bin");
5717        write_fake_tools(&bin);
5718        with_test_env(
5719            &bin,
5720            vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
5721            || {
5722                // alpha: 404 then 200 (publishes ok)
5723                // beta: 404 then always 404 (readiness never passes)
5724                let server = spawn_registry_server(
5725                    std::collections::BTreeMap::from([
5726                        (
5727                            "/api/v1/crates/alpha/1.0.0".to_string(),
5728                            vec![(404, "{}".to_string()), (200, "{}".to_string())],
5729                        ),
5730                        (
5731                            "/api/v1/crates/beta/2.0.0".to_string(),
5732                            vec![
5733                                (404, "{}".to_string()), // initial
5734                                (404, "{}".to_string()), // readiness
5735                                (404, "{}".to_string()), // final
5736                            ],
5737                        ),
5738                    ]),
5739                    5,
5740                );
5741                let ws = multi_package_workspace(
5742                    td.path(),
5743                    server.base_url.clone(),
5744                    vec![("alpha", "1.0.0"), ("beta", "2.0.0")],
5745                );
5746                let mut opts = default_opts(PathBuf::from(".shipper"));
5747                opts.max_attempts = 1;
5748                opts.readiness.max_total_wait = Duration::from_millis(0);
5749
5750                let mut reporter = CollectingReporter::default();
5751                let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
5752                assert!(format!("{err:#}").contains("beta"));
5753
5754                // Verify partial progress saved: alpha is Published
5755                let st = state::load_state(&td.path().join(".shipper"))
5756                    .expect("load")
5757                    .expect("exists");
5758                let alpha = st.packages.get("alpha@1.0.0").expect("alpha");
5759                assert!(
5760                    matches!(alpha.state, PackageState::Published),
5761                    "alpha should be Published, got {:?}",
5762                    alpha.state
5763                );
5764                let beta = st.packages.get("beta@2.0.0").expect("beta");
5765                assert!(
5766                    matches!(beta.state, PackageState::Failed { .. }),
5767                    "beta should be Failed, got {:?}",
5768                    beta.state
5769                );
5770                server.join();
5771            },
5772        );
5773    }
5774
5775    // 6. Resume from partial state — only remaining packages are attempted
5776    #[test]
5777    #[serial]
5778    fn sm_resume_from_partial_state() {
5779        let td = tempdir().expect("tempdir");
5780        let bin = td.path().join("bin");
5781        write_fake_tools(&bin);
5782        let args_log = td.path().join("cargo_args.txt");
5783        with_test_env(
5784            &bin,
5785            vec![
5786                ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
5787                (
5788                    "SHIPPER_CARGO_ARGS_LOG",
5789                    Some(args_log.to_str().expect("utf8").to_string()),
5790                ),
5791            ],
5792            || {
5793                // beta: 404 then 200 (publishes ok)
5794                let server = spawn_registry_server(
5795                    std::collections::BTreeMap::from([(
5796                        "/api/v1/crates/beta/2.0.0".to_string(),
5797                        vec![(404, "{}".to_string()), (200, "{}".to_string())],
5798                    )]),
5799                    2,
5800                );
5801                let ws = multi_package_workspace(
5802                    td.path(),
5803                    server.base_url.clone(),
5804                    vec![("alpha", "1.0.0"), ("beta", "2.0.0")],
5805                );
5806                let state_dir = td.path().join(".shipper");
5807
5808                // Pre-create state: alpha already Published
5809                let mut packages = std::collections::BTreeMap::new();
5810                packages.insert(
5811                    "alpha@1.0.0".to_string(),
5812                    PackageProgress {
5813                        name: "alpha".to_string(),
5814                        version: "1.0.0".to_string(),
5815                        attempts: 1,
5816                        state: PackageState::Published,
5817                        last_updated_at: Utc::now(),
5818                    },
5819                );
5820                packages.insert(
5821                    "beta@2.0.0".to_string(),
5822                    PackageProgress {
5823                        name: "beta".to_string(),
5824                        version: "2.0.0".to_string(),
5825                        attempts: 0,
5826                        state: PackageState::Pending,
5827                        last_updated_at: Utc::now(),
5828                    },
5829                );
5830                let st = ExecutionState {
5831                    state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
5832                    plan_id: ws.plan.plan_id.clone(),
5833                    registry: ws.plan.registry.clone(),
5834                    created_at: Utc::now(),
5835                    updated_at: Utc::now(),
5836                    packages,
5837                };
5838                state::save_state(&state_dir, &st).expect("save");
5839
5840                let opts = default_opts(PathBuf::from(".shipper"));
5841                let mut reporter = CollectingReporter::default();
5842                let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
5843
5844                // alpha should be skipped (already complete)
5845                assert!(
5846                    reporter
5847                        .infos
5848                        .iter()
5849                        .any(|i| i.contains("alpha") && i.contains("already complete"))
5850                );
5851
5852                // beta should be published
5853                assert_eq!(receipt.packages.len(), 1);
5854                assert_eq!(receipt.packages[0].name, "beta");
5855                assert!(matches!(receipt.packages[0].state, PackageState::Published));
5856
5857                // cargo publish should only have been called for beta
5858                let log = fs::read_to_string(&args_log).unwrap_or_default();
5859                assert!(
5860                    !log.contains("alpha"),
5861                    "alpha should not have been published"
5862                );
5863                assert!(log.contains("beta"), "beta should have been published");
5864
5865                server.join();
5866            },
5867        );
5868    }
5869
5870    // 7. Plan ID mismatch on resume — verify rejection
5871    #[test]
5872    fn sm_plan_id_mismatch_rejected() {
5873        let td = tempdir().expect("tempdir");
5874        let ws = multi_package_workspace(
5875            td.path(),
5876            "http://127.0.0.1:9".to_string(),
5877            vec![("demo", "0.1.0")],
5878        );
5879        let state_dir = td.path().join(".shipper");
5880
5881        let mut packages = std::collections::BTreeMap::new();
5882        packages.insert(
5883            "demo@0.1.0".to_string(),
5884            PackageProgress {
5885                name: "demo".to_string(),
5886                version: "0.1.0".to_string(),
5887                attempts: 0,
5888                state: PackageState::Pending,
5889                last_updated_at: Utc::now(),
5890            },
5891        );
5892        let st = ExecutionState {
5893            state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
5894            plan_id: "completely-different-plan".to_string(),
5895            registry: ws.plan.registry.clone(),
5896            created_at: Utc::now(),
5897            updated_at: Utc::now(),
5898            packages,
5899        };
5900        state::save_state(&state_dir, &st).expect("save");
5901
5902        let opts = default_opts(PathBuf::from(".shipper"));
5903        let mut reporter = CollectingReporter::default();
5904        let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
5905        let msg = format!("{err:#}");
5906        assert!(msg.contains("does not match current plan_id"), "got: {msg}");
5907    }
5908
5909    // 8. Empty package list — verify graceful handling
5910    #[test]
5911    fn sm_empty_package_list_graceful() {
5912        let td = tempdir().expect("tempdir");
5913        let ws = multi_package_workspace(
5914            td.path(),
5915            "http://127.0.0.1:9".to_string(),
5916            vec![], // no packages
5917        );
5918        let opts = default_opts(PathBuf::from(".shipper"));
5919
5920        let mut reporter = CollectingReporter::default();
5921        let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
5922        assert!(receipt.packages.is_empty());
5923    }
5924
5925    // 9. Dry-run mode: cargo publishes but readiness check prevents registering
5926    //    We test that when no_verify is set, the publish still proceeds normally.
5927    //    (The engine doesn't have an explicit dry-run mode separate from no_verify;
5928    //    "dry-run" is a preflight concept. We test that no_verify flag is respected.)
5929    #[test]
5930    #[serial]
5931    fn sm_no_verify_flag_respected() {
5932        let td = tempdir().expect("tempdir");
5933        let bin = td.path().join("bin");
5934        write_fake_tools(&bin);
5935        let args_log = td.path().join("cargo_args.txt");
5936        with_test_env(
5937            &bin,
5938            vec![
5939                ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
5940                (
5941                    "SHIPPER_CARGO_ARGS_LOG",
5942                    Some(args_log.to_str().expect("utf8").to_string()),
5943                ),
5944            ],
5945            || {
5946                let server = spawn_registry_server(
5947                    std::collections::BTreeMap::from([(
5948                        "/api/v1/crates/demo/0.1.0".to_string(),
5949                        vec![(404, "{}".to_string()), (200, "{}".to_string())],
5950                    )]),
5951                    2,
5952                );
5953                let ws = planned_workspace(td.path(), server.base_url.clone());
5954                let mut opts = default_opts(PathBuf::from(".shipper"));
5955                opts.no_verify = true;
5956
5957                let mut reporter = CollectingReporter::default();
5958                let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
5959                assert!(matches!(receipt.packages[0].state, PackageState::Published));
5960
5961                // Verify cargo was called with the right flags
5962                let log = fs::read_to_string(&args_log).unwrap_or_default();
5963                assert!(
5964                    log.contains("publish"),
5965                    "cargo publish should have been called"
5966                );
5967                server.join();
5968            },
5969        );
5970    }
5971
5972    // 10. Max retries exceeded — verify proper error with attempt count
5973    #[test]
5974    #[serial]
5975    fn sm_max_retries_exceeded_attempt_count() {
5976        let td = tempdir().expect("tempdir");
5977        let bin = td.path().join("bin");
5978        write_fake_tools(&bin);
5979        with_test_env(
5980            &bin,
5981            vec![
5982                ("SHIPPER_CARGO_EXIT", Some("1".to_string())),
5983                (
5984                    "SHIPPER_CARGO_STDERR",
5985                    Some("timeout talking to server".to_string()),
5986                ),
5987            ],
5988            || {
5989                let server = spawn_registry_server(
5990                    std::collections::BTreeMap::from([(
5991                        "/api/v1/crates/demo/0.1.0".to_string(),
5992                        vec![
5993                            (404, "{}".to_string()),
5994                            (404, "{}".to_string()),
5995                            (404, "{}".to_string()),
5996                            (404, "{}".to_string()),
5997                            (404, "{}".to_string()),
5998                            (404, "{}".to_string()),
5999                            (404, "{}".to_string()),
6000                        ],
6001                    )]),
6002                    7,
6003                );
6004                let ws = planned_workspace(td.path(), server.base_url.clone());
6005                let mut opts = default_opts(PathBuf::from(".shipper"));
6006                opts.max_attempts = 3;
6007                opts.base_delay = Duration::from_millis(0);
6008                opts.max_delay = Duration::from_millis(0);
6009
6010                let mut reporter = CollectingReporter::default();
6011                let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
6012                assert!(format!("{err:#}").contains("failed"));
6013
6014                let st = state::load_state(&td.path().join(".shipper"))
6015                    .expect("load")
6016                    .expect("exists");
6017                let pkg = st.packages.get("demo@0.1.0").expect("pkg");
6018                assert_eq!(pkg.attempts, 3, "should have made exactly 3 attempts");
6019                assert!(matches!(pkg.state, PackageState::Failed { .. }));
6020
6021                // Verify reporter logged all retry attempts
6022                let attempt_msgs: Vec<_> = reporter
6023                    .infos
6024                    .iter()
6025                    .filter(|i| i.contains("attempt"))
6026                    .collect();
6027                assert!(
6028                    attempt_msgs.len() >= 3,
6029                    "expected at least 3 attempt messages, got {}: {:?}",
6030                    attempt_msgs.len(),
6031                    attempt_msgs
6032                );
6033                server.join();
6034            },
6035        );
6036    }
6037
6038    // 11. Timeout during publish — verify state preservation
6039    #[test]
6040    #[serial]
6041    fn sm_timeout_preserves_state() {
6042        let td = tempdir().expect("tempdir");
6043        let bin = td.path().join("bin");
6044        write_fake_tools(&bin);
6045        with_test_env(
6046            &bin,
6047            vec![
6048                ("SHIPPER_CARGO_EXIT", Some("1".to_string())),
6049                (
6050                    "SHIPPER_CARGO_STDERR",
6051                    Some("timeout while uploading".to_string()),
6052                ),
6053            ],
6054            || {
6055                // cargo fails with timeout, but registry shows the version exists
6056                let server = spawn_registry_server(
6057                    std::collections::BTreeMap::from([(
6058                        "/api/v1/crates/demo/0.1.0".to_string(),
6059                        vec![(404, "{}".to_string()), (200, "{}".to_string())],
6060                    )]),
6061                    2,
6062                );
6063                let ws = planned_workspace(td.path(), server.base_url.clone());
6064                let mut opts = default_opts(PathBuf::from(".shipper"));
6065                opts.base_delay = Duration::from_millis(0);
6066                opts.max_delay = Duration::from_millis(0);
6067
6068                let mut reporter = CollectingReporter::default();
6069                let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
6070
6071                // Even though cargo reported failure, registry check found the version
6072                assert!(
6073                    matches!(receipt.packages[0].state, PackageState::Published),
6074                    "expected Published after timeout recovery, got {:?}",
6075                    receipt.packages[0].state
6076                );
6077
6078                // State file on disk should also reflect Published
6079                let st = state::load_state(&td.path().join(".shipper"))
6080                    .expect("load")
6081                    .expect("exists");
6082                let pkg = st.packages.get("demo@0.1.0").expect("pkg");
6083                assert!(matches!(pkg.state, PackageState::Published));
6084                server.join();
6085            },
6086        );
6087    }
6088
6089    // 12. Concurrent package independence — parallel packages don't affect each other
6090    //     (sequential mode: verify each package state is independent)
6091    #[test]
6092    #[serial]
6093    fn sm_package_independence_sequential() {
6094        let td = tempdir().expect("tempdir");
6095        let bin = td.path().join("bin");
6096        write_fake_tools(&bin);
6097        with_test_env(
6098            &bin,
6099            vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
6100            || {
6101                let server = spawn_registry_server(
6102                    std::collections::BTreeMap::from([
6103                        (
6104                            "/api/v1/crates/alpha/1.0.0".to_string(),
6105                            vec![(200, "{}".to_string())], // already published
6106                        ),
6107                        (
6108                            "/api/v1/crates/beta/2.0.0".to_string(),
6109                            vec![(404, "{}".to_string()), (200, "{}".to_string())],
6110                        ),
6111                        (
6112                            "/api/v1/crates/gamma/3.0.0".to_string(),
6113                            vec![(200, "{}".to_string())], // already published
6114                        ),
6115                    ]),
6116                    4,
6117                );
6118                let ws = multi_package_workspace(
6119                    td.path(),
6120                    server.base_url.clone(),
6121                    vec![("alpha", "1.0.0"), ("beta", "2.0.0"), ("gamma", "3.0.0")],
6122                );
6123                let opts = default_opts(PathBuf::from(".shipper"));
6124
6125                let mut reporter = CollectingReporter::default();
6126                let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
6127
6128                assert_eq!(receipt.packages.len(), 3);
6129                // alpha: skipped (already published)
6130                assert!(
6131                    matches!(receipt.packages[0].state, PackageState::Skipped { .. }),
6132                    "alpha should be Skipped, got {:?}",
6133                    receipt.packages[0].state
6134                );
6135                // beta: published
6136                assert!(
6137                    matches!(receipt.packages[1].state, PackageState::Published),
6138                    "beta should be Published, got {:?}",
6139                    receipt.packages[1].state
6140                );
6141                // gamma: skipped (already published)
6142                assert!(
6143                    matches!(receipt.packages[2].state, PackageState::Skipped { .. }),
6144                    "gamma should be Skipped, got {:?}",
6145                    receipt.packages[2].state
6146                );
6147                server.join();
6148            },
6149        );
6150    }
6151
6152    // 13. Resume from Uploaded state skips cargo publish, goes straight to verify
6153    #[test]
6154    #[serial]
6155    fn sm_resume_from_uploaded_skips_cargo() {
6156        let td = tempdir().expect("tempdir");
6157        let bin = td.path().join("bin");
6158        write_fake_tools(&bin);
6159        let args_log = td.path().join("cargo_args.txt");
6160        with_test_env(
6161            &bin,
6162            vec![
6163                ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
6164                (
6165                    "SHIPPER_CARGO_ARGS_LOG",
6166                    Some(args_log.to_str().expect("utf8").to_string()),
6167                ),
6168            ],
6169            || {
6170                let server = spawn_registry_server(
6171                    std::collections::BTreeMap::from([(
6172                        "/api/v1/crates/demo/0.1.0".to_string(),
6173                        vec![(404, "{}".to_string()), (200, "{}".to_string())],
6174                    )]),
6175                    2,
6176                );
6177                let ws = planned_workspace(td.path(), server.base_url.clone());
6178                let state_dir = td.path().join(".shipper");
6179
6180                // Pre-create state with Uploaded
6181                let mut packages = std::collections::BTreeMap::new();
6182                packages.insert(
6183                    "demo@0.1.0".to_string(),
6184                    PackageProgress {
6185                        name: "demo".to_string(),
6186                        version: "0.1.0".to_string(),
6187                        attempts: 1,
6188                        state: PackageState::Uploaded,
6189                        last_updated_at: Utc::now(),
6190                    },
6191                );
6192                let st = ExecutionState {
6193                    state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
6194                    plan_id: ws.plan.plan_id.clone(),
6195                    registry: ws.plan.registry.clone(),
6196                    created_at: Utc::now(),
6197                    updated_at: Utc::now(),
6198                    packages,
6199                };
6200                state::save_state(&state_dir, &st).expect("save");
6201
6202                let opts = default_opts(PathBuf::from(".shipper"));
6203                let mut reporter = CollectingReporter::default();
6204                let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
6205
6206                assert!(matches!(receipt.packages[0].state, PackageState::Published));
6207                assert!(
6208                    reporter
6209                        .infos
6210                        .iter()
6211                        .any(|i| i.contains("resuming from uploaded"))
6212                );
6213
6214                // cargo publish should NOT have been called
6215                let cargo_called = args_log.exists()
6216                    && fs::read_to_string(&args_log)
6217                        .unwrap_or_default()
6218                        .contains("publish");
6219                assert!(
6220                    !cargo_called,
6221                    "cargo publish should not run on resume from Uploaded"
6222                );
6223                server.join();
6224            },
6225        );
6226    }
6227
6228    // 14. Failed package produces correct event log entries
6229    #[test]
6230    #[serial]
6231    fn sm_failed_package_event_log() {
6232        let td = tempdir().expect("tempdir");
6233        let bin = td.path().join("bin");
6234        write_fake_tools(&bin);
6235        with_test_env(
6236            &bin,
6237            vec![
6238                ("SHIPPER_CARGO_EXIT", Some("1".to_string())),
6239                (
6240                    "SHIPPER_CARGO_STDERR",
6241                    Some("permission denied".to_string()),
6242                ),
6243            ],
6244            || {
6245                let server = spawn_registry_server(
6246                    std::collections::BTreeMap::from([(
6247                        "/api/v1/crates/demo/0.1.0".to_string(),
6248                        vec![(404, "{}".to_string()), (404, "{}".to_string())],
6249                    )]),
6250                    2,
6251                );
6252                let ws = planned_workspace(td.path(), server.base_url.clone());
6253                let opts = default_opts(PathBuf::from(".shipper"));
6254
6255                let mut reporter = CollectingReporter::default();
6256                let _ = run_publish(&ws, &opts, &mut reporter);
6257
6258                let events_path = td.path().join(".shipper").join("events.jsonl");
6259                let log = crate::state::events::EventLog::read_from_file(&events_path)
6260                    .expect("read events");
6261                let events = log.all_events();
6262
6263                // Must have: ExecutionStarted, PlanCreated, PackageStarted, PackageAttempted, PackageFailed
6264                assert!(
6265                    events
6266                        .iter()
6267                        .any(|e| matches!(e.event_type, EventType::ExecutionStarted))
6268                );
6269                assert!(
6270                    events
6271                        .iter()
6272                        .any(|e| matches!(e.event_type, EventType::PlanCreated { .. }))
6273                );
6274                assert!(
6275                    events
6276                        .iter()
6277                        .any(|e| matches!(e.event_type, EventType::PackageStarted { .. }))
6278                );
6279                assert!(
6280                    events
6281                        .iter()
6282                        .any(|e| matches!(e.event_type, EventType::PackageAttempted { .. }))
6283                );
6284                assert!(
6285                    events
6286                        .iter()
6287                        .any(|e| matches!(e.event_type, EventType::PackageFailed { .. }))
6288                );
6289                server.join();
6290            },
6291        );
6292    }
6293
6294    // 15. State file persists across transitions with correct version
6295    #[test]
6296    fn sm_state_version_preserved_through_transitions() {
6297        let td = tempdir().expect("tempdir");
6298        let state_dir = td.path().join(".shipper");
6299        let ws = multi_package_workspace(
6300            td.path(),
6301            "http://127.0.0.1:9".to_string(),
6302            vec![("alpha", "1.0.0"), ("beta", "2.0.0")],
6303        );
6304
6305        let mut st = init_state(&ws, &state_dir).expect("init");
6306        assert_eq!(
6307            st.state_version,
6308            crate::state::execution_state::CURRENT_STATE_VERSION
6309        );
6310
6311        // Transition alpha: Pending → Uploaded → Published
6312        update_state(&mut st, &state_dir, "alpha@1.0.0", PackageState::Uploaded).expect("update");
6313        update_state(&mut st, &state_dir, "alpha@1.0.0", PackageState::Published).expect("update");
6314
6315        // Transition beta: Pending → Failed
6316        update_state(
6317            &mut st,
6318            &state_dir,
6319            "beta@2.0.0",
6320            PackageState::Failed {
6321                class: ErrorClass::Permanent,
6322                message: "denied".to_string(),
6323            },
6324        )
6325        .expect("update");
6326
6327        let loaded = state::load_state(&state_dir)
6328            .expect("load")
6329            .expect("exists");
6330        assert_eq!(
6331            loaded.state_version,
6332            crate::state::execution_state::CURRENT_STATE_VERSION
6333        );
6334        assert!(matches!(
6335            loaded.packages.get("alpha@1.0.0").unwrap().state,
6336            PackageState::Published
6337        ));
6338        assert!(matches!(
6339            loaded.packages.get("beta@2.0.0").unwrap().state,
6340            PackageState::Failed { .. }
6341        ));
6342    }
6343
6344    // 16. Snapshot: receipt after successful multi-package publish
6345    #[test]
6346    #[serial]
6347    fn sm_snapshot_receipt_multi_package() {
6348        let td = tempdir().expect("tempdir");
6349        let bin = td.path().join("bin");
6350        write_fake_tools(&bin);
6351        with_test_env(
6352            &bin,
6353            vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
6354            || {
6355                let server = spawn_registry_server(
6356                    std::collections::BTreeMap::from([
6357                        (
6358                            "/api/v1/crates/alpha/1.0.0".to_string(),
6359                            vec![(404, "{}".to_string()), (200, "{}".to_string())],
6360                        ),
6361                        (
6362                            "/api/v1/crates/beta/2.0.0".to_string(),
6363                            vec![(200, "{}".to_string())],
6364                        ),
6365                    ]),
6366                    3,
6367                );
6368                let ws = multi_package_workspace(
6369                    td.path(),
6370                    server.base_url.clone(),
6371                    vec![("alpha", "1.0.0"), ("beta", "2.0.0")],
6372                );
6373                let opts = default_opts(PathBuf::from(".shipper"));
6374
6375                let mut reporter = CollectingReporter::default();
6376                let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
6377
6378                // Snapshot the package states (redact timestamps)
6379                let snapshot: Vec<String> = receipt
6380                    .packages
6381                    .iter()
6382                    .map(|p| {
6383                        format!(
6384                            "name={} version={} attempts={} state={}",
6385                            p.name,
6386                            p.version,
6387                            p.attempts,
6388                            short_state(&p.state)
6389                        )
6390                    })
6391                    .collect();
6392                insta::assert_debug_snapshot!("sm_receipt_multi_package", snapshot);
6393                server.join();
6394            },
6395        );
6396    }
6397
6398    // 17. Snapshot: state after partial failure
6399    #[test]
6400    fn sm_snapshot_state_partial_failure() {
6401        let td = tempdir().expect("tempdir");
6402        let state_dir = td.path().join(".shipper");
6403        let ws = multi_package_workspace(
6404            td.path(),
6405            "http://127.0.0.1:9".to_string(),
6406            vec![("alpha", "1.0.0"), ("beta", "2.0.0"), ("gamma", "3.0.0")],
6407        );
6408
6409        let mut st = init_state(&ws, &state_dir).expect("init");
6410
6411        // alpha: Published, beta: Uploaded (in-progress), gamma: still Pending
6412        update_state(&mut st, &state_dir, "alpha@1.0.0", PackageState::Published).expect("update");
6413        update_state(&mut st, &state_dir, "beta@2.0.0", PackageState::Uploaded).expect("update");
6414
6415        let snapshot: Vec<String> = st
6416            .packages
6417            .iter()
6418            .map(|(k, v)| {
6419                format!(
6420                    "key={} attempts={} state={}",
6421                    k,
6422                    v.attempts,
6423                    short_state(&v.state)
6424                )
6425            })
6426            .collect();
6427        insta::assert_debug_snapshot!("sm_state_partial_failure", snapshot);
6428    }
6429
6430    // 18. Force resume with plan mismatch proceeds with warning
6431    #[test]
6432    fn sm_force_resume_with_mismatch() {
6433        let td = tempdir().expect("tempdir");
6434        let ws = multi_package_workspace(
6435            td.path(),
6436            "http://127.0.0.1:9".to_string(),
6437            vec![("demo", "0.1.0")],
6438        );
6439        let state_dir = td.path().join(".shipper");
6440
6441        // Create state with different plan_id but all packages Published
6442        let mut packages = std::collections::BTreeMap::new();
6443        packages.insert(
6444            "demo@0.1.0".to_string(),
6445            PackageProgress {
6446                name: "demo".to_string(),
6447                version: "0.1.0".to_string(),
6448                attempts: 1,
6449                state: PackageState::Published,
6450                last_updated_at: Utc::now(),
6451            },
6452        );
6453        let st = ExecutionState {
6454            state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
6455            plan_id: "old-plan-id".to_string(),
6456            registry: ws.plan.registry.clone(),
6457            created_at: Utc::now(),
6458            updated_at: Utc::now(),
6459            packages,
6460        };
6461        state::save_state(&state_dir, &st).expect("save");
6462
6463        let mut opts = default_opts(PathBuf::from(".shipper"));
6464        opts.force_resume = true;
6465
6466        let mut reporter = CollectingReporter::default();
6467        let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
6468        assert!(receipt.packages.is_empty()); // already complete
6469        assert!(
6470            reporter
6471                .warns
6472                .iter()
6473                .any(|w| w.contains("forcing resume with mismatched plan_id"))
6474        );
6475    }
6476
6477    // ── Snapshot tests with insta for engine state ─────────────────────
6478
6479    #[test]
6480    fn snapshot_init_state_single_package() {
6481        let td = tempdir().expect("tempdir");
6482        let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
6483        let state_dir = td.path().join(".shipper");
6484
6485        let st = init_state(&ws, &state_dir).expect("init");
6486
6487        // Redact timestamps for deterministic snapshots
6488        let snapshot: Vec<String> = st
6489            .packages
6490            .iter()
6491            .map(|(k, v)| {
6492                format!(
6493                    "key={} name={} version={} attempts={} state={}",
6494                    k,
6495                    v.name,
6496                    v.version,
6497                    v.attempts,
6498                    short_state(&v.state)
6499                )
6500            })
6501            .collect();
6502        insta::assert_debug_snapshot!("init_state_single_package", snapshot);
6503    }
6504
6505    #[test]
6506    fn snapshot_init_state_multi_package() {
6507        let td = tempdir().expect("tempdir");
6508        let mut ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
6509        ws.plan.packages = vec![
6510            PlannedPackage {
6511                name: "alpha".to_string(),
6512                version: "1.0.0".to_string(),
6513                manifest_path: td.path().join("alpha/Cargo.toml"),
6514            },
6515            PlannedPackage {
6516                name: "beta".to_string(),
6517                version: "2.0.0".to_string(),
6518                manifest_path: td.path().join("beta/Cargo.toml"),
6519            },
6520            PlannedPackage {
6521                name: "gamma".to_string(),
6522                version: "0.3.0".to_string(),
6523                manifest_path: td.path().join("gamma/Cargo.toml"),
6524            },
6525        ];
6526        let state_dir = td.path().join(".shipper");
6527
6528        let st = init_state(&ws, &state_dir).expect("init");
6529
6530        let snapshot: Vec<String> = st
6531            .packages
6532            .iter()
6533            .map(|(k, v)| {
6534                format!(
6535                    "key={} name={} version={} attempts={} state={}",
6536                    k,
6537                    v.name,
6538                    v.version,
6539                    v.attempts,
6540                    short_state(&v.state)
6541                )
6542            })
6543            .collect();
6544        insta::assert_debug_snapshot!("init_state_multi_package", snapshot);
6545    }
6546
6547    #[test]
6548    fn snapshot_state_after_transitions() {
6549        let td = tempdir().expect("tempdir");
6550        let mut ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
6551        ws.plan.packages = vec![
6552            PlannedPackage {
6553                name: "alpha".to_string(),
6554                version: "1.0.0".to_string(),
6555                manifest_path: td.path().join("alpha/Cargo.toml"),
6556            },
6557            PlannedPackage {
6558                name: "beta".to_string(),
6559                version: "2.0.0".to_string(),
6560                manifest_path: td.path().join("beta/Cargo.toml"),
6561            },
6562        ];
6563        let state_dir = td.path().join(".shipper");
6564        let mut st = init_state(&ws, &state_dir).expect("init");
6565
6566        // Simulate: alpha published, beta failed
6567        update_state(&mut st, &state_dir, "alpha@1.0.0", PackageState::Published).expect("update");
6568        update_state(
6569            &mut st,
6570            &state_dir,
6571            "beta@2.0.0",
6572            PackageState::Failed {
6573                class: ErrorClass::Permanent,
6574                message: "auth failure".to_string(),
6575            },
6576        )
6577        .expect("update");
6578
6579        let snapshot: Vec<String> = st
6580            .packages
6581            .iter()
6582            .map(|(k, v)| {
6583                format!(
6584                    "key={} attempts={} state={}",
6585                    k,
6586                    v.attempts,
6587                    short_state(&v.state)
6588                )
6589            })
6590            .collect();
6591        insta::assert_debug_snapshot!("state_after_mixed_transitions", snapshot);
6592    }
6593
6594    #[test]
6595    fn snapshot_error_class_classification_matrix() {
6596        let cases = vec![
6597            ("HTTP 429 too many requests", ""),
6598            ("timeout talking to server", ""),
6599            ("HTTP 503 service unavailable", ""),
6600            ("connection refused", ""),
6601            ("HTTP 500 internal server error", ""),
6602            ("permission denied", ""),
6603            ("crate version `0.1.0` is already uploaded", ""),
6604            ("something totally unexpected", ""),
6605            ("", ""),
6606        ];
6607
6608        let snapshot: Vec<String> = cases
6609            .iter()
6610            .map(|(stderr, stdout)| {
6611                let (class, msg) = classify_cargo_failure(stderr, stdout);
6612                format!(
6613                    "stderr={:50} class={:10} msg={}",
6614                    format!("{stderr:?}"),
6615                    format!("{class:?}"),
6616                    msg
6617                )
6618            })
6619            .collect();
6620        insta::assert_debug_snapshot!("error_classification_matrix", snapshot);
6621    }
6622
6623    // ── Proptest: state transition invariants ──────────────────────────
6624
6625    mod engine_proptests {
6626        use super::*;
6627        use proptest::prelude::*;
6628
6629        fn arb_error_class() -> impl Strategy<Value = ErrorClass> {
6630            prop_oneof![
6631                Just(ErrorClass::Retryable),
6632                Just(ErrorClass::Permanent),
6633                Just(ErrorClass::Ambiguous),
6634            ]
6635        }
6636
6637        fn arb_package_state() -> impl Strategy<Value = PackageState> {
6638            prop_oneof![
6639                Just(PackageState::Pending),
6640                Just(PackageState::Uploaded),
6641                Just(PackageState::Published),
6642                ".*".prop_map(|r| PackageState::Skipped { reason: r }),
6643                (arb_error_class(), ".*").prop_map(|(c, m)| PackageState::Failed {
6644                    class: c,
6645                    message: m
6646                }),
6647                ".*".prop_map(|m| PackageState::Ambiguous { message: m }),
6648            ]
6649        }
6650
6651        proptest! {
6652            /// update_state always persists new_state to disk
6653            #[test]
6654            fn update_state_always_persists(new_state in arb_package_state()) {
6655                let td = tempdir().expect("tempdir");
6656                let state_dir = td.path().join(".shipper");
6657                let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
6658                let mut st = init_state(&ws, &state_dir).expect("init");
6659                let key = "demo@0.1.0";
6660
6661                update_state(&mut st, &state_dir, key, new_state.clone()).expect("update");
6662
6663                // In-memory matches
6664                assert_eq!(st.packages.get(key).unwrap().state, new_state);
6665
6666                // On-disk matches
6667                let loaded = state::load_state(&state_dir)
6668                    .expect("load")
6669                    .expect("exists");
6670                assert_eq!(loaded.packages.get(key).unwrap().state, new_state);
6671            }
6672
6673            /// short_state never panics on any PackageState variant
6674            #[test]
6675            fn short_state_never_panics(state in arb_package_state()) {
6676                let label = short_state(&state);
6677                assert!(!label.is_empty());
6678            }
6679
6680            /// pkg_key is deterministic and reversible
6681            #[test]
6682            fn pkg_key_deterministic(
6683                name in "[a-z][a-z0-9_-]{0,19}",
6684                version in "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}"
6685            ) {
6686                let key1 = pkg_key(&name, &version);
6687                let key2 = pkg_key(&name, &version);
6688                assert_eq!(key1, key2);
6689                assert!(key1.contains('@'));
6690                assert!(key1.starts_with(&name));
6691                assert!(key1.ends_with(&version));
6692            }
6693
6694            /// backoff_delay is always bounded by [0, max + jitter headroom]
6695            #[test]
6696            fn backoff_delay_bounded(
6697                base_ms in 1u64..5000,
6698                max_ms in 100u64..30000,
6699                attempt in 1u32..50,
6700                jitter in 0.0f64..1.0,
6701            ) {
6702                let base = Duration::from_millis(base_ms.min(max_ms));
6703                let max = Duration::from_millis(max_ms);
6704
6705                let delay = backoff_delay(
6706                    base,
6707                    max,
6708                    attempt,
6709                    crate::retry::RetryStrategyType::Exponential,
6710                    jitter,
6711                );
6712
6713                // With jitter, delay can be at most max * (1 + jitter)
6714                let upper_bound_ms = (max_ms as f64 * (1.0 + jitter)).ceil() as u64 + 1;
6715                assert!(
6716                    delay.as_millis() <= upper_bound_ms as u128,
6717                    "delay {}ms exceeded upper bound {}ms (base={}ms, max={}ms, attempt={}, jitter={})",
6718                    delay.as_millis(), upper_bound_ms, base_ms, max_ms, attempt, jitter
6719                );
6720            }
6721
6722            /// ExecutionState roundtrips through JSON for arbitrary states
6723            #[test]
6724            fn execution_state_roundtrip(
6725                attempts in 0u32..100,
6726                state in arb_package_state()
6727            ) {
6728                let mut packages = BTreeMap::new();
6729                packages.insert(
6730                    "test@1.0.0".to_string(),
6731                    PackageProgress {
6732                        name: "test".to_string(),
6733                        version: "1.0.0".to_string(),
6734                        attempts,
6735                        state,
6736                        last_updated_at: Utc::now(),
6737                    },
6738                );
6739                let st = ExecutionState {
6740                    state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
6741                    plan_id: "plan-proptest".to_string(),
6742                    registry: Registry {
6743                        name: "crates-io".to_string(),
6744                        api_base: "https://crates.io".to_string(),
6745                        index_base: None,
6746                    },
6747                    created_at: Utc::now(),
6748                    updated_at: Utc::now(),
6749                    packages,
6750                };
6751
6752                let json = serde_json::to_string(&st).expect("serialize");
6753                let parsed: ExecutionState = serde_json::from_str(&json).expect("deserialize");
6754                assert_eq!(parsed.packages.len(), 1);
6755                let pkg = parsed.packages.get("test@1.0.0").unwrap();
6756                assert_eq!(pkg.attempts, attempts);
6757            }
6758        }
6759    }
6760
6761    // ───────────────────────────────────────────────────────────────────
6762    // run_rehearsal (#97 PR 2) — phase-2 preflight against an alt registry
6763    // ───────────────────────────────────────────────────────────────────
6764
6765    fn read_events_raw(state_dir: &Path) -> Vec<serde_json::Value> {
6766        let path = events::events_path(state_dir);
6767        let raw = std::fs::read_to_string(&path).unwrap_or_default();
6768        raw.lines()
6769            .filter(|l| !l.trim().is_empty())
6770            .map(|l| serde_json::from_str(l).expect("events.jsonl must parse"))
6771            .collect()
6772    }
6773
6774    fn event_discriminator(event: &serde_json::Value) -> Option<String> {
6775        event
6776            .get("event_type")
6777            .and_then(|et| et.get("type"))
6778            .and_then(|t| t.as_str())
6779            .map(str::to_owned)
6780    }
6781
6782    #[test]
6783    #[serial]
6784    fn run_rehearsal_errors_when_no_rehearsal_registry_configured() {
6785        let td = tempdir().expect("tempdir");
6786        let bin = td.path().join("bin");
6787        write_fake_tools(&bin);
6788        let env_vars = fake_program_env_vars(&bin);
6789        temp_env::with_vars(env_vars, || {
6790            let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
6791            let opts = default_opts(PathBuf::from(".shipper"));
6792
6793            let mut reporter = CollectingReporter::default();
6794            let err = run_rehearsal(&ws, &opts, &mut reporter).expect_err("must fail");
6795            let msg = format!("{err:#}");
6796            assert!(msg.contains("no rehearsal registry"), "err was: {msg}");
6797        });
6798    }
6799
6800    #[test]
6801    #[serial]
6802    fn run_rehearsal_errors_when_rehearsal_equals_live_target() {
6803        let td = tempdir().expect("tempdir");
6804        let bin = td.path().join("bin");
6805        write_fake_tools(&bin);
6806        let env_vars = fake_program_env_vars(&bin);
6807        temp_env::with_vars(env_vars, || {
6808            let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
6809            let mut opts = default_opts(PathBuf::from(".shipper"));
6810            // Point rehearsal at the same registry name as the live target.
6811            opts.rehearsal_registry = Some("crates-io".to_string());
6812            opts.registries = vec![Registry {
6813                name: "crates-io".to_string(),
6814                api_base: "http://127.0.0.1:1".to_string(),
6815                index_base: None,
6816            }];
6817
6818            let mut reporter = CollectingReporter::default();
6819            let err = run_rehearsal(&ws, &opts, &mut reporter).expect_err("must fail");
6820            let msg = format!("{err:#}");
6821            assert!(
6822                msg.contains("must differ from the live target"),
6823                "err was: {msg}"
6824            );
6825        });
6826    }
6827
6828    #[test]
6829    #[serial]
6830    fn run_rehearsal_errors_when_registry_name_not_in_config() {
6831        let td = tempdir().expect("tempdir");
6832        let bin = td.path().join("bin");
6833        write_fake_tools(&bin);
6834        let env_vars = fake_program_env_vars(&bin);
6835        temp_env::with_vars(env_vars, || {
6836            let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
6837            let mut opts = default_opts(PathBuf::from(".shipper"));
6838            opts.rehearsal_registry = Some("bogus-registry".to_string());
6839            // opts.registries is empty — bogus-registry won't resolve.
6840
6841            let mut reporter = CollectingReporter::default();
6842            let err = run_rehearsal(&ws, &opts, &mut reporter).expect_err("must fail");
6843            let msg = format!("{err:#}");
6844            assert!(msg.contains("is not configured"), "err was: {msg}");
6845        });
6846    }
6847
6848    #[test]
6849    #[serial]
6850    fn run_rehearsal_skip_flag_returns_without_running() {
6851        let td = tempdir().expect("tempdir");
6852        let bin = td.path().join("bin");
6853        write_fake_tools(&bin);
6854        let env_vars = fake_program_env_vars(&bin);
6855        temp_env::with_vars(env_vars, || {
6856            let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
6857            let mut opts = default_opts(PathBuf::from(".shipper"));
6858            opts.rehearsal_registry = Some("rehearsal".to_string());
6859            opts.rehearsal_skip = true;
6860
6861            let mut reporter = CollectingReporter::default();
6862            let outcome =
6863                run_rehearsal(&ws, &opts, &mut reporter).expect("skip path should not error");
6864            assert!(!outcome.passed, "skip should not claim a pass");
6865            assert_eq!(outcome.packages_published, 0);
6866            assert!(outcome.summary.contains("skipped"));
6867            // Skip path must not write events — nothing to audit.
6868            let events_path = events::events_path(&td.path().join(".shipper"));
6869            assert!(
6870                !events_path.exists(),
6871                "skip path must not create events.jsonl"
6872            );
6873        });
6874    }
6875
6876    #[test]
6877    #[serial]
6878    fn run_rehearsal_happy_path_emits_started_published_complete_events() {
6879        let td = tempdir().expect("tempdir");
6880        let bin = td.path().join("bin");
6881        write_fake_tools(&bin);
6882        let env_vars = fake_program_env_vars(&bin);
6883        temp_env::with_vars(env_vars, || {
6884            // Rehearsal-registry mock: returns 404 for the preflight lookup
6885            // (not here) and 200 for the post-publish visibility check.
6886            let rehearsal_server = spawn_registry_server(
6887                std::collections::BTreeMap::from([(
6888                    "/api/v1/crates/demo/0.1.0".to_string(),
6889                    vec![(200, "{}".to_string())],
6890                )]),
6891                1,
6892            );
6893
6894            let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
6895            let mut opts = default_opts(PathBuf::from(".shipper"));
6896            opts.rehearsal_registry = Some("rehearsal".to_string());
6897            opts.registries = vec![Registry {
6898                name: "rehearsal".to_string(),
6899                api_base: rehearsal_server.base_url.clone(),
6900                index_base: None,
6901            }];
6902
6903            let mut reporter = CollectingReporter::default();
6904            let outcome = run_rehearsal(&ws, &opts, &mut reporter).expect("rehearse");
6905            assert!(outcome.passed, "outcome: {outcome:?}");
6906            assert_eq!(outcome.packages_published, 1);
6907
6908            let events = read_events_raw(&td.path().join(".shipper"));
6909            let types: Vec<String> = events.iter().filter_map(event_discriminator).collect();
6910            assert!(
6911                types.contains(&"rehearsal_started".to_string()),
6912                "types: {types:?}"
6913            );
6914            assert!(
6915                types.contains(&"rehearsal_package_published".to_string()),
6916                "types: {types:?}"
6917            );
6918            assert!(
6919                types.contains(&"rehearsal_complete".to_string()),
6920                "types: {types:?}"
6921            );
6922
6923            // RehearsalComplete must carry passed=true for the happy path.
6924            let complete = events
6925                .iter()
6926                .find(|e| event_discriminator(e).as_deref() == Some("rehearsal_complete"))
6927                .expect("RehearsalComplete event");
6928            assert_eq!(
6929                complete["event_type"]["passed"].as_bool(),
6930                Some(true),
6931                "complete event: {complete}"
6932            );
6933
6934            rehearsal_server.join();
6935        });
6936    }
6937
6938    // ───────────────────────────────────────────────────────────────────
6939    // enforce_rehearsal_gate (#97 PR 3)
6940    // ───────────────────────────────────────────────────────────────────
6941
6942    fn write_rehearsal_receipt(
6943        state_dir: &Path,
6944        plan_id: &str,
6945        passed: bool,
6946    ) -> crate::state::rehearsal::RehearsalReceipt {
6947        let receipt = crate::state::rehearsal::RehearsalReceipt {
6948            schema_version: crate::state::rehearsal::CURRENT_REHEARSAL_VERSION.to_string(),
6949            plan_id: plan_id.to_string(),
6950            registry: "rehearsal".to_string(),
6951            passed,
6952            packages_attempted: 1,
6953            packages_published: if passed { 1 } else { 0 },
6954            summary: if passed {
6955                "rehearsed 1 package successfully".into()
6956            } else {
6957                "rehearsal failed".into()
6958            },
6959            started_at: Utc::now(),
6960            completed_at: Utc::now(),
6961        };
6962        crate::state::rehearsal::save_rehearsal(state_dir, &receipt).expect("write");
6963        receipt
6964    }
6965
6966    #[test]
6967    fn gate_is_dormant_when_rehearsal_registry_is_none() {
6968        let td = tempdir().expect("tempdir");
6969        let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
6970        let opts = default_opts(PathBuf::from(".shipper"));
6971        // opts.rehearsal_registry is None by default.
6972        let mut reporter = CollectingReporter::default();
6973        enforce_rehearsal_gate(&ws, &opts, td.path(), &mut reporter).expect("gate dormant");
6974    }
6975
6976    #[test]
6977    fn gate_proceeds_with_warning_when_skip_is_set() {
6978        let td = tempdir().expect("tempdir");
6979        let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
6980        let mut opts = default_opts(PathBuf::from(".shipper"));
6981        opts.rehearsal_registry = Some("rehearsal".into());
6982        opts.rehearsal_skip = true;
6983        let mut reporter = CollectingReporter::default();
6984        enforce_rehearsal_gate(&ws, &opts, td.path(), &mut reporter).expect("skip bypass");
6985        assert!(
6986            reporter
6987                .warns
6988                .iter()
6989                .any(|w| w.contains("--skip-rehearsal")),
6990            "warns: {:?}",
6991            reporter.warns
6992        );
6993    }
6994
6995    #[test]
6996    fn gate_refuses_when_no_receipt_exists() {
6997        let td = tempdir().expect("tempdir");
6998        let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
6999        let mut opts = default_opts(PathBuf::from(".shipper"));
7000        opts.rehearsal_registry = Some("rehearsal".into());
7001        let mut reporter = CollectingReporter::default();
7002        let err =
7003            enforce_rehearsal_gate(&ws, &opts, td.path(), &mut reporter).expect_err("must fail");
7004        let msg = format!("{err:#}");
7005        assert!(msg.contains("no rehearsal receipt was found"), "err: {msg}");
7006        assert!(
7007            msg.contains("shipper rehearse"),
7008            "err should hint fix: {msg}"
7009        );
7010    }
7011
7012    #[test]
7013    fn gate_refuses_on_plan_id_mismatch() {
7014        let td = tempdir().expect("tempdir");
7015        let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
7016        let mut opts = default_opts(PathBuf::from(".shipper"));
7017        opts.rehearsal_registry = Some("rehearsal".into());
7018
7019        write_rehearsal_receipt(td.path(), "some-other-plan", true);
7020
7021        let mut reporter = CollectingReporter::default();
7022        let err =
7023            enforce_rehearsal_gate(&ws, &opts, td.path(), &mut reporter).expect_err("must fail");
7024        let msg = format!("{err:#}");
7025        assert!(msg.contains("stale"), "err: {msg}");
7026        assert!(
7027            msg.contains(&ws.plan.plan_id),
7028            "err should reference current plan_id: {msg}"
7029        );
7030    }
7031
7032    #[test]
7033    fn gate_refuses_on_failing_receipt() {
7034        let td = tempdir().expect("tempdir");
7035        let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
7036        let mut opts = default_opts(PathBuf::from(".shipper"));
7037        opts.rehearsal_registry = Some("rehearsal".into());
7038
7039        write_rehearsal_receipt(td.path(), &ws.plan.plan_id, false);
7040
7041        let mut reporter = CollectingReporter::default();
7042        let err =
7043            enforce_rehearsal_gate(&ws, &opts, td.path(), &mut reporter).expect_err("must fail");
7044        let msg = format!("{err:#}");
7045        assert!(msg.contains("did NOT pass"), "err: {msg}");
7046    }
7047
7048    #[test]
7049    fn gate_passes_on_fresh_passing_receipt() {
7050        let td = tempdir().expect("tempdir");
7051        let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
7052        let mut opts = default_opts(PathBuf::from(".shipper"));
7053        opts.rehearsal_registry = Some("rehearsal".into());
7054
7055        write_rehearsal_receipt(td.path(), &ws.plan.plan_id, true);
7056
7057        let mut reporter = CollectingReporter::default();
7058        enforce_rehearsal_gate(&ws, &opts, td.path(), &mut reporter).expect("fresh pass");
7059        assert!(
7060            reporter.infos.iter().any(|i| i.contains("passing receipt")),
7061            "infos: {:?}",
7062            reporter.infos
7063        );
7064    }
7065
7066    /// End-to-end: `run_publish` refuses to run when rehearsal is required
7067    /// but no receipt exists. This is the gate's actual contract — the
7068    /// finer-grained tests above exercise the gate helper in isolation;
7069    /// this one confirms run_publish wires it in correctly.
7070    #[test]
7071    #[serial]
7072    fn run_publish_refuses_without_rehearsal_when_required() {
7073        let td = tempdir().expect("tempdir");
7074        let bin = td.path().join("bin");
7075        write_fake_tools(&bin);
7076        let env_vars = fake_program_env_vars(&bin);
7077        temp_env::with_vars(env_vars, || {
7078            let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
7079            let state_dir = td.path().join(".shipper");
7080            let mut opts = default_opts(state_dir);
7081            opts.rehearsal_registry = Some("rehearsal".into());
7082
7083            let mut reporter = CollectingReporter::default();
7084            let err = run_publish(&ws, &opts, &mut reporter).expect_err("gate must bail");
7085            let msg = format!("{err:#}");
7086            assert!(
7087                msg.contains("rehearsal is required") || msg.contains("no rehearsal receipt"),
7088                "expected gate error, got: {msg}"
7089            );
7090        });
7091    }
7092
7093    /// #97 PR 4 — smoke-install happy path. --smoke-install names a
7094    /// crate in the plan; fake cargo returns 0 for the install call;
7095    /// rehearsal emits smoke-check events and passes.
7096    #[test]
7097    #[serial]
7098    fn run_rehearsal_smoke_install_happy_path_emits_succeeded_event() {
7099        let td = tempdir().expect("tempdir");
7100        let bin = td.path().join("bin");
7101        write_fake_tools(&bin);
7102        let env_vars = fake_program_env_vars(&bin);
7103        temp_env::with_vars(env_vars, || {
7104            let rehearsal_server = spawn_registry_server(
7105                std::collections::BTreeMap::from([(
7106                    "/api/v1/crates/demo/0.1.0".to_string(),
7107                    vec![(200, "{}".to_string())],
7108                )]),
7109                1,
7110            );
7111
7112            let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
7113            let mut opts = default_opts(PathBuf::from(".shipper"));
7114            opts.rehearsal_registry = Some("rehearsal".to_string());
7115            opts.registries = vec![Registry {
7116                name: "rehearsal".to_string(),
7117                api_base: rehearsal_server.base_url.clone(),
7118                index_base: None,
7119            }];
7120            opts.rehearsal_smoke_install = Some("demo".to_string());
7121
7122            let mut reporter = CollectingReporter::default();
7123            let outcome = run_rehearsal(&ws, &opts, &mut reporter).expect("rehearse");
7124            assert!(outcome.passed, "outcome: {outcome:?}");
7125
7126            let events = read_events_raw(&td.path().join(".shipper"));
7127            let types: Vec<String> = events.iter().filter_map(event_discriminator).collect();
7128            assert!(
7129                types.contains(&"rehearsal_smoke_check_started".to_string()),
7130                "types: {types:?}"
7131            );
7132            assert!(
7133                types.contains(&"rehearsal_smoke_check_succeeded".to_string()),
7134                "types: {types:?}"
7135            );
7136            rehearsal_server.join();
7137        });
7138    }
7139
7140    /// #97 PR 4 — smoke-install named a crate not in the plan. Warn-only
7141    /// path: rehearsal itself still passes (publish was fine) but the
7142    /// reporter surfaces the misconfiguration so the operator can fix it.
7143    #[test]
7144    #[serial]
7145    fn run_rehearsal_smoke_install_missing_target_warns_without_failing() {
7146        let td = tempdir().expect("tempdir");
7147        let bin = td.path().join("bin");
7148        write_fake_tools(&bin);
7149        let env_vars = fake_program_env_vars(&bin);
7150        temp_env::with_vars(env_vars, || {
7151            let rehearsal_server = spawn_registry_server(
7152                std::collections::BTreeMap::from([(
7153                    "/api/v1/crates/demo/0.1.0".to_string(),
7154                    vec![(200, "{}".to_string())],
7155                )]),
7156                1,
7157            );
7158
7159            let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
7160            let mut opts = default_opts(PathBuf::from(".shipper"));
7161            opts.rehearsal_registry = Some("rehearsal".to_string());
7162            opts.registries = vec![Registry {
7163                name: "rehearsal".to_string(),
7164                api_base: rehearsal_server.base_url.clone(),
7165                index_base: None,
7166            }];
7167            opts.rehearsal_smoke_install = Some("nonexistent".to_string());
7168
7169            let mut reporter = CollectingReporter::default();
7170            let outcome = run_rehearsal(&ws, &opts, &mut reporter).expect("rehearse");
7171            assert!(outcome.passed);
7172            assert!(
7173                reporter
7174                    .warns
7175                    .iter()
7176                    .any(|w| w.contains("not in the rehearsal plan")),
7177                "warns: {:?}",
7178                reporter.warns
7179            );
7180            rehearsal_server.join();
7181        });
7182    }
7183
7184    #[test]
7185    #[serial]
7186    fn run_rehearsal_cargo_failure_emits_package_failed_and_marks_not_passed() {
7187        let td = tempdir().expect("tempdir");
7188        let bin = td.path().join("bin");
7189        write_fake_tools(&bin);
7190        let mut env_vars = fake_program_env_vars(&bin);
7191        env_vars.extend([("SHIPPER_CARGO_EXIT", Some("101".to_string()))]);
7192        temp_env::with_vars(env_vars, || {
7193            // Rehearsal registry is never hit because cargo fails.
7194            let rehearsal_server = spawn_registry_server(std::collections::BTreeMap::new(), 0);
7195
7196            let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
7197            let mut opts = default_opts(PathBuf::from(".shipper"));
7198            opts.rehearsal_registry = Some("rehearsal".to_string());
7199            opts.registries = vec![Registry {
7200                name: "rehearsal".to_string(),
7201                api_base: rehearsal_server.base_url.clone(),
7202                index_base: None,
7203            }];
7204
7205            let mut reporter = CollectingReporter::default();
7206            let outcome = run_rehearsal(&ws, &opts, &mut reporter).expect("rehearse");
7207            assert!(!outcome.passed);
7208            assert_eq!(outcome.packages_published, 0);
7209
7210            let events = read_events_raw(&td.path().join(".shipper"));
7211            let types: Vec<String> = events.iter().filter_map(event_discriminator).collect();
7212            assert!(
7213                types.contains(&"rehearsal_package_failed".to_string()),
7214                "types: {types:?}"
7215            );
7216            assert!(
7217                types.contains(&"rehearsal_complete".to_string()),
7218                "types: {types:?}"
7219            );
7220
7221            let complete = events
7222                .iter()
7223                .find(|e| event_discriminator(e).as_deref() == Some("rehearsal_complete"))
7224                .expect("RehearsalComplete");
7225            assert_eq!(complete["event_type"]["passed"].as_bool(), Some(false));
7226            rehearsal_server.join();
7227        });
7228    }
7229}
7230
7231/// Wave-based parallel publishing engine.
7232pub mod parallel;
7233
7234/// Plan-yank: reverse-topological containment plan from a receipt (#98 PR 2).
7235pub mod plan_yank;
7236
7237/// Fix-forward: supersession plan from a compromised receipt (#98 PR 3).
7238pub mod fix_forward;