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