Skip to main content

shipper_core/runtime/execution/
mod.rs

1//! Shared execution helpers for publish workflows.
2//!
3//! Absorbed from the former `shipper-execution-core` microcrate. These items
4//! are `pub` (rather than `pub(crate)`) because an external fuzz target in
5//! `fuzz/` exercises them directly; they will be tightened to `pub(crate)`
6//! once the fuzz surface is rationalized in a later pass.
7
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11use anyhow::{Context, Result};
12use chrono::{DateTime, Utc};
13
14use shipper_retry::{RetryStrategyConfig, RetryStrategyType, calculate_delay};
15use shipper_types::{AttemptDetail, ErrorClass, ExecutionState, PackageState, PublishRegime};
16
17/// Update a package state and persist the entire execution state to disk.
18pub fn update_state(
19    st: &mut ExecutionState,
20    state_dir: &Path,
21    key: &str,
22    new_state: PackageState,
23) -> Result<()> {
24    let pr = st
25        .packages
26        .get_mut(key)
27        .context("missing package in state")?;
28    pr.state = new_state;
29    pr.last_updated_at = Utc::now();
30    st.updated_at = Utc::now();
31    crate::state::execution_state::save_state(state_dir, st)
32}
33
34/// Append an attempt detail to an in-memory state and refresh its timestamp.
35pub fn append_attempt_detail(st: &mut ExecutionState, detail: AttemptDetail) {
36    st.attempt_history.push(detail);
37    st.updated_at = Utc::now();
38}
39
40/// Append an attempt detail and persist the execution state.
41pub fn record_attempt_detail(
42    st: &mut ExecutionState,
43    state_dir: &Path,
44    detail: AttemptDetail,
45) -> Result<()> {
46    append_attempt_detail(st, detail);
47    crate::state::execution_state::save_state(state_dir, st)
48}
49
50/// Calculate the wall-clock time for a scheduled retry.
51pub fn retry_next_attempt_at(delay: Duration) -> DateTime<Utc> {
52    Utc::now() + chrono::Duration::from_std(delay).unwrap_or_else(|_| chrono::Duration::zero())
53}
54
55/// Resolve the effective state directory from a workspace root and user option.
56pub fn resolve_state_dir(workspace_root: &Path, state_dir: &PathBuf) -> PathBuf {
57    if state_dir.is_absolute() {
58        state_dir.clone()
59    } else {
60        workspace_root.join(state_dir)
61    }
62}
63
64/// Create a stable key for a package version.
65pub fn pkg_key(name: &str, version: &str) -> String {
66    format!("{name}@{version}")
67}
68
69/// Short, human-readable label for a package state.
70pub fn short_state(st: &PackageState) -> &'static str {
71    match st {
72        PackageState::Pending => "pending",
73        PackageState::Uploaded => "uploaded",
74        PackageState::Published => "published",
75        PackageState::Skipped { .. } => "skipped",
76        PackageState::Failed { .. } => "failed",
77        PackageState::Ambiguous { .. } => "ambiguous",
78    }
79}
80
81/// Classify a cargo failure output into retry semantics for publish decisioning.
82///
83/// **This is a hint, not authoritative truth.** The returned [`ErrorClass`]
84/// is produced by pattern-matching on cargo's human-facing stdout/stderr —
85/// a surface that is explicitly not a stable machine protocol. The retry
86/// loop consumes this classification as fast-path input, but the
87/// authoritative resolution for an [`ErrorClass::Ambiguous`] outcome comes
88/// from querying the registry (sparse index + API) via the reconciliation
89/// flow — never from the cargo text alone. See the `ErrorClass` rustdoc
90/// and `shipper::engine::parallel::reconcile` for the "hint vs truth"
91/// contract.
92pub fn classify_cargo_failure(stderr: &str, stdout: &str) -> (ErrorClass, String) {
93    let outcome = shipper_cargo_failure::classify_publish_failure(stderr, stdout);
94    let class = match outcome.class {
95        shipper_cargo_failure::CargoFailureClass::Retryable => ErrorClass::Retryable,
96        shipper_cargo_failure::CargoFailureClass::Permanent => ErrorClass::Permanent,
97        shipper_cargo_failure::CargoFailureClass::Ambiguous => ErrorClass::Ambiguous,
98    };
99
100    (class, outcome.message.to_string())
101}
102
103/// Calculate the delay for a retry attempt.
104pub fn backoff_delay(
105    base: Duration,
106    max: Duration,
107    attempt: u32,
108    strategy: RetryStrategyType,
109    jitter: f64,
110) -> Duration {
111    let config = RetryStrategyConfig {
112        strategy,
113        max_attempts: 10,
114        base_delay: base,
115        max_delay: max,
116        jitter,
117    };
118    calculate_delay(&config, attempt)
119}
120
121/// crates.io's documented rate-limit window for new-crate publishes: 10 min.
122/// After the 5-crate account burst is consumed, new crates are admitted at
123/// most once per `CRATES_IO_NEW_CRATE_WINDOW`. Source:
124/// <https://crates.io/docs/rate-limits>.
125pub const CRATES_IO_NEW_CRATE_WINDOW: Duration = Duration::from_secs(10 * 60);
126
127/// How Shipper expects a registry to propagate newly published packages.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum RegistryPropagationModel {
130    /// No registry-specific propagation model is known.
131    Unknown,
132    /// The registry exposes both an HTTP API and sparse index path that can
133    /// be checked for visibility.
134    ApiAndSparseIndex,
135}
136
137/// How Shipper should treat ambiguous cargo publish output for this registry.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum RegistryAmbiguityModel {
140    /// No registry-specific ambiguity model is known.
141    Unknown,
142    /// Cargo process output is only a hint; registry visibility decides.
143    RegistryTruth,
144}
145
146/// Registry-specific publish constraints that affect retry pacing and
147/// operator estimates.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct RegistryProfile {
150    /// Stable registry profile name.
151    pub name: &'static str,
152    /// Initial first-publish burst, when documented.
153    pub first_publish_burst: Option<u32>,
154    /// First-publish refill interval, when documented.
155    pub first_publish_refill: Option<Duration>,
156    /// Initial version-update burst, when documented.
157    pub version_publish_burst: Option<u32>,
158    /// Version-update refill interval, when documented.
159    pub version_publish_refill: Option<Duration>,
160    /// Registry visibility model used by readiness/reconciliation.
161    pub propagation_model: RegistryPropagationModel,
162    /// Registry ambiguity model used after cargo exits unclearly.
163    pub ambiguity_model: RegistryAmbiguityModel,
164}
165
166impl RegistryProfile {
167    /// Built-in crates.io profile.
168    pub const fn crates_io() -> Self {
169        Self {
170            name: "crates-io",
171            first_publish_burst: Some(5),
172            first_publish_refill: Some(CRATES_IO_NEW_CRATE_WINDOW),
173            version_publish_burst: None,
174            version_publish_refill: None,
175            propagation_model: RegistryPropagationModel::ApiAndSparseIndex,
176            ambiguity_model: RegistryAmbiguityModel::RegistryTruth,
177        }
178    }
179
180    /// Conservative profile for registries without documented constraints.
181    pub const fn unknown() -> Self {
182        Self {
183            name: "unknown",
184            first_publish_burst: None,
185            first_publish_refill: None,
186            version_publish_burst: None,
187            version_publish_refill: None,
188            propagation_model: RegistryPropagationModel::Unknown,
189            ambiguity_model: RegistryAmbiguityModel::Unknown,
190        }
191    }
192
193    /// Resolve Shipper's built-in profile for a registry name.
194    pub fn for_registry_name(name: &str) -> Self {
195        match name.trim().to_ascii_lowercase().as_str() {
196            "crates-io" | "crates.io" | "crates_io" => Self::crates_io(),
197            _ => Self::unknown(),
198        }
199    }
200
201    /// Return the documented retry floor for this publish regime, if any.
202    pub fn retry_floor_for(self, regime: PublishRegime, error_message: &str) -> Option<Duration> {
203        if !looks_like_rate_limit(error_message) {
204            return None;
205        }
206
207        match regime {
208            PublishRegime::FirstPublish => self.first_publish_refill,
209            PublishRegime::Update => self.version_publish_refill,
210        }
211    }
212}
213
214/// Return `true` if an error message looks like a rate-limit signal
215/// (HTTP 429 / "too many requests" / "rate limit" phrasings that appear
216/// in cargo publish stderr or common registry error bodies). Used to gate
217/// the crates.io-aware backoff adjustment: we only extend the delay when
218/// we believe we're actually being rate-limited.
219pub fn looks_like_rate_limit(message: &str) -> bool {
220    let m = message.to_lowercase();
221    m.contains("429")
222        || m.contains("rate limit")
223        || m.contains("rate-limit")
224        || m.contains("too many requests")
225}
226
227/// Parse a `Retry-After` header value from cargo/registry output.
228///
229/// Cargo exposes registry failures through human-facing stderr/stdout rather
230/// than a structured HTTP response. When that text contains a `Retry-After`
231/// header, this returns the registry's requested wait as a duration.
232pub fn retry_after_delay(message: &str) -> Option<Duration> {
233    retry_after_delay_at(message, Utc::now())
234}
235
236fn retry_after_delay_at(message: &str, now: DateTime<Utc>) -> Option<Duration> {
237    message.lines().find_map(|line| {
238        let line = line
239            .trim_start()
240            .trim_start_matches(['<', '>'])
241            .trim_start();
242        let (name, value) = line.split_once(':')?;
243        if !name.trim().eq_ignore_ascii_case("retry-after") {
244            return None;
245        }
246
247        parse_retry_after_value(value.trim(), now)
248    })
249}
250
251fn parse_retry_after_value(value: &str, now: DateTime<Utc>) -> Option<Duration> {
252    let value = value.trim_matches('"').trim_matches('\'').trim();
253    if value.is_empty() {
254        return None;
255    }
256
257    if value.bytes().all(|b| b.is_ascii_digit()) {
258        return value.parse::<u64>().ok().map(Duration::from_secs);
259    }
260
261    let target = DateTime::parse_from_rfc2822(value)
262        .ok()?
263        .with_timezone(&Utc);
264    let delta = target.signed_duration_since(now);
265    delta.to_std().ok().or(Some(Duration::ZERO))
266}
267
268/// Registry-aware backoff. Layered on top of the generic [`backoff_delay`]:
269/// if we're publishing a brand-new crate and the retry is caused by a
270/// rate-limit signal, floor the delay at [`CRATES_IO_NEW_CRATE_WINDOW`]
271/// so we stop burning retries during the 10-minute window crates.io has
272/// already told us to wait through. Everything else uses the generic delay.
273///
274/// Preflight discovers `is_new_crate` already (one `check_new_crate` call
275/// per package at publish start); wiring it here costs no additional I/O.
276/// See issues #94 and #91 for the design discussion.
277pub fn registry_aware_backoff(
278    base: Duration,
279    max: Duration,
280    attempt: u32,
281    strategy: RetryStrategyType,
282    jitter: f64,
283    is_new_crate: bool,
284    error_message: &str,
285) -> Duration {
286    let regime = if is_new_crate {
287        PublishRegime::FirstPublish
288    } else {
289        PublishRegime::Update
290    };
291    registry_profile_aware_backoff(
292        base,
293        max,
294        attempt,
295        strategy,
296        jitter,
297        RegistryProfile::crates_io(),
298        regime,
299        error_message,
300    )
301}
302
303/// Registry-profile-aware backoff.
304///
305/// This is the explicit version of [`registry_aware_backoff`]. It keeps the
306/// existing crates.io behavior while giving later Profile / Adapt work a
307/// named profile object to thread through plan, preflight, and publish.
308pub fn registry_profile_aware_backoff(
309    base: Duration,
310    max: Duration,
311    attempt: u32,
312    strategy: RetryStrategyType,
313    jitter: f64,
314    profile: RegistryProfile,
315    regime: PublishRegime,
316    error_message: &str,
317) -> Duration {
318    let generic = backoff_delay(base, max, attempt, strategy, jitter);
319    [
320        profile.retry_floor_for(regime, error_message),
321        retry_after_delay(error_message),
322    ]
323    .into_iter()
324    .flatten()
325    .fold(generic, Duration::max)
326}
327
328/// Update a package state inside an in-memory execution state.
329pub fn update_state_locked(st: &mut ExecutionState, key: &str, new_state: PackageState) {
330    if let Some(pr) = st.packages.get_mut(key) {
331        pr.state = new_state;
332        pr.last_updated_at = Utc::now();
333    }
334    st.updated_at = Utc::now();
335}
336
337#[cfg(test)]
338mod tests {
339    use std::collections::BTreeMap;
340    use std::path::PathBuf;
341
342    use chrono::Utc;
343    use proptest::prelude::*;
344    use tempfile::tempdir;
345
346    use super::*;
347
348    // ---- Tests for looks_like_rate_limit + registry_aware_backoff (#94) ----
349
350    #[test]
351    fn looks_like_rate_limit_matches_common_phrasings() {
352        assert!(looks_like_rate_limit("HTTP 429 Too Many Requests"));
353        assert!(looks_like_rate_limit("rate limit exceeded"));
354        assert!(looks_like_rate_limit("rate-limited by server"));
355        assert!(looks_like_rate_limit("received 429"));
356        assert!(looks_like_rate_limit("429: retry later"));
357    }
358
359    #[test]
360    fn looks_like_rate_limit_ignores_unrelated_errors() {
361        assert!(!looks_like_rate_limit("connection refused"));
362        assert!(!looks_like_rate_limit("DNS lookup failed"));
363        assert!(!looks_like_rate_limit("invalid manifest"));
364        assert!(!looks_like_rate_limit("500 internal server error"));
365        assert!(!looks_like_rate_limit(""));
366    }
367
368    #[test]
369    fn crates_io_profile_captures_documented_first_publish_window() {
370        let profile = RegistryProfile::crates_io();
371
372        assert_eq!(profile.name, "crates-io");
373        assert_eq!(profile.first_publish_burst, Some(5));
374        assert_eq!(
375            profile.retry_floor_for(PublishRegime::FirstPublish, "HTTP 429 Too Many Requests"),
376            Some(CRATES_IO_NEW_CRATE_WINDOW)
377        );
378        assert_eq!(
379            profile.retry_floor_for(PublishRegime::Update, "HTTP 429 Too Many Requests"),
380            None
381        );
382        assert_eq!(
383            profile.retry_floor_for(PublishRegime::FirstPublish, "connection reset"),
384            None
385        );
386    }
387
388    #[test]
389    fn registry_profile_lookup_recognizes_cargo_crates_io_spellings() {
390        for name in ["crates-io", "crates.io", "crates_io", " CRATES-IO "] {
391            assert_eq!(
392                RegistryProfile::for_registry_name(name),
393                RegistryProfile::crates_io()
394            );
395        }
396
397        assert_eq!(
398            RegistryProfile::for_registry_name("private-registry"),
399            RegistryProfile::unknown()
400        );
401    }
402
403    #[test]
404    fn registry_profile_aware_backoff_uses_profile_floor() {
405        let d = registry_profile_aware_backoff(
406            Duration::from_secs(10),
407            Duration::from_secs(120),
408            1,
409            RetryStrategyType::Exponential,
410            0.0,
411            RegistryProfile::crates_io(),
412            PublishRegime::FirstPublish,
413            "HTTP 429 Too Many Requests",
414        );
415
416        assert_eq!(d, CRATES_IO_NEW_CRATE_WINDOW);
417    }
418
419    #[test]
420    fn retry_after_delay_parses_delta_seconds() {
421        assert_eq!(
422            retry_after_delay("HTTP 429\r\nRetry-After: 90\r\n"),
423            Some(Duration::from_secs(90))
424        );
425        assert_eq!(
426            retry_after_delay("< retry-after: \"120\""),
427            Some(Duration::from_secs(120))
428        );
429    }
430
431    #[test]
432    fn retry_after_delay_parses_http_date() {
433        let now = DateTime::parse_from_rfc2822("Wed, 21 Oct 2015 07:27:00 GMT")
434            .expect("valid rfc2822")
435            .with_timezone(&Utc);
436
437        assert_eq!(
438            retry_after_delay_at("Retry-After: Wed, 21 Oct 2015 07:28:00 GMT", now),
439            Some(Duration::from_secs(60))
440        );
441    }
442
443    #[test]
444    fn retry_after_delay_past_http_date_is_zero() {
445        let now = DateTime::parse_from_rfc2822("Wed, 21 Oct 2015 07:29:00 GMT")
446            .expect("valid rfc2822")
447            .with_timezone(&Utc);
448
449        assert_eq!(
450            retry_after_delay_at("Retry-After: Wed, 21 Oct 2015 07:28:00 GMT", now),
451            Some(Duration::ZERO)
452        );
453    }
454
455    #[test]
456    fn retry_after_delay_ignores_invalid_headers() {
457        assert_eq!(retry_after_delay("Retry-After:"), None);
458        assert_eq!(retry_after_delay("X-Retry-After: 60"), None);
459        assert_eq!(retry_after_delay("retry-after: not a date"), None);
460        assert_eq!(retry_after_delay("HTTP 429 Too Many Requests"), None);
461    }
462
463    #[test]
464    fn registry_profile_aware_backoff_honors_retry_after_floor() {
465        let d = registry_profile_aware_backoff(
466            Duration::from_secs(10),
467            Duration::from_secs(120),
468            1,
469            RetryStrategyType::Exponential,
470            0.0,
471            RegistryProfile::unknown(),
472            PublishRegime::Update,
473            "HTTP 429 Too Many Requests\nRetry-After: 75",
474        );
475
476        assert_eq!(d, Duration::from_secs(75));
477    }
478
479    #[test]
480    fn registry_profile_aware_backoff_uses_larger_floor() {
481        let d = registry_profile_aware_backoff(
482            Duration::from_secs(10),
483            Duration::from_secs(120),
484            1,
485            RetryStrategyType::Exponential,
486            0.0,
487            RegistryProfile::crates_io(),
488            PublishRegime::FirstPublish,
489            "HTTP 429 Too Many Requests\nRetry-After: 30",
490        );
491
492        assert_eq!(d, CRATES_IO_NEW_CRATE_WINDOW);
493    }
494
495    #[test]
496    fn unknown_registry_profile_keeps_generic_backoff() {
497        let d = registry_profile_aware_backoff(
498            Duration::from_secs(10),
499            Duration::from_secs(120),
500            1,
501            RetryStrategyType::Exponential,
502            0.0,
503            RegistryProfile::unknown(),
504            PublishRegime::FirstPublish,
505            "HTTP 429 Too Many Requests",
506        );
507
508        assert!(d < CRATES_IO_NEW_CRATE_WINDOW);
509    }
510
511    #[test]
512    fn registry_aware_backoff_extends_for_new_crate_rate_limit() {
513        let short = Duration::from_secs(10);
514        let d = registry_aware_backoff(
515            short,
516            Duration::from_secs(120),
517            1,
518            RetryStrategyType::Exponential,
519            0.0,
520            true,
521            "HTTP 429 Too Many Requests",
522        );
523        assert!(
524            d >= CRATES_IO_NEW_CRATE_WINDOW,
525            "expected delay floored at 10 min for new-crate rate limit; got {:?}",
526            d
527        );
528    }
529
530    #[test]
531    fn registry_aware_backoff_unchanged_for_existing_crate_rate_limit() {
532        // Existing crate hitting a 429 uses the higher per-minute budget;
533        // Shipper should NOT over-extend to the 10-min new-crate window.
534        let base = Duration::from_secs(2);
535        let max = Duration::from_secs(120);
536        let d = registry_aware_backoff(
537            base,
538            max,
539            1,
540            RetryStrategyType::Exponential,
541            0.0,
542            false,
543            "HTTP 429 Too Many Requests",
544        );
545        assert!(
546            d < CRATES_IO_NEW_CRATE_WINDOW,
547            "expected generic backoff for existing crate; got {:?}",
548            d
549        );
550    }
551
552    #[test]
553    fn registry_aware_backoff_unchanged_for_new_crate_non_rate_limit() {
554        // New crate hit a non-rate-limit retryable (network blip); we should
555        // NOT wait 10 min for a transient network issue.
556        let base = Duration::from_secs(2);
557        let max = Duration::from_secs(120);
558        let d = registry_aware_backoff(
559            base,
560            max,
561            1,
562            RetryStrategyType::Exponential,
563            0.0,
564            true,
565            "connection reset by peer",
566        );
567        assert!(
568            d < CRATES_IO_NEW_CRATE_WINDOW,
569            "expected generic backoff for network error; got {:?}",
570            d
571        );
572    }
573
574    #[test]
575    fn registry_aware_backoff_respects_longer_generic_when_it_exceeds_window() {
576        // If the generic exponential delay is already >= 10 min, don't floor
577        // downward — use whichever is larger.
578        let base = Duration::from_secs(60 * 20); // 20 min
579        let max = Duration::from_secs(60 * 30);
580        let d = registry_aware_backoff(base, max, 1, RetryStrategyType::Constant, 0.0, true, "429");
581        assert!(
582            d >= base,
583            "expected to keep the larger delay; got {:?}, base {:?}",
584            d,
585            base
586        );
587    }
588
589    fn make_progress(
590        name: &str,
591        version: &str,
592        state: PackageState,
593    ) -> shipper_types::PackageProgress {
594        shipper_types::PackageProgress {
595            name: name.to_string(),
596            version: version.to_string(),
597            attempts: 0,
598            state,
599            last_updated_at: Utc::now(),
600        }
601    }
602
603    fn sample_state(key: &str, state: PackageState) -> shipper_types::ExecutionState {
604        shipper_types::ExecutionState {
605            state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
606            plan_id: "plan-sample".to_string(),
607            registry: shipper_types::Registry::crates_io(),
608            created_at: Utc::now(),
609            updated_at: Utc::now(),
610            attempt_history: Vec::new(),
611            packages: BTreeMap::from([(key.to_string(), make_progress("demo", "0.1.0", state))]),
612        }
613    }
614
615    #[test]
616    fn resolves_state_dir_relative_paths() {
617        let root = PathBuf::from("root");
618        let rel = resolve_state_dir(&root, &PathBuf::from(".shipper"));
619        assert_eq!(rel, root.join(".shipper"));
620
621        #[cfg(windows)]
622        {
623            let abs = PathBuf::from(r"C:\x\state");
624            assert_eq!(resolve_state_dir(&root, &abs), abs);
625        }
626        #[cfg(not(windows))]
627        {
628            let abs = PathBuf::from("/x/state");
629            assert_eq!(resolve_state_dir(&root, &abs), abs);
630        }
631    }
632
633    #[test]
634    fn pkg_key_and_short_state_cover_all_variants() {
635        assert_eq!(pkg_key("a", "1.2.3"), "a@1.2.3");
636        assert_eq!(
637            short_state(&shipper_types::PackageState::Pending),
638            "pending"
639        );
640        assert_eq!(
641            short_state(&shipper_types::PackageState::Uploaded),
642            "uploaded"
643        );
644        assert_eq!(
645            short_state(&shipper_types::PackageState::Published),
646            "published"
647        );
648        assert_eq!(
649            short_state(&shipper_types::PackageState::Skipped { reason: "x".into() }),
650            "skipped"
651        );
652        assert_eq!(
653            short_state(&shipper_types::PackageState::Failed {
654                class: ErrorClass::Permanent,
655                message: "x".into()
656            }),
657            "failed"
658        );
659        assert_eq!(
660            short_state(&shipper_types::PackageState::Ambiguous {
661                message: "x".into()
662            }),
663            "ambiguous"
664        );
665    }
666
667    #[test]
668    fn classify_cargo_failure_covers_retryable_permanent_and_ambiguous() {
669        let retryable = classify_cargo_failure("HTTP 429 too many requests", "");
670        assert_eq!(retryable.0, ErrorClass::Retryable);
671
672        let permanent = classify_cargo_failure("permission denied", "");
673        assert_eq!(permanent.0, ErrorClass::Permanent);
674
675        let ambiguous = classify_cargo_failure("strange output", "");
676        assert_eq!(ambiguous.0, ErrorClass::Ambiguous);
677    }
678
679    #[test]
680    fn update_state_updates_timestamp_and_persists() {
681        let mut st = sample_state("demo@0.1.0", shipper_types::PackageState::Pending);
682        let td = tempdir().expect("tempdir");
683        let state_dir = td.path();
684
685        let before = st.updated_at;
686        std::thread::sleep(std::time::Duration::from_millis(2));
687
688        update_state(
689            &mut st,
690            state_dir,
691            "demo@0.1.0",
692            shipper_types::PackageState::Uploaded,
693        )
694        .expect("state update");
695
696        assert!(st.updated_at >= before);
697        let loaded = crate::state::execution_state::load_state(state_dir)
698            .expect("load state")
699            .expect("state exists");
700        assert!(matches!(
701            loaded.packages.get("demo@0.1.0").expect("pkg").state,
702            shipper_types::PackageState::Uploaded
703        ));
704    }
705
706    #[test]
707    fn update_state_fails_for_missing_package() {
708        let mut st = sample_state("demo@0.1.0", shipper_types::PackageState::Pending);
709        let td = tempdir().expect("tempdir");
710        assert!(
711            update_state(
712                &mut st,
713                td.path(),
714                "missing",
715                shipper_types::PackageState::Uploaded,
716            )
717            .is_err()
718        );
719    }
720
721    #[test]
722    fn update_state_locked_is_noop_for_missing_package() {
723        let mut st = sample_state("demo@0.1.0", shipper_types::PackageState::Pending);
724        let before = st.updated_at;
725        std::thread::sleep(std::time::Duration::from_millis(2));
726        update_state_locked(&mut st, "missing", shipper_types::PackageState::Published);
727        assert_eq!(
728            st.packages.get("demo@0.1.0").expect("pkg").state,
729            shipper_types::PackageState::Pending
730        );
731        assert!(st.updated_at >= before);
732    }
733
734    #[test]
735    fn backoff_delay_is_bounded_with_jitter() {
736        let base = std::time::Duration::from_millis(100);
737        let max = std::time::Duration::from_millis(500);
738        let d1 = backoff_delay(
739            base,
740            max,
741            1,
742            shipper_retry::RetryStrategyType::Exponential,
743            0.5,
744        );
745        let d20 = backoff_delay(
746            base,
747            max,
748            20,
749            shipper_retry::RetryStrategyType::Exponential,
750            0.5,
751        );
752
753        assert!(d1 >= std::time::Duration::from_millis(50));
754        assert!(d1 <= std::time::Duration::from_millis(150));
755        assert!(d20 >= std::time::Duration::from_millis(250));
756        assert!(d20 <= std::time::Duration::from_millis(750));
757    }
758
759    // -- State transitions: success flow --
760
761    #[test]
762    fn update_state_locked_pending_to_uploaded() {
763        let key = "a@1.0.0";
764        let mut st = sample_state(key, PackageState::Pending);
765        update_state_locked(&mut st, key, PackageState::Uploaded);
766        assert_eq!(st.packages[key].state, PackageState::Uploaded);
767    }
768
769    #[test]
770    fn update_state_locked_uploaded_to_published() {
771        let key = "a@1.0.0";
772        let mut st = sample_state(key, PackageState::Uploaded);
773        update_state_locked(&mut st, key, PackageState::Published);
774        assert_eq!(st.packages[key].state, PackageState::Published);
775    }
776
777    // -- State transitions: failure flow --
778
779    #[test]
780    fn update_state_locked_pending_to_failed_permanent() {
781        let key = "a@1.0.0";
782        let mut st = sample_state(key, PackageState::Pending);
783        let fail = PackageState::Failed {
784            class: ErrorClass::Permanent,
785            message: "denied".into(),
786        };
787        update_state_locked(&mut st, key, fail.clone());
788        assert_eq!(st.packages[key].state, fail);
789    }
790
791    #[test]
792    fn update_state_locked_pending_to_failed_retryable() {
793        let key = "a@1.0.0";
794        let mut st = sample_state(key, PackageState::Pending);
795        let fail = PackageState::Failed {
796            class: ErrorClass::Retryable,
797            message: "rate limited".into(),
798        };
799        update_state_locked(&mut st, key, fail.clone());
800        assert_eq!(st.packages[key].state, fail);
801    }
802
803    #[test]
804    fn update_state_locked_pending_to_ambiguous() {
805        let key = "a@1.0.0";
806        let mut st = sample_state(
807            key,
808            PackageState::Ambiguous {
809                message: "timeout".into(),
810            },
811        );
812        // Ambiguous can transition to published on verification
813        update_state_locked(&mut st, key, PackageState::Published);
814        assert_eq!(st.packages[key].state, PackageState::Published);
815    }
816
817    // -- State transitions: skip flow --
818
819    #[test]
820    fn update_state_locked_pending_to_skipped() {
821        let key = "a@1.0.0";
822        let mut st = sample_state(key, PackageState::Pending);
823        let skip = PackageState::Skipped {
824            reason: "already published".into(),
825        };
826        update_state_locked(&mut st, key, skip.clone());
827        assert_eq!(st.packages[key].state, skip);
828    }
829
830    // -- Timestamp correctness --
831
832    #[test]
833    fn update_state_locked_updates_package_timestamp() {
834        let key = "a@1.0.0";
835        let mut st = sample_state(key, PackageState::Pending);
836        let pkg_ts_before = st.packages[key].last_updated_at;
837        std::thread::sleep(std::time::Duration::from_millis(2));
838        update_state_locked(&mut st, key, PackageState::Published);
839        assert!(st.packages[key].last_updated_at > pkg_ts_before);
840    }
841
842    #[test]
843    fn update_state_locked_updates_global_timestamp_even_for_missing_key() {
844        let mut st = sample_state("a@1.0.0", PackageState::Pending);
845        let ts_before = st.updated_at;
846        std::thread::sleep(std::time::Duration::from_millis(2));
847        update_state_locked(&mut st, "nonexistent", PackageState::Published);
848        assert!(st.updated_at >= ts_before);
849    }
850
851    // -- Edge case: empty package list --
852
853    #[test]
854    fn update_state_on_empty_packages_returns_error() {
855        let mut st = shipper_types::ExecutionState {
856            state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
857            plan_id: "plan-empty".to_string(),
858            registry: shipper_types::Registry::crates_io(),
859            created_at: Utc::now(),
860            updated_at: Utc::now(),
861            attempt_history: Vec::new(),
862            packages: BTreeMap::new(),
863        };
864        let td = tempdir().expect("tempdir");
865        assert!(update_state(&mut st, td.path(), "any@1.0.0", PackageState::Published).is_err());
866    }
867
868    #[test]
869    fn update_state_locked_on_empty_packages_is_noop() {
870        let mut st = shipper_types::ExecutionState {
871            state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
872            plan_id: "plan-empty".to_string(),
873            registry: shipper_types::Registry::crates_io(),
874            created_at: Utc::now(),
875            updated_at: Utc::now(),
876            attempt_history: Vec::new(),
877            packages: BTreeMap::new(),
878        };
879        // Should not panic
880        update_state_locked(&mut st, "any@1.0.0", PackageState::Published);
881        assert!(st.packages.is_empty());
882    }
883
884    // -- Edge case: multiple packages, all-skipped --
885
886    fn multi_state(entries: &[(&str, PackageState)]) -> ExecutionState {
887        let mut packages = BTreeMap::new();
888        for (key, state) in entries {
889            packages.insert(
890                key.to_string(),
891                make_progress(key.split('@').next().unwrap(), "1.0.0", state.clone()),
892            );
893        }
894        ExecutionState {
895            state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
896            plan_id: "plan-multi".to_string(),
897            registry: shipper_types::Registry::crates_io(),
898            created_at: Utc::now(),
899            updated_at: Utc::now(),
900            attempt_history: Vec::new(),
901            packages,
902        }
903    }
904
905    #[test]
906    fn all_packages_skipped() {
907        let skip = |r: &str| PackageState::Skipped { reason: r.into() };
908        let mut st = multi_state(&[
909            ("a@1.0.0", skip("already published")),
910            ("b@1.0.0", skip("already published")),
911            ("c@1.0.0", skip("yanked")),
912        ]);
913        // All already skipped — updating one to published still works
914        update_state_locked(&mut st, "a@1.0.0", PackageState::Published);
915        assert_eq!(st.packages["a@1.0.0"].state, PackageState::Published);
916        assert!(matches!(
917            st.packages["b@1.0.0"].state,
918            PackageState::Skipped { .. }
919        ));
920    }
921
922    #[test]
923    fn all_packages_failed() {
924        let fail = |m: &str| PackageState::Failed {
925            class: ErrorClass::Permanent,
926            message: m.into(),
927        };
928        let st = multi_state(&[("a@1.0.0", fail("denied")), ("b@1.0.0", fail("denied"))]);
929        let failed_count = st
930            .packages
931            .values()
932            .filter(|p| matches!(p.state, PackageState::Failed { .. }))
933            .count();
934        assert_eq!(failed_count, 2);
935    }
936
937    // -- Error classification accuracy --
938
939    #[test]
940    fn classify_rate_limit_variants() {
941        // HTTP 429
942        let (class, _) = classify_cargo_failure("error: 429 too many requests", "");
943        assert_eq!(class, ErrorClass::Retryable);
944
945        // timeout
946        let (class, _) = classify_cargo_failure("connection timeout", "");
947        assert_eq!(class, ErrorClass::Retryable);
948    }
949
950    #[test]
951    fn classify_auth_failures_as_permanent() {
952        let (class, _) = classify_cargo_failure("error: not authorized", "");
953        assert_eq!(class, ErrorClass::Permanent);
954
955        let (class, _) = classify_cargo_failure("token is invalid", "");
956        assert_eq!(class, ErrorClass::Permanent);
957    }
958
959    #[test]
960    fn classify_empty_output_as_ambiguous() {
961        let (class, _) = classify_cargo_failure("", "");
962        assert_eq!(class, ErrorClass::Ambiguous);
963    }
964
965    #[test]
966    fn classify_already_uploaded_as_permanent() {
967        let (class, _) =
968            classify_cargo_failure("error: crate version `1.0.0` is already uploaded", "");
969        assert_eq!(class, ErrorClass::Permanent);
970    }
971
972    #[test]
973    fn classify_network_errors_as_retryable() {
974        let (class, _) = classify_cargo_failure("connection reset by peer", "");
975        assert_eq!(class, ErrorClass::Retryable);
976
977        let (class, _) = classify_cargo_failure("network unreachable", "");
978        assert_eq!(class, ErrorClass::Retryable);
979    }
980
981    #[test]
982    fn classify_returns_nonempty_message() {
983        let (_, msg) = classify_cargo_failure("some unknown error text", "");
984        assert!(
985            !msg.is_empty(),
986            "classification message should not be empty"
987        );
988    }
989
990    // -- Retry / backoff delay logic --
991
992    #[test]
993    fn backoff_immediate_strategy_returns_zero() {
994        let d = backoff_delay(
995            Duration::from_millis(100),
996            Duration::from_secs(10),
997            5,
998            shipper_retry::RetryStrategyType::Immediate,
999            0.0,
1000        );
1001        assert_eq!(d, Duration::ZERO);
1002    }
1003
1004    #[test]
1005    fn backoff_constant_strategy_returns_base() {
1006        let base = Duration::from_millis(200);
1007        let d = backoff_delay(
1008            base,
1009            Duration::from_secs(10),
1010            5,
1011            shipper_retry::RetryStrategyType::Constant,
1012            0.0,
1013        );
1014        assert_eq!(d, base);
1015    }
1016
1017    #[test]
1018    fn backoff_linear_strategy_scales_with_attempt() {
1019        let base = Duration::from_millis(100);
1020        let d1 = backoff_delay(
1021            base,
1022            Duration::from_secs(10),
1023            1,
1024            shipper_retry::RetryStrategyType::Linear,
1025            0.0,
1026        );
1027        let d3 = backoff_delay(
1028            base,
1029            Duration::from_secs(10),
1030            3,
1031            shipper_retry::RetryStrategyType::Linear,
1032            0.0,
1033        );
1034        assert_eq!(d1, Duration::from_millis(100));
1035        assert_eq!(d3, Duration::from_millis(300));
1036    }
1037
1038    #[test]
1039    fn backoff_exponential_without_jitter_doubles() {
1040        let base = Duration::from_millis(100);
1041        let max = Duration::from_secs(60);
1042        let d1 = backoff_delay(
1043            base,
1044            max,
1045            1,
1046            shipper_retry::RetryStrategyType::Exponential,
1047            0.0,
1048        );
1049        let d2 = backoff_delay(
1050            base,
1051            max,
1052            2,
1053            shipper_retry::RetryStrategyType::Exponential,
1054            0.0,
1055        );
1056        let d3 = backoff_delay(
1057            base,
1058            max,
1059            3,
1060            shipper_retry::RetryStrategyType::Exponential,
1061            0.0,
1062        );
1063        assert_eq!(d1, Duration::from_millis(100));
1064        assert_eq!(d2, Duration::from_millis(200));
1065        assert_eq!(d3, Duration::from_millis(400));
1066    }
1067
1068    #[test]
1069    fn backoff_clamped_to_max() {
1070        let base = Duration::from_millis(100);
1071        let max = Duration::from_millis(300);
1072        let d = backoff_delay(
1073            base,
1074            max,
1075            10,
1076            shipper_retry::RetryStrategyType::Exponential,
1077            0.0,
1078        );
1079        assert!(d <= max, "delay {d:?} should be <= max {max:?}");
1080    }
1081
1082    #[test]
1083    fn backoff_zero_jitter_is_deterministic() {
1084        let base = Duration::from_millis(100);
1085        let max = Duration::from_secs(10);
1086        let a = backoff_delay(
1087            base,
1088            max,
1089            3,
1090            shipper_retry::RetryStrategyType::Exponential,
1091            0.0,
1092        );
1093        let b = backoff_delay(
1094            base,
1095            max,
1096            3,
1097            shipper_retry::RetryStrategyType::Exponential,
1098            0.0,
1099        );
1100        assert_eq!(a, b);
1101    }
1102
1103    #[test]
1104    fn backoff_high_attempt_does_not_overflow() {
1105        let base = Duration::from_millis(100);
1106        let max = Duration::from_secs(60);
1107        // Very high attempt number should not panic
1108        let d = backoff_delay(
1109            base,
1110            max,
1111            u32::MAX,
1112            shipper_retry::RetryStrategyType::Exponential,
1113            1.0,
1114        );
1115        assert!(d <= max.mul_f64(1.5 + 1.0)); // max + full jitter headroom
1116    }
1117
1118    // -- pkg_key edge cases --
1119
1120    #[test]
1121    fn pkg_key_with_scoped_name() {
1122        assert_eq!(pkg_key("@scope/pkg", "2.0.0-rc.1"), "@scope/pkg@2.0.0-rc.1");
1123    }
1124
1125    #[test]
1126    fn pkg_key_empty_inputs() {
1127        assert_eq!(pkg_key("", ""), "@");
1128    }
1129
1130    // -- Persist round-trip for each terminal state --
1131
1132    #[test]
1133    fn update_state_persists_skipped() {
1134        let key = "s@1.0.0";
1135        let mut st = sample_state(key, PackageState::Pending);
1136        let td = tempdir().expect("tempdir");
1137        update_state(
1138            &mut st,
1139            td.path(),
1140            key,
1141            PackageState::Skipped {
1142                reason: "already on registry".into(),
1143            },
1144        )
1145        .expect("persist");
1146        let loaded = crate::state::execution_state::load_state(td.path())
1147            .unwrap()
1148            .unwrap();
1149        assert!(matches!(
1150            loaded.packages[key].state,
1151            PackageState::Skipped { .. }
1152        ));
1153    }
1154
1155    #[test]
1156    fn update_state_persists_failed() {
1157        let key = "f@1.0.0";
1158        let mut st = sample_state(key, PackageState::Pending);
1159        let td = tempdir().expect("tempdir");
1160        update_state(
1161            &mut st,
1162            td.path(),
1163            key,
1164            PackageState::Failed {
1165                class: ErrorClass::Ambiguous,
1166                message: "timeout".into(),
1167            },
1168        )
1169        .expect("persist");
1170        let loaded = crate::state::execution_state::load_state(td.path())
1171            .unwrap()
1172            .unwrap();
1173        match &loaded.packages[key].state {
1174            PackageState::Failed { class, message } => {
1175                assert_eq!(*class, ErrorClass::Ambiguous);
1176                assert_eq!(message, "timeout");
1177            }
1178            other => panic!("expected Failed, got {other:?}"),
1179        }
1180    }
1181
1182    #[test]
1183    fn update_state_persists_ambiguous() {
1184        let key = "x@1.0.0";
1185        let mut st = sample_state(key, PackageState::Pending);
1186        let td = tempdir().expect("tempdir");
1187        update_state(
1188            &mut st,
1189            td.path(),
1190            key,
1191            PackageState::Ambiguous {
1192                message: "unknown".into(),
1193            },
1194        )
1195        .expect("persist");
1196        let loaded = crate::state::execution_state::load_state(td.path())
1197            .unwrap()
1198            .unwrap();
1199        assert!(matches!(
1200            loaded.packages[key].state,
1201            PackageState::Ambiguous { .. }
1202        ));
1203    }
1204
1205    // -- resolve_state_dir edge cases --
1206
1207    #[test]
1208    fn resolve_state_dir_empty_relative() {
1209        let root = PathBuf::from("workspace");
1210        let result = resolve_state_dir(&root, &PathBuf::from(""));
1211        assert_eq!(result, PathBuf::from("workspace"));
1212    }
1213
1214    #[test]
1215    fn resolve_state_dir_nested_relative() {
1216        let root = PathBuf::from("workspace");
1217        let result = resolve_state_dir(&root, &PathBuf::from("a/b/c"));
1218        assert_eq!(result, root.join("a/b/c"));
1219    }
1220
1221    // -- Multiple package state tracking --
1222
1223    #[test]
1224    fn multi_package_independent_transitions() {
1225        let mut st = multi_state(&[
1226            ("a@1.0.0", PackageState::Pending),
1227            ("b@2.0.0", PackageState::Pending),
1228            ("c@3.0.0", PackageState::Pending),
1229        ]);
1230        update_state_locked(&mut st, "a@1.0.0", PackageState::Published);
1231        update_state_locked(
1232            &mut st,
1233            "b@2.0.0",
1234            PackageState::Failed {
1235                class: ErrorClass::Retryable,
1236                message: "429".into(),
1237            },
1238        );
1239        update_state_locked(
1240            &mut st,
1241            "c@3.0.0",
1242            PackageState::Skipped {
1243                reason: "dep failed".into(),
1244            },
1245        );
1246        assert_eq!(st.packages["a@1.0.0"].state, PackageState::Published);
1247        assert!(matches!(
1248            st.packages["b@2.0.0"].state,
1249            PackageState::Failed { .. }
1250        ));
1251        assert!(matches!(
1252            st.packages["c@3.0.0"].state,
1253            PackageState::Skipped { .. }
1254        ));
1255    }
1256
1257    #[test]
1258    fn multi_package_persist_round_trip() {
1259        let mut st = multi_state(&[
1260            ("a@1.0.0", PackageState::Pending),
1261            ("b@2.0.0", PackageState::Pending),
1262        ]);
1263        let td = tempdir().expect("tempdir");
1264        update_state(&mut st, td.path(), "a@1.0.0", PackageState::Published).unwrap();
1265        update_state(
1266            &mut st,
1267            td.path(),
1268            "b@2.0.0",
1269            PackageState::Skipped {
1270                reason: "skip".into(),
1271            },
1272        )
1273        .unwrap();
1274        let loaded = crate::state::execution_state::load_state(td.path())
1275            .unwrap()
1276            .unwrap();
1277        assert_eq!(loaded.packages["a@1.0.0"].state, PackageState::Published);
1278        assert!(matches!(
1279            loaded.packages["b@2.0.0"].state,
1280            PackageState::Skipped { .. }
1281        ));
1282    }
1283
1284    // -- Property tests --
1285
1286    fn ascii_text() -> impl Strategy<Value = String> {
1287        proptest::collection::vec(any::<char>(), 0..128)
1288            .prop_map(|chars| chars.into_iter().collect())
1289    }
1290
1291    fn arb_error_class() -> impl Strategy<Value = ErrorClass> {
1292        prop_oneof![
1293            Just(ErrorClass::Retryable),
1294            Just(ErrorClass::Permanent),
1295            Just(ErrorClass::Ambiguous),
1296        ]
1297    }
1298
1299    fn arb_package_state() -> impl Strategy<Value = PackageState> {
1300        prop_oneof![
1301            Just(PackageState::Pending),
1302            Just(PackageState::Uploaded),
1303            Just(PackageState::Published),
1304            ".*".prop_map(|r| PackageState::Skipped { reason: r }),
1305            (arb_error_class(), ".*").prop_map(|(c, m)| PackageState::Failed {
1306                class: c,
1307                message: m
1308            }),
1309            ".*".prop_map(|m| PackageState::Ambiguous { message: m }),
1310        ]
1311    }
1312
1313    proptest! {
1314        #[test]
1315        fn classify_is_deterministic_with_ascii(stderr in ascii_text(), stdout in ascii_text()) {
1316            let first = classify_cargo_failure(&stderr, &stdout);
1317            let second = classify_cargo_failure(&stderr, &stdout);
1318            prop_assert_eq!(first, second);
1319        }
1320
1321        #[test]
1322        fn classify_is_case_insensitive_with_ascii(stderr in ascii_text(), stdout in ascii_text()) {
1323            let lower = classify_cargo_failure(&stderr.to_ascii_lowercase(), &stdout.to_ascii_lowercase());
1324            let upper = classify_cargo_failure(&stderr.to_ascii_uppercase(), &stdout.to_ascii_uppercase());
1325            prop_assert_eq!(lower.0, upper.0);
1326        }
1327
1328        #[test]
1329        fn classify_always_returns_valid_class(stderr in ascii_text(), stdout in ascii_text()) {
1330            let (class, msg) = classify_cargo_failure(&stderr, &stdout);
1331            prop_assert!(matches!(class, ErrorClass::Retryable | ErrorClass::Permanent | ErrorClass::Ambiguous));
1332            prop_assert!(!msg.is_empty());
1333        }
1334
1335        #[test]
1336        fn short_state_returns_known_label(state in arb_package_state()) {
1337            let label = short_state(&state);
1338            prop_assert!(["pending", "uploaded", "published", "skipped", "failed", "ambiguous"].contains(&label));
1339        }
1340
1341        #[test]
1342        fn update_state_locked_preserves_other_packages(
1343            state_a in arb_package_state(),
1344            state_b in arb_package_state(),
1345        ) {
1346            let mut st = multi_state(&[
1347                ("a@1.0.0", PackageState::Pending),
1348                ("b@1.0.0", PackageState::Pending),
1349            ]);
1350            update_state_locked(&mut st, "a@1.0.0", state_a);
1351            update_state_locked(&mut st, "b@1.0.0", state_b);
1352            // Both packages still exist
1353            prop_assert!(st.packages.contains_key("a@1.0.0"));
1354            prop_assert!(st.packages.contains_key("b@1.0.0"));
1355            prop_assert_eq!(st.packages.len(), 2);
1356        }
1357
1358        #[test]
1359        fn backoff_never_exceeds_max_with_jitter(
1360            attempt in 1..100u32,
1361            jitter in 0.0..1.0f64,
1362        ) {
1363            let base = Duration::from_millis(100);
1364            let max = Duration::from_millis(500);
1365            let d = backoff_delay(base, max, attempt, shipper_retry::RetryStrategyType::Exponential, jitter);
1366            // With jitter up to 1.0, max theoretical is max + max*jitter + epsilon for fp rounding
1367            let upper = max + max.mul_f64(jitter) + Duration::from_millis(1);
1368            prop_assert!(d <= upper, "delay {:?} exceeded upper bound {:?}", d, upper);
1369        }
1370
1371        #[test]
1372        fn pkg_key_contains_at_separator(name in "[a-z_-]{1,30}", version in "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}") {
1373            let key = pkg_key(&name, &version);
1374            prop_assert!(key.contains('@'));
1375            prop_assert_eq!(key, format!("{name}@{version}"));
1376        }
1377
1378        // -- Retry logic: monotonicity and range --
1379
1380        #[test]
1381        fn exponential_monotonic_without_jitter(
1382            base_ms in 1u64..10_000,
1383            extra_ms in 1u64..100_000,
1384            a in 1u32..50,
1385            b in 1u32..50,
1386        ) {
1387            let base = Duration::from_millis(base_ms);
1388            let max = Duration::from_millis(base_ms + extra_ms);
1389            let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
1390            let d_lo = backoff_delay(base, max, lo, shipper_retry::RetryStrategyType::Exponential, 0.0);
1391            let d_hi = backoff_delay(base, max, hi, shipper_retry::RetryStrategyType::Exponential, 0.0);
1392            prop_assert!(d_hi >= d_lo, "exp backoff not monotonic: attempt {hi} ({d_hi:?}) < attempt {lo} ({d_lo:?})");
1393        }
1394
1395        #[test]
1396        fn linear_monotonic_without_jitter(
1397            base_ms in 1u64..10_000,
1398            extra_ms in 1u64..100_000,
1399            a in 1u32..50,
1400            b in 1u32..50,
1401        ) {
1402            let base = Duration::from_millis(base_ms);
1403            let max = Duration::from_millis(base_ms + extra_ms);
1404            let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
1405            let d_lo = backoff_delay(base, max, lo, shipper_retry::RetryStrategyType::Linear, 0.0);
1406            let d_hi = backoff_delay(base, max, hi, shipper_retry::RetryStrategyType::Linear, 0.0);
1407            prop_assert!(d_hi >= d_lo, "linear backoff not monotonic: attempt {hi} ({d_hi:?}) < attempt {lo} ({d_lo:?})");
1408        }
1409
1410        #[test]
1411        fn immediate_always_zero_regardless_of_params(
1412            base_ms in 0u64..100_000,
1413            max_ms in 0u64..300_000,
1414            attempt in 0u32..1000,
1415            jitter in 0.0..1.0f64,
1416        ) {
1417            let d = backoff_delay(
1418                Duration::from_millis(base_ms),
1419                Duration::from_millis(max_ms),
1420                attempt,
1421                shipper_retry::RetryStrategyType::Immediate,
1422                jitter,
1423            );
1424            prop_assert_eq!(d, Duration::ZERO);
1425        }
1426
1427        #[test]
1428        fn constant_same_delay_regardless_of_attempt(
1429            base_ms in 0u64..100_000,
1430            max_ms in 0u64..300_000,
1431            a in 1u32..100,
1432            b in 1u32..100,
1433        ) {
1434            let base = Duration::from_millis(base_ms);
1435            let max = Duration::from_millis(max_ms);
1436            let d_a = backoff_delay(base, max, a, shipper_retry::RetryStrategyType::Constant, 0.0);
1437            let d_b = backoff_delay(base, max, b, shipper_retry::RetryStrategyType::Constant, 0.0);
1438            prop_assert_eq!(d_a, d_b);
1439            prop_assert_eq!(d_a, base.min(max));
1440        }
1441
1442        // -- State transitions: invariants --
1443
1444        #[test]
1445        fn update_state_locked_sets_exact_state(state in arb_package_state()) {
1446            let key = "t@1.0.0";
1447            let mut st = sample_state(key, PackageState::Pending);
1448            update_state_locked(&mut st, key, state.clone());
1449            prop_assert_eq!(&st.packages[key].state, &state);
1450        }
1451
1452        #[test]
1453        fn update_state_locked_timestamp_never_decreases(state in arb_package_state()) {
1454            let key = "t@1.0.0";
1455            let mut st = sample_state(key, PackageState::Pending);
1456            let before = st.updated_at;
1457            update_state_locked(&mut st, key, state);
1458            prop_assert!(st.updated_at >= before);
1459        }
1460
1461        #[test]
1462        fn sequential_transitions_preserve_count(
1463            s1 in arb_package_state(),
1464            s2 in arb_package_state(),
1465            s3 in arb_package_state(),
1466        ) {
1467            let mut st = multi_state(&[
1468                ("a@1.0.0", PackageState::Pending),
1469                ("b@1.0.0", PackageState::Pending),
1470                ("c@1.0.0", PackageState::Pending),
1471            ]);
1472            update_state_locked(&mut st, "a@1.0.0", s1);
1473            update_state_locked(&mut st, "b@1.0.0", s2);
1474            update_state_locked(&mut st, "c@1.0.0", s3);
1475            prop_assert_eq!(st.packages.len(), 3);
1476        }
1477
1478        // -- Error categorization: mapping correctness --
1479
1480        #[test]
1481        fn classify_cargo_failure_preserves_class_mapping(
1482            stderr in ascii_text(),
1483            stdout in ascii_text(),
1484        ) {
1485            let internal = shipper_cargo_failure::classify_publish_failure(&stderr, &stdout);
1486            let (mapped_class, _) = classify_cargo_failure(&stderr, &stdout);
1487            let expected = match internal.class {
1488                shipper_cargo_failure::CargoFailureClass::Retryable => ErrorClass::Retryable,
1489                shipper_cargo_failure::CargoFailureClass::Permanent => ErrorClass::Permanent,
1490                shipper_cargo_failure::CargoFailureClass::Ambiguous => ErrorClass::Ambiguous,
1491            };
1492            prop_assert_eq!(mapped_class, expected);
1493        }
1494
1495        #[test]
1496        fn classify_stderr_stdout_symmetric(stderr in ascii_text(), stdout in ascii_text()) {
1497            let normal = classify_cargo_failure(&stderr, &stdout);
1498            let swapped = classify_cargo_failure(&stdout, &stderr);
1499            prop_assert_eq!(normal.0, swapped.0, "classification differs when swapping stderr/stdout");
1500        }
1501
1502        // -- Timeout / overflow safety --
1503
1504        #[test]
1505        fn backoff_arbitrary_strategy_never_panics(
1506            base_ms in 0u64..500_000,
1507            max_ms in 0u64..500_000,
1508            attempt in 0u32..10_000,
1509            strategy_idx in 0u8..4,
1510            jitter in 0.0..1.0f64,
1511        ) {
1512            let strategy = match strategy_idx {
1513                0 => shipper_retry::RetryStrategyType::Immediate,
1514                1 => shipper_retry::RetryStrategyType::Exponential,
1515                2 => shipper_retry::RetryStrategyType::Linear,
1516                _ => shipper_retry::RetryStrategyType::Constant,
1517            };
1518            let d = backoff_delay(
1519                Duration::from_millis(base_ms),
1520                Duration::from_millis(max_ms),
1521                attempt,
1522                strategy,
1523                jitter,
1524            );
1525            prop_assert!(d.as_secs() < u64::MAX);
1526        }
1527
1528        #[test]
1529        fn backoff_base_exceeds_max_clamps(
1530            base_ms in 100u64..500_000,
1531            delta in 1u64..100_000,
1532            attempt in 1u32..100,
1533            jitter in 0.0..1.0f64,
1534        ) {
1535            let base = Duration::from_millis(base_ms);
1536            let max = Duration::from_millis(base_ms.saturating_sub(delta).max(1));
1537            let d = backoff_delay(base, max, attempt, shipper_retry::RetryStrategyType::Exponential, jitter);
1538            let upper = max + max.mul_f64(jitter) + Duration::from_millis(1);
1539            prop_assert!(d <= upper, "delay {d:?} exceeded upper bound {upper:?} when base > max");
1540        }
1541
1542        #[test]
1543        fn backoff_large_attempt_all_strategies(
1544            attempt in 10_000u32..=u32::MAX,
1545            strategy_idx in 0u8..4,
1546        ) {
1547            let strategy = match strategy_idx {
1548                0 => shipper_retry::RetryStrategyType::Immediate,
1549                1 => shipper_retry::RetryStrategyType::Exponential,
1550                2 => shipper_retry::RetryStrategyType::Linear,
1551                _ => shipper_retry::RetryStrategyType::Constant,
1552            };
1553            let base = Duration::from_millis(100);
1554            let max = Duration::from_secs(60);
1555            let d = backoff_delay(base, max, attempt, strategy, 0.5);
1556            let upper = max + max.mul_f64(0.5);
1557            prop_assert!(d <= upper, "large attempt overflow: {d:?} > {upper:?}");
1558        }
1559
1560        /// State machine invariant: transitioning from any valid state
1561        /// always produces a known/valid short_state label.
1562        #[test]
1563        fn state_transition_always_produces_valid_state(
1564            from_state in arb_package_state(),
1565            to_state in arb_package_state(),
1566        ) {
1567            let key = "t@1.0.0";
1568            let mut st = sample_state(key, from_state);
1569            update_state_locked(&mut st, key, to_state);
1570            let label = short_state(&st.packages[key].state);
1571            prop_assert!(
1572                ["pending", "uploaded", "published", "skipped", "failed", "ambiguous"].contains(&label),
1573                "invalid state label: {label}"
1574            );
1575        }
1576
1577        /// Progress invariant: the proportion of terminal packages is always
1578        /// between 0.0 and 1.0 (inclusive).
1579        #[test]
1580        fn progress_percentage_always_bounded(
1581            count in 1usize..20,
1582            terminal_count in 0usize..20,
1583        ) {
1584            let terminal = terminal_count.min(count);
1585            let mut entries: Vec<(&str, PackageState)> = Vec::new();
1586            let names: Vec<String> = (0..count).map(|i| format!("p{i}@1.0.0")).collect();
1587            for (i, name) in names.iter().enumerate() {
1588                let state = if i < terminal {
1589                    PackageState::Published
1590                } else {
1591                    PackageState::Pending
1592                };
1593                // We need to keep names alive, but multi_state takes &str
1594                entries.push((name.as_str(), state));
1595            }
1596            let st = multi_state(&entries);
1597            let total = st.packages.len() as f64;
1598            let done = st.packages.values()
1599                .filter(|p| matches!(p.state, PackageState::Published | PackageState::Skipped { .. }))
1600                .count() as f64;
1601            let progress = done / total;
1602            prop_assert!((0.0..=1.0).contains(&progress),
1603                "progress {progress} out of bounds");
1604            prop_assert_eq!(st.packages.len(), count);
1605        }
1606
1607        /// Package count invariant: state transitions never add or remove packages.
1608        #[test]
1609        fn state_transitions_preserve_package_count(
1610            s1 in arb_package_state(),
1611            s2 in arb_package_state(),
1612        ) {
1613            let mut st = multi_state(&[
1614                ("x@1.0.0", PackageState::Pending),
1615                ("y@2.0.0", PackageState::Pending),
1616            ]);
1617            let before = st.packages.len();
1618            update_state_locked(&mut st, "x@1.0.0", s1);
1619            update_state_locked(&mut st, "y@2.0.0", s2);
1620            prop_assert_eq!(st.packages.len(), before,
1621                "package count changed after transitions");
1622        }
1623    }
1624
1625    mod snapshots {
1626        use super::*;
1627
1628        fn fixed_time() -> chrono::DateTime<chrono::Utc> {
1629            "2025-01-15T12:00:00Z".parse().unwrap()
1630        }
1631
1632        #[derive(serde::Serialize)]
1633        struct ClassificationSnapshot {
1634            class: shipper_types::ErrorClass,
1635            message: String,
1636        }
1637
1638        impl From<(shipper_types::ErrorClass, String)> for ClassificationSnapshot {
1639            fn from((class, message): (shipper_types::ErrorClass, String)) -> Self {
1640                Self { class, message }
1641            }
1642        }
1643
1644        #[derive(serde::Serialize)]
1645        struct DelaySequence {
1646            strategy: String,
1647            base_ms: u64,
1648            max_ms: u64,
1649            jitter: f64,
1650            delays_ms: Vec<u64>,
1651        }
1652
1653        fn delay_sequence(
1654            strategy: shipper_retry::RetryStrategyType,
1655            base_ms: u64,
1656            max_ms: u64,
1657            attempts: u32,
1658        ) -> DelaySequence {
1659            let base = Duration::from_millis(base_ms);
1660            let max = Duration::from_millis(max_ms);
1661            let delays_ms: Vec<u64> = (1..=attempts)
1662                .map(|a| backoff_delay(base, max, a, strategy, 0.0).as_millis() as u64)
1663                .collect();
1664            DelaySequence {
1665                strategy: format!("{strategy:?}"),
1666                base_ms,
1667                max_ms,
1668                jitter: 0.0,
1669                delays_ms,
1670            }
1671        }
1672
1673        fn make_fixed_progress(
1674            name: &str,
1675            version: &str,
1676            state: PackageState,
1677        ) -> shipper_types::PackageProgress {
1678            shipper_types::PackageProgress {
1679                name: name.to_string(),
1680                version: version.to_string(),
1681                attempts: 0,
1682                state,
1683                last_updated_at: fixed_time(),
1684            }
1685        }
1686
1687        fn fixed_state(entries: &[(&str, &str, &str, PackageState)]) -> ExecutionState {
1688            let mut packages = BTreeMap::new();
1689            for (key, name, version, state) in entries {
1690                packages.insert(
1691                    key.to_string(),
1692                    make_fixed_progress(name, version, state.clone()),
1693                );
1694            }
1695            ExecutionState {
1696                state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
1697                plan_id: "plan-snapshot-test".to_string(),
1698                registry: shipper_types::Registry::crates_io(),
1699                created_at: fixed_time(),
1700                updated_at: fixed_time(),
1701                attempt_history: Vec::new(),
1702                packages,
1703            }
1704        }
1705
1706        fn stabilize_timestamps(st: &mut ExecutionState) {
1707            let t = fixed_time();
1708            st.updated_at = t;
1709            for p in st.packages.values_mut() {
1710                p.last_updated_at = t;
1711            }
1712        }
1713
1714        // --- 1. Retry strategy configurations ---
1715
1716        #[test]
1717        fn snapshot_retry_config_immediate() {
1718            let config = shipper_retry::RetryStrategyConfig {
1719                strategy: shipper_retry::RetryStrategyType::Immediate,
1720                max_attempts: 3,
1721                base_delay: Duration::from_millis(100),
1722                max_delay: Duration::from_secs(10),
1723                jitter: 0.0,
1724            };
1725            insta::assert_yaml_snapshot!(config);
1726        }
1727
1728        #[test]
1729        fn snapshot_retry_config_exponential() {
1730            let config = shipper_retry::RetryStrategyConfig {
1731                strategy: shipper_retry::RetryStrategyType::Exponential,
1732                max_attempts: 5,
1733                base_delay: Duration::from_secs(2),
1734                max_delay: Duration::from_secs(120),
1735                jitter: 0.5,
1736            };
1737            insta::assert_yaml_snapshot!(config);
1738        }
1739
1740        #[test]
1741        fn snapshot_retry_config_linear() {
1742            let config = shipper_retry::RetryStrategyConfig {
1743                strategy: shipper_retry::RetryStrategyType::Linear,
1744                max_attempts: 4,
1745                base_delay: Duration::from_millis(500),
1746                max_delay: Duration::from_secs(30),
1747                jitter: 0.25,
1748            };
1749            insta::assert_yaml_snapshot!(config);
1750        }
1751
1752        #[test]
1753        fn snapshot_retry_config_constant() {
1754            let config = shipper_retry::RetryStrategyConfig {
1755                strategy: shipper_retry::RetryStrategyType::Constant,
1756                max_attempts: 10,
1757                base_delay: Duration::from_secs(5),
1758                max_delay: Duration::from_secs(5),
1759                jitter: 0.0,
1760            };
1761            insta::assert_yaml_snapshot!(config);
1762        }
1763
1764        // --- 2. Error categorization results ---
1765
1766        #[test]
1767        fn snapshot_classify_rate_limit() {
1768            let snap: ClassificationSnapshot =
1769                classify_cargo_failure("HTTP 429 too many requests", "").into();
1770            insta::assert_yaml_snapshot!(snap);
1771        }
1772
1773        #[test]
1774        fn snapshot_classify_network_timeout() {
1775            let snap: ClassificationSnapshot =
1776                classify_cargo_failure("connection timeout", "").into();
1777            insta::assert_yaml_snapshot!(snap);
1778        }
1779
1780        #[test]
1781        fn snapshot_classify_auth_denied() {
1782            let snap: ClassificationSnapshot =
1783                classify_cargo_failure("error: not authorized", "").into();
1784            insta::assert_yaml_snapshot!(snap);
1785        }
1786
1787        #[test]
1788        fn snapshot_classify_already_uploaded() {
1789            let snap: ClassificationSnapshot =
1790                classify_cargo_failure("error: crate version `1.0.0` is already uploaded", "")
1791                    .into();
1792            insta::assert_yaml_snapshot!(snap);
1793        }
1794
1795        #[test]
1796        fn snapshot_classify_network_reset() {
1797            let snap: ClassificationSnapshot =
1798                classify_cargo_failure("connection reset by peer", "").into();
1799            insta::assert_yaml_snapshot!(snap);
1800        }
1801
1802        #[test]
1803        fn snapshot_classify_empty_output() {
1804            let snap: ClassificationSnapshot = classify_cargo_failure("", "").into();
1805            insta::assert_yaml_snapshot!(snap);
1806        }
1807
1808        #[test]
1809        fn snapshot_classify_unknown_error() {
1810            let snap: ClassificationSnapshot =
1811                classify_cargo_failure("some strange unexpected output", "").into();
1812            insta::assert_yaml_snapshot!(snap);
1813        }
1814
1815        // --- 3. Backoff delay calculations ---
1816
1817        #[test]
1818        fn snapshot_backoff_exponential_sequence() {
1819            let seq = delay_sequence(shipper_retry::RetryStrategyType::Exponential, 100, 5000, 8);
1820            insta::assert_yaml_snapshot!(seq);
1821        }
1822
1823        #[test]
1824        fn snapshot_backoff_linear_sequence() {
1825            let seq = delay_sequence(shipper_retry::RetryStrategyType::Linear, 200, 5000, 8);
1826            insta::assert_yaml_snapshot!(seq);
1827        }
1828
1829        #[test]
1830        fn snapshot_backoff_constant_sequence() {
1831            let seq = delay_sequence(shipper_retry::RetryStrategyType::Constant, 500, 5000, 5);
1832            insta::assert_yaml_snapshot!(seq);
1833        }
1834
1835        #[test]
1836        fn snapshot_backoff_immediate_sequence() {
1837            let seq = delay_sequence(shipper_retry::RetryStrategyType::Immediate, 100, 5000, 5);
1838            insta::assert_yaml_snapshot!(seq);
1839        }
1840
1841        #[test]
1842        fn snapshot_backoff_exponential_clamped() {
1843            let seq = delay_sequence(shipper_retry::RetryStrategyType::Exponential, 100, 300, 8);
1844            insta::assert_yaml_snapshot!(seq);
1845        }
1846
1847        // --- 4. State transition sequences ---
1848
1849        #[test]
1850        fn snapshot_state_success_flow() {
1851            let mut st = fixed_state(&[("demo@1.0.0", "demo", "1.0.0", PackageState::Pending)]);
1852            update_state_locked(&mut st, "demo@1.0.0", PackageState::Uploaded);
1853            update_state_locked(&mut st, "demo@1.0.0", PackageState::Published);
1854            st.packages.get_mut("demo@1.0.0").unwrap().attempts = 1;
1855            stabilize_timestamps(&mut st);
1856            insta::assert_yaml_snapshot!(st);
1857        }
1858
1859        #[test]
1860        fn snapshot_state_failure_flow() {
1861            let mut st = fixed_state(&[("demo@1.0.0", "demo", "1.0.0", PackageState::Pending)]);
1862            update_state_locked(
1863                &mut st,
1864                "demo@1.0.0",
1865                PackageState::Failed {
1866                    class: ErrorClass::Retryable,
1867                    message: "429 rate limited".to_string(),
1868                },
1869            );
1870            st.packages.get_mut("demo@1.0.0").unwrap().attempts = 3;
1871            stabilize_timestamps(&mut st);
1872            insta::assert_yaml_snapshot!(st);
1873        }
1874
1875        #[test]
1876        fn snapshot_state_skip_flow() {
1877            let mut st = fixed_state(&[("demo@1.0.0", "demo", "1.0.0", PackageState::Pending)]);
1878            update_state_locked(
1879                &mut st,
1880                "demo@1.0.0",
1881                PackageState::Skipped {
1882                    reason: "already published on registry".to_string(),
1883                },
1884            );
1885            stabilize_timestamps(&mut st);
1886            insta::assert_yaml_snapshot!(st);
1887        }
1888
1889        #[test]
1890        fn snapshot_state_ambiguous_resolved() {
1891            let mut st = fixed_state(&[(
1892                "demo@1.0.0",
1893                "demo",
1894                "1.0.0",
1895                PackageState::Ambiguous {
1896                    message: "timeout during upload".to_string(),
1897                },
1898            )]);
1899            update_state_locked(&mut st, "demo@1.0.0", PackageState::Published);
1900            st.packages.get_mut("demo@1.0.0").unwrap().attempts = 2;
1901            stabilize_timestamps(&mut st);
1902            insta::assert_yaml_snapshot!(st);
1903        }
1904
1905        #[test]
1906        fn snapshot_state_multi_package_mixed_outcomes() {
1907            let mut st = fixed_state(&[
1908                ("core@1.0.0", "core", "1.0.0", PackageState::Pending),
1909                ("utils@1.0.0", "utils", "1.0.0", PackageState::Pending),
1910                ("cli@1.0.0", "cli", "1.0.0", PackageState::Pending),
1911            ]);
1912            update_state_locked(&mut st, "core@1.0.0", PackageState::Published);
1913            st.packages.get_mut("core@1.0.0").unwrap().attempts = 1;
1914            update_state_locked(
1915                &mut st,
1916                "utils@1.0.0",
1917                PackageState::Failed {
1918                    class: ErrorClass::Permanent,
1919                    message: "not authorized".to_string(),
1920                },
1921            );
1922            st.packages.get_mut("utils@1.0.0").unwrap().attempts = 1;
1923            update_state_locked(
1924                &mut st,
1925                "cli@1.0.0",
1926                PackageState::Skipped {
1927                    reason: "dependency utils@1.0.0 failed".to_string(),
1928                },
1929            );
1930            stabilize_timestamps(&mut st);
1931            insta::assert_yaml_snapshot!(st);
1932        }
1933
1934        // --- 5. ExecutionState variant snapshots ---
1935
1936        #[test]
1937        fn snapshot_execution_state_empty_packages() {
1938            let st = fixed_state(&[]);
1939            insta::assert_debug_snapshot!(st);
1940        }
1941
1942        #[test]
1943        fn snapshot_execution_state_single_pending() {
1944            let st = fixed_state(&[("a@1.0.0", "a", "1.0.0", PackageState::Pending)]);
1945            insta::assert_debug_snapshot!(st);
1946        }
1947
1948        #[test]
1949        fn snapshot_execution_state_single_uploaded() {
1950            let st = fixed_state(&[("a@1.0.0", "a", "1.0.0", PackageState::Uploaded)]);
1951            insta::assert_debug_snapshot!(st);
1952        }
1953
1954        #[test]
1955        fn snapshot_execution_state_single_published() {
1956            let st = fixed_state(&[("a@1.0.0", "a", "1.0.0", PackageState::Published)]);
1957            insta::assert_debug_snapshot!(st);
1958        }
1959
1960        #[test]
1961        fn snapshot_execution_state_single_skipped() {
1962            let st = fixed_state(&[(
1963                "a@1.0.0",
1964                "a",
1965                "1.0.0",
1966                PackageState::Skipped {
1967                    reason: "already on registry".into(),
1968                },
1969            )]);
1970            insta::assert_debug_snapshot!(st);
1971        }
1972
1973        #[test]
1974        fn snapshot_execution_state_single_failed() {
1975            let st = fixed_state(&[(
1976                "a@1.0.0",
1977                "a",
1978                "1.0.0",
1979                PackageState::Failed {
1980                    class: ErrorClass::Permanent,
1981                    message: "denied".into(),
1982                },
1983            )]);
1984            insta::assert_debug_snapshot!(st);
1985        }
1986
1987        #[test]
1988        fn snapshot_execution_state_single_ambiguous() {
1989            let st = fixed_state(&[(
1990                "a@1.0.0",
1991                "a",
1992                "1.0.0",
1993                PackageState::Ambiguous {
1994                    message: "timeout".into(),
1995                },
1996            )]);
1997            insta::assert_debug_snapshot!(st);
1998        }
1999
2000        // --- 6. State transition sequence snapshots ---
2001
2002        #[test]
2003        fn snapshot_transition_pending_to_uploaded_to_published() {
2004            let key = "pkg@1.0.0";
2005            let mut st = fixed_state(&[(key, "pkg", "1.0.0", PackageState::Pending)]);
2006            let mut steps: Vec<String> = vec![format!("initial: {:?}", st.packages[key].state)];
2007            update_state_locked(&mut st, key, PackageState::Uploaded);
2008            steps.push(format!("after upload: {:?}", st.packages[key].state));
2009            update_state_locked(&mut st, key, PackageState::Published);
2010            steps.push(format!("after publish: {:?}", st.packages[key].state));
2011            insta::assert_debug_snapshot!(steps);
2012        }
2013
2014        #[test]
2015        fn snapshot_transition_pending_to_failed_retry_to_published() {
2016            let key = "pkg@1.0.0";
2017            let mut st = fixed_state(&[(key, "pkg", "1.0.0", PackageState::Pending)]);
2018            let mut steps: Vec<String> = vec![format!("initial: {:?}", st.packages[key].state)];
2019            update_state_locked(
2020                &mut st,
2021                key,
2022                PackageState::Failed {
2023                    class: ErrorClass::Retryable,
2024                    message: "rate limited".into(),
2025                },
2026            );
2027            steps.push(format!("after failure: {:?}", st.packages[key].state));
2028            update_state_locked(&mut st, key, PackageState::Pending);
2029            steps.push(format!("after retry reset: {:?}", st.packages[key].state));
2030            update_state_locked(&mut st, key, PackageState::Uploaded);
2031            steps.push(format!("after upload: {:?}", st.packages[key].state));
2032            update_state_locked(&mut st, key, PackageState::Published);
2033            steps.push(format!("after publish: {:?}", st.packages[key].state));
2034            insta::assert_debug_snapshot!(steps);
2035        }
2036
2037        #[test]
2038        fn snapshot_transition_ambiguous_to_published() {
2039            let key = "pkg@1.0.0";
2040            let mut st = fixed_state(&[(
2041                key,
2042                "pkg",
2043                "1.0.0",
2044                PackageState::Ambiguous {
2045                    message: "upload timeout".into(),
2046                },
2047            )]);
2048            let mut steps: Vec<String> = vec![format!("initial: {:?}", st.packages[key].state)];
2049            update_state_locked(&mut st, key, PackageState::Published);
2050            steps.push(format!("after verification: {:?}", st.packages[key].state));
2051            insta::assert_debug_snapshot!(steps);
2052        }
2053
2054        #[test]
2055        fn snapshot_transition_all_skipped_plan() {
2056            let mut st = fixed_state(&[
2057                ("a@1.0.0", "a", "1.0.0", PackageState::Pending),
2058                ("b@1.0.0", "b", "1.0.0", PackageState::Pending),
2059            ]);
2060            update_state_locked(
2061                &mut st,
2062                "a@1.0.0",
2063                PackageState::Skipped {
2064                    reason: "already published".into(),
2065                },
2066            );
2067            update_state_locked(
2068                &mut st,
2069                "b@1.0.0",
2070                PackageState::Skipped {
2071                    reason: "already published".into(),
2072                },
2073            );
2074            stabilize_timestamps(&mut st);
2075            insta::assert_debug_snapshot!(st);
2076        }
2077    }
2078
2079    // -- 1. State machine transitions: all valid transitions --
2080
2081    #[test]
2082    fn transition_pending_to_uploaded() {
2083        let key = "a@1.0.0";
2084        let mut st = sample_state(key, PackageState::Pending);
2085        update_state_locked(&mut st, key, PackageState::Uploaded);
2086        assert_eq!(st.packages[key].state, PackageState::Uploaded);
2087    }
2088
2089    #[test]
2090    fn transition_pending_to_skipped() {
2091        let key = "a@1.0.0";
2092        let mut st = sample_state(key, PackageState::Pending);
2093        update_state_locked(
2094            &mut st,
2095            key,
2096            PackageState::Skipped {
2097                reason: "pre-existing".into(),
2098            },
2099        );
2100        assert!(matches!(
2101            st.packages[key].state,
2102            PackageState::Skipped { .. }
2103        ));
2104    }
2105
2106    #[test]
2107    fn transition_pending_to_failed() {
2108        let key = "a@1.0.0";
2109        let mut st = sample_state(key, PackageState::Pending);
2110        update_state_locked(
2111            &mut st,
2112            key,
2113            PackageState::Failed {
2114                class: ErrorClass::Permanent,
2115                message: "auth".into(),
2116            },
2117        );
2118        assert!(matches!(
2119            st.packages[key].state,
2120            PackageState::Failed { .. }
2121        ));
2122    }
2123
2124    #[test]
2125    fn transition_pending_to_ambiguous() {
2126        let key = "a@1.0.0";
2127        let mut st = sample_state(key, PackageState::Pending);
2128        update_state_locked(
2129            &mut st,
2130            key,
2131            PackageState::Ambiguous {
2132                message: "timeout".into(),
2133            },
2134        );
2135        assert!(matches!(
2136            st.packages[key].state,
2137            PackageState::Ambiguous { .. }
2138        ));
2139    }
2140
2141    #[test]
2142    fn transition_uploaded_to_published() {
2143        let key = "a@1.0.0";
2144        let mut st = sample_state(key, PackageState::Uploaded);
2145        update_state_locked(&mut st, key, PackageState::Published);
2146        assert_eq!(st.packages[key].state, PackageState::Published);
2147    }
2148
2149    #[test]
2150    fn transition_uploaded_to_failed() {
2151        let key = "a@1.0.0";
2152        let mut st = sample_state(key, PackageState::Uploaded);
2153        update_state_locked(
2154            &mut st,
2155            key,
2156            PackageState::Failed {
2157                class: ErrorClass::Retryable,
2158                message: "verify timeout".into(),
2159            },
2160        );
2161        assert!(matches!(
2162            st.packages[key].state,
2163            PackageState::Failed { .. }
2164        ));
2165    }
2166
2167    #[test]
2168    fn transition_uploaded_to_ambiguous() {
2169        let key = "a@1.0.0";
2170        let mut st = sample_state(key, PackageState::Uploaded);
2171        update_state_locked(
2172            &mut st,
2173            key,
2174            PackageState::Ambiguous {
2175                message: "verify timeout".into(),
2176            },
2177        );
2178        assert!(matches!(
2179            st.packages[key].state,
2180            PackageState::Ambiguous { .. }
2181        ));
2182    }
2183
2184    #[test]
2185    fn transition_ambiguous_to_published() {
2186        let key = "a@1.0.0";
2187        let mut st = sample_state(
2188            key,
2189            PackageState::Ambiguous {
2190                message: "timeout".into(),
2191            },
2192        );
2193        update_state_locked(&mut st, key, PackageState::Published);
2194        assert_eq!(st.packages[key].state, PackageState::Published);
2195    }
2196
2197    #[test]
2198    fn transition_ambiguous_to_failed() {
2199        let key = "a@1.0.0";
2200        let mut st = sample_state(
2201            key,
2202            PackageState::Ambiguous {
2203                message: "timeout".into(),
2204            },
2205        );
2206        update_state_locked(
2207            &mut st,
2208            key,
2209            PackageState::Failed {
2210                class: ErrorClass::Permanent,
2211                message: "confirmed not on registry".into(),
2212            },
2213        );
2214        assert!(matches!(
2215            st.packages[key].state,
2216            PackageState::Failed { .. }
2217        ));
2218    }
2219
2220    #[test]
2221    fn transition_failed_retryable_back_to_pending() {
2222        let key = "a@1.0.0";
2223        let mut st = sample_state(
2224            key,
2225            PackageState::Failed {
2226                class: ErrorClass::Retryable,
2227                message: "rate limit".into(),
2228            },
2229        );
2230        update_state_locked(&mut st, key, PackageState::Pending);
2231        assert_eq!(st.packages[key].state, PackageState::Pending);
2232    }
2233
2234    // -- 2. Invalid / unusual transitions (the API is permissive, verify it accepts them) --
2235
2236    #[test]
2237    fn transition_published_to_pending_is_accepted() {
2238        // update_state_locked is a raw setter — it does not enforce a state machine
2239        let key = "a@1.0.0";
2240        let mut st = sample_state(key, PackageState::Published);
2241        update_state_locked(&mut st, key, PackageState::Pending);
2242        assert_eq!(st.packages[key].state, PackageState::Pending);
2243    }
2244
2245    #[test]
2246    fn transition_skipped_to_published_is_accepted() {
2247        let key = "a@1.0.0";
2248        let mut st = sample_state(
2249            key,
2250            PackageState::Skipped {
2251                reason: "skip".into(),
2252            },
2253        );
2254        update_state_locked(&mut st, key, PackageState::Published);
2255        assert_eq!(st.packages[key].state, PackageState::Published);
2256    }
2257
2258    #[test]
2259    fn transition_published_to_failed_is_accepted() {
2260        let key = "a@1.0.0";
2261        let mut st = sample_state(key, PackageState::Published);
2262        update_state_locked(
2263            &mut st,
2264            key,
2265            PackageState::Failed {
2266                class: ErrorClass::Ambiguous,
2267                message: "weird".into(),
2268            },
2269        );
2270        assert!(matches!(
2271            st.packages[key].state,
2272            PackageState::Failed { .. }
2273        ));
2274    }
2275
2276    #[test]
2277    fn update_state_rejects_missing_key() {
2278        let mut st = sample_state("a@1.0.0", PackageState::Pending);
2279        let td = tempdir().expect("tempdir");
2280        let err = update_state(
2281            &mut st,
2282            td.path(),
2283            "nonexistent@0.0.0",
2284            PackageState::Published,
2285        );
2286        assert!(err.is_err());
2287        assert!(
2288            err.unwrap_err()
2289                .to_string()
2290                .contains("missing package in state")
2291        );
2292    }
2293
2294    // -- 3. Concurrent state updates (sequential simulation) --
2295
2296    #[test]
2297    fn concurrent_updates_to_different_packages_are_independent() {
2298        let mut st = multi_state(&[
2299            ("a@1.0.0", PackageState::Pending),
2300            ("b@1.0.0", PackageState::Pending),
2301            ("c@1.0.0", PackageState::Pending),
2302        ]);
2303        // Simulate concurrent workers updating different keys
2304        update_state_locked(&mut st, "a@1.0.0", PackageState::Uploaded);
2305        update_state_locked(&mut st, "b@1.0.0", PackageState::Published);
2306        update_state_locked(
2307            &mut st,
2308            "c@1.0.0",
2309            PackageState::Failed {
2310                class: ErrorClass::Retryable,
2311                message: "rate limited".into(),
2312            },
2313        );
2314        assert_eq!(st.packages["a@1.0.0"].state, PackageState::Uploaded);
2315        assert_eq!(st.packages["b@1.0.0"].state, PackageState::Published);
2316        assert!(matches!(
2317            st.packages["c@1.0.0"].state,
2318            PackageState::Failed { .. }
2319        ));
2320    }
2321
2322    #[test]
2323    fn rapid_sequential_updates_same_key() {
2324        let key = "a@1.0.0";
2325        let mut st = sample_state(key, PackageState::Pending);
2326        // Rapid-fire transitions on the same key
2327        let states = [
2328            PackageState::Uploaded,
2329            PackageState::Ambiguous {
2330                message: "check".into(),
2331            },
2332            PackageState::Published,
2333        ];
2334        for s in &states {
2335            update_state_locked(&mut st, key, s.clone());
2336        }
2337        assert_eq!(st.packages[key].state, PackageState::Published);
2338    }
2339
2340    #[test]
2341    fn concurrent_persist_updates_are_consistent() {
2342        let td = tempdir().expect("tempdir");
2343        let mut st = multi_state(&[
2344            ("a@1.0.0", PackageState::Pending),
2345            ("b@1.0.0", PackageState::Pending),
2346        ]);
2347        update_state(&mut st, td.path(), "a@1.0.0", PackageState::Uploaded).unwrap();
2348        update_state(&mut st, td.path(), "b@1.0.0", PackageState::Published).unwrap();
2349        let loaded = crate::state::execution_state::load_state(td.path())
2350            .unwrap()
2351            .unwrap();
2352        assert_eq!(loaded.packages["a@1.0.0"].state, PackageState::Uploaded);
2353        assert_eq!(loaded.packages["b@1.0.0"].state, PackageState::Published);
2354    }
2355
2356    // -- 4. Empty execution plan --
2357
2358    #[test]
2359    fn empty_plan_state_has_no_packages() {
2360        let st = multi_state(&[]);
2361        assert!(st.packages.is_empty());
2362    }
2363
2364    #[test]
2365    fn empty_plan_update_locked_is_noop() {
2366        let mut st = multi_state(&[]);
2367        update_state_locked(&mut st, "nonexistent@1.0.0", PackageState::Published);
2368        assert!(st.packages.is_empty());
2369    }
2370
2371    #[test]
2372    fn empty_plan_update_state_errors() {
2373        let mut st = multi_state(&[]);
2374        let td = tempdir().expect("tempdir");
2375        assert!(update_state(&mut st, td.path(), "any@1.0.0", PackageState::Published).is_err());
2376    }
2377
2378    #[test]
2379    fn empty_plan_persist_and_reload() {
2380        let td = tempdir().expect("tempdir");
2381        let st = multi_state(&[]);
2382        crate::state::execution_state::save_state(td.path(), &st).unwrap();
2383        let loaded = crate::state::execution_state::load_state(td.path())
2384            .unwrap()
2385            .unwrap();
2386        assert!(loaded.packages.is_empty());
2387        assert_eq!(loaded.plan_id, "plan-multi");
2388    }
2389
2390    // -- 5. Single-package execution --
2391
2392    #[test]
2393    fn single_package_full_lifecycle() {
2394        let key = "solo@0.1.0";
2395        let td = tempdir().expect("tempdir");
2396        let mut st = sample_state(key, PackageState::Pending);
2397        update_state(&mut st, td.path(), key, PackageState::Uploaded).unwrap();
2398        assert_eq!(st.packages[key].state, PackageState::Uploaded);
2399        update_state(&mut st, td.path(), key, PackageState::Published).unwrap();
2400        assert_eq!(st.packages[key].state, PackageState::Published);
2401        let loaded = crate::state::execution_state::load_state(td.path())
2402            .unwrap()
2403            .unwrap();
2404        assert_eq!(loaded.packages[key].state, PackageState::Published);
2405    }
2406
2407    #[test]
2408    fn single_package_skip_lifecycle() {
2409        let key = "solo@0.1.0";
2410        let td = tempdir().expect("tempdir");
2411        let mut st = sample_state(key, PackageState::Pending);
2412        update_state(
2413            &mut st,
2414            td.path(),
2415            key,
2416            PackageState::Skipped {
2417                reason: "already exists".into(),
2418            },
2419        )
2420        .unwrap();
2421        let loaded = crate::state::execution_state::load_state(td.path())
2422            .unwrap()
2423            .unwrap();
2424        assert!(matches!(
2425            loaded.packages[key].state,
2426            PackageState::Skipped { .. }
2427        ));
2428    }
2429
2430    #[test]
2431    fn single_package_failure_lifecycle() {
2432        let key = "solo@0.1.0";
2433        let td = tempdir().expect("tempdir");
2434        let mut st = sample_state(key, PackageState::Pending);
2435        update_state(
2436            &mut st,
2437            td.path(),
2438            key,
2439            PackageState::Failed {
2440                class: ErrorClass::Permanent,
2441                message: "auth denied".into(),
2442            },
2443        )
2444        .unwrap();
2445        let loaded = crate::state::execution_state::load_state(td.path())
2446            .unwrap()
2447            .unwrap();
2448        match &loaded.packages[key].state {
2449            PackageState::Failed { class, message } => {
2450                assert_eq!(*class, ErrorClass::Permanent);
2451                assert_eq!(message, "auth denied");
2452            }
2453            other => panic!("expected Failed, got {other:?}"),
2454        }
2455    }
2456
2457    // -- 6. All packages already published (skip everything) --
2458
2459    #[test]
2460    fn all_packages_skipped_preserves_reasons() {
2461        let mut st = multi_state(&[
2462            ("a@1.0.0", PackageState::Pending),
2463            ("b@2.0.0", PackageState::Pending),
2464            ("c@3.0.0", PackageState::Pending),
2465        ]);
2466        let reasons = ["version exists", "yanked version", "no changes"];
2467        for (i, (key, _)) in st.packages.clone().iter().enumerate() {
2468            update_state_locked(
2469                &mut st,
2470                key,
2471                PackageState::Skipped {
2472                    reason: reasons[i].into(),
2473                },
2474            );
2475        }
2476        for pkg in st.packages.values() {
2477            assert!(
2478                matches!(&pkg.state, PackageState::Skipped { .. }),
2479                "all should be skipped"
2480            );
2481        }
2482    }
2483
2484    #[test]
2485    fn all_packages_already_published_remain_published() {
2486        let st = multi_state(&[
2487            ("a@1.0.0", PackageState::Published),
2488            ("b@2.0.0", PackageState::Published),
2489        ]);
2490        let published_count = st
2491            .packages
2492            .values()
2493            .filter(|p| matches!(p.state, PackageState::Published))
2494            .count();
2495        assert_eq!(published_count, 2);
2496    }
2497
2498    #[test]
2499    fn all_skipped_persist_round_trip() {
2500        let td = tempdir().expect("tempdir");
2501        let mut st = multi_state(&[
2502            ("a@1.0.0", PackageState::Pending),
2503            ("b@2.0.0", PackageState::Pending),
2504        ]);
2505        update_state(
2506            &mut st,
2507            td.path(),
2508            "a@1.0.0",
2509            PackageState::Skipped {
2510                reason: "exists".into(),
2511            },
2512        )
2513        .unwrap();
2514        update_state(
2515            &mut st,
2516            td.path(),
2517            "b@2.0.0",
2518            PackageState::Skipped {
2519                reason: "exists".into(),
2520            },
2521        )
2522        .unwrap();
2523        let loaded = crate::state::execution_state::load_state(td.path())
2524            .unwrap()
2525            .unwrap();
2526        assert!(
2527            loaded
2528                .packages
2529                .values()
2530                .all(|p| matches!(p.state, PackageState::Skipped { .. }))
2531        );
2532    }
2533
2534    // -- 10. Error propagation from callbacks --
2535
2536    #[test]
2537    fn update_state_propagates_save_error_on_invalid_dir() {
2538        let mut st = sample_state("a@1.0.0", PackageState::Pending);
2539        // Use a nonexistent directory that cannot be created
2540        let bad_dir = PathBuf::from(if cfg!(windows) {
2541            r"Z:\nonexistent\deep\path\state"
2542        } else {
2543            "/nonexistent/deep/path/state"
2544        });
2545        let result = update_state(&mut st, &bad_dir, "a@1.0.0", PackageState::Published);
2546        assert!(result.is_err(), "should propagate IO error from save_state");
2547    }
2548
2549    #[test]
2550    fn update_state_error_does_not_corrupt_in_memory_state() {
2551        let mut st = sample_state("a@1.0.0", PackageState::Pending);
2552        let bad_dir = PathBuf::from(if cfg!(windows) {
2553            r"Z:\nonexistent\path"
2554        } else {
2555            "/nonexistent/path"
2556        });
2557        // The update modifies in-memory state, then fails on persist.
2558        // Even on error, the in-memory mutation has occurred (this is the current behavior).
2559        let _ = update_state(&mut st, &bad_dir, "a@1.0.0", PackageState::Published);
2560        // The in-memory state was already mutated before the persist call
2561        assert_eq!(st.packages["a@1.0.0"].state, PackageState::Published);
2562    }
2563
2564    #[test]
2565    fn update_state_missing_key_error_message_is_descriptive() {
2566        let mut st = sample_state("a@1.0.0", PackageState::Pending);
2567        let td = tempdir().expect("tempdir");
2568        let err = update_state(&mut st, td.path(), "z@9.9.9", PackageState::Published).unwrap_err();
2569        let msg = format!("{err}");
2570        assert!(
2571            msg.contains("missing package"),
2572            "error should mention missing package: {msg}"
2573        );
2574    }
2575
2576    // -- 9. Property test: state transitions are deterministic --
2577
2578    proptest! {
2579        #[test]
2580        fn state_transitions_are_deterministic(
2581            initial in arb_package_state(),
2582            target in arb_package_state(),
2583        ) {
2584            let key = "d@1.0.0";
2585            let mut st1 = sample_state(key, initial.clone());
2586            let mut st2 = sample_state(key, initial);
2587            update_state_locked(&mut st1, key, target.clone());
2588            update_state_locked(&mut st2, key, target);
2589            prop_assert_eq!(&st1.packages[key].state, &st2.packages[key].state);
2590        }
2591
2592        #[test]
2593        fn multi_step_transitions_preserve_package_count(
2594            s1 in arb_package_state(),
2595            s2 in arb_package_state(),
2596        ) {
2597            let mut st = multi_state(&[
2598                ("x@1.0.0", PackageState::Pending),
2599                ("y@1.0.0", PackageState::Pending),
2600            ]);
2601            update_state_locked(&mut st, "x@1.0.0", s1);
2602            update_state_locked(&mut st, "y@1.0.0", s2);
2603            prop_assert_eq!(st.packages.len(), 2);
2604        }
2605
2606        #[test]
2607        fn update_state_locked_idempotent_for_same_state(state in arb_package_state()) {
2608            let key = "i@1.0.0";
2609            let mut st = sample_state(key, state.clone());
2610            update_state_locked(&mut st, key, state.clone());
2611            update_state_locked(&mut st, key, state.clone());
2612            prop_assert_eq!(&st.packages[key].state, &state);
2613        }
2614    }
2615}