1use 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
17pub 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
34pub 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
43pub fn pkg_key(name: &str, version: &str) -> String {
45 format!("{name}@{version}")
46}
47
48pub 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
60pub 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
82pub 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
100pub const CRATES_IO_NEW_CRATE_WINDOW: Duration = Duration::from_secs(10 * 60);
105
106pub 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
119pub 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
145pub 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 #[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 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 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 let base = Duration::from_secs(60 * 20); 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 #[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 #[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 update_state_locked(&mut st, key, PackageState::Published);
487 assert_eq!(st.packages[key].state, PackageState::Published);
488 }
489
490 #[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 #[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 #[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 update_state_locked(&mut st, "any@1.0.0", PackageState::Published);
552 assert!(st.packages.is_empty());
553 }
554
555 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 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 #[test]
610 fn classify_rate_limit_variants() {
611 let (class, _) = classify_cargo_failure("error: 429 too many requests", "");
613 assert_eq!(class, ErrorClass::Retryable);
614
615 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 #[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 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)); }
787
788 #[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 #[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 #[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 #[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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
1906 fn transition_published_to_pending_is_accepted() {
1907 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 #[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 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 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 #[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 #[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 #[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 #[test]
2206 fn update_state_propagates_save_error_on_invalid_dir() {
2207 let mut st = sample_state("a@1.0.0", PackageState::Pending);
2208 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 let _ = update_state(&mut st, &bad_dir, "a@1.0.0", PackageState::Published);
2229 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 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}