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