1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum CargoFailureClass {
9 Retryable,
11 Permanent,
13 Ambiguous,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct CargoFailureOutcome {
20 pub class: CargoFailureClass,
22 pub message: &'static str,
24}
25
26const RETRYABLE_PATTERNS: [&str; 20] = [
27 "too many requests",
28 "429",
29 "timeout",
30 "timed out",
31 "connection reset",
32 "connection refused",
33 "connection closed",
34 "dns",
35 "tls",
36 "temporarily unavailable",
37 "failed to download",
38 "failed to send",
39 "server error",
40 "500",
41 "502",
42 "503",
43 "504",
44 "broken pipe",
45 "reset by peer",
46 "network unreachable",
47];
48
49const PERMANENT_PATTERNS: [&str; 22] = [
50 "failed to parse manifest",
51 "invalid",
52 "missing",
53 "license",
54 "description",
55 "readme",
56 "repository",
57 "could not compile",
58 "compilation failed",
59 "failed to verify",
60 "package is not allowed to be published",
61 "publish is disabled",
62 "yanked",
63 "forbidden",
64 "permission denied",
65 "not authorized",
66 "unauthorized",
67 "version already exists",
68 "is already uploaded",
69 "token is invalid",
70 "invalid credentials",
71 "checksum mismatch",
72];
73
74pub fn classify_publish_failure(stderr: &str, stdout: &str) -> CargoFailureOutcome {
79 let haystack = format!("{stderr}\n{stdout}").to_lowercase();
80
81 if RETRYABLE_PATTERNS
82 .iter()
83 .any(|pattern| haystack.contains(pattern))
84 {
85 return CargoFailureOutcome {
86 class: CargoFailureClass::Retryable,
87 message: "transient failure (retryable)",
88 };
89 }
90
91 if PERMANENT_PATTERNS
92 .iter()
93 .any(|pattern| haystack.contains(pattern))
94 {
95 return CargoFailureOutcome {
96 class: CargoFailureClass::Permanent,
97 message: "permanent failure (fix required)",
98 };
99 }
100
101 CargoFailureOutcome {
102 class: CargoFailureClass::Ambiguous,
103 message: "publish outcome ambiguous; registry did not show version",
104 }
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110
111 #[test]
114 fn classifies_retryable_failure() {
115 let outcome = classify_publish_failure("HTTP 429 too many requests", "");
116 assert_eq!(outcome.class, CargoFailureClass::Retryable);
117 assert_eq!(outcome.message, "transient failure (retryable)");
118 }
119
120 #[test]
121 fn classifies_permanent_failure() {
122 let outcome = classify_publish_failure("permission denied", "");
123 assert_eq!(outcome.class, CargoFailureClass::Permanent);
124 assert_eq!(outcome.message, "permanent failure (fix required)");
125 }
126
127 #[test]
128 fn classifies_ambiguous_failure() {
129 let outcome = classify_publish_failure("unexpected tool output", "");
130 assert_eq!(outcome.class, CargoFailureClass::Ambiguous);
131 assert_eq!(
132 outcome.message,
133 "publish outcome ambiguous; registry did not show version"
134 );
135 }
136
137 #[test]
138 fn retryable_takes_precedence_when_both_pattern_sets_match() {
139 let outcome = classify_publish_failure("permission denied and 429", "");
140 assert_eq!(outcome.class, CargoFailureClass::Retryable);
141 }
142
143 #[test]
144 fn scans_stdout_in_addition_to_stderr() {
145 let outcome = classify_publish_failure("", "server error 503");
146 assert_eq!(outcome.class, CargoFailureClass::Retryable);
147 }
148
149 #[test]
152 fn retryable_too_many_requests() {
153 let o = classify_publish_failure("too many requests", "");
154 assert_eq!(o.class, CargoFailureClass::Retryable);
155 }
156
157 #[test]
158 fn retryable_429() {
159 let o = classify_publish_failure("HTTP/1.1 429", "");
160 assert_eq!(o.class, CargoFailureClass::Retryable);
161 }
162
163 #[test]
164 fn retryable_timeout() {
165 let o = classify_publish_failure("request timeout", "");
166 assert_eq!(o.class, CargoFailureClass::Retryable);
167 }
168
169 #[test]
170 fn retryable_timed_out() {
171 let o = classify_publish_failure("operation timed out", "");
172 assert_eq!(o.class, CargoFailureClass::Retryable);
173 }
174
175 #[test]
176 fn retryable_connection_reset() {
177 let o = classify_publish_failure("connection reset by peer", "");
178 assert_eq!(o.class, CargoFailureClass::Retryable);
179 }
180
181 #[test]
182 fn retryable_connection_refused() {
183 let o = classify_publish_failure("connection refused", "");
184 assert_eq!(o.class, CargoFailureClass::Retryable);
185 }
186
187 #[test]
188 fn retryable_connection_closed() {
189 let o = classify_publish_failure("connection closed before message completed", "");
190 assert_eq!(o.class, CargoFailureClass::Retryable);
191 }
192
193 #[test]
194 fn retryable_dns() {
195 let o = classify_publish_failure("dns resolution failed", "");
196 assert_eq!(o.class, CargoFailureClass::Retryable);
197 }
198
199 #[test]
200 fn retryable_tls() {
201 let o = classify_publish_failure("tls handshake failed", "");
202 assert_eq!(o.class, CargoFailureClass::Retryable);
203 }
204
205 #[test]
206 fn retryable_temporarily_unavailable() {
207 let o = classify_publish_failure("service temporarily unavailable", "");
208 assert_eq!(o.class, CargoFailureClass::Retryable);
209 }
210
211 #[test]
212 fn retryable_failed_to_download() {
213 let o = classify_publish_failure("failed to download index", "");
214 assert_eq!(o.class, CargoFailureClass::Retryable);
215 }
216
217 #[test]
218 fn retryable_failed_to_send() {
219 let o = classify_publish_failure("failed to send request", "");
220 assert_eq!(o.class, CargoFailureClass::Retryable);
221 }
222
223 #[test]
224 fn retryable_server_error() {
225 let o = classify_publish_failure("server error", "");
226 assert_eq!(o.class, CargoFailureClass::Retryable);
227 }
228
229 #[test]
230 fn retryable_500() {
231 let o = classify_publish_failure("HTTP 500 Internal Server Error", "");
232 assert_eq!(o.class, CargoFailureClass::Retryable);
233 }
234
235 #[test]
236 fn retryable_502() {
237 let o = classify_publish_failure("502 Bad Gateway", "");
238 assert_eq!(o.class, CargoFailureClass::Retryable);
239 }
240
241 #[test]
242 fn retryable_503() {
243 let o = classify_publish_failure("503 Service Unavailable", "");
244 assert_eq!(o.class, CargoFailureClass::Retryable);
245 }
246
247 #[test]
248 fn retryable_504() {
249 let o = classify_publish_failure("504 Gateway Timeout", "");
250 assert_eq!(o.class, CargoFailureClass::Retryable);
251 }
252
253 #[test]
254 fn retryable_broken_pipe() {
255 let o = classify_publish_failure("broken pipe", "");
256 assert_eq!(o.class, CargoFailureClass::Retryable);
257 }
258
259 #[test]
260 fn retryable_reset_by_peer() {
261 let o = classify_publish_failure("reset by peer", "");
262 assert_eq!(o.class, CargoFailureClass::Retryable);
263 }
264
265 #[test]
266 fn retryable_network_unreachable() {
267 let o = classify_publish_failure("network unreachable", "");
268 assert_eq!(o.class, CargoFailureClass::Retryable);
269 }
270
271 #[test]
274 fn permanent_failed_to_parse_manifest() {
275 let o = classify_publish_failure("failed to parse manifest at Cargo.toml", "");
276 assert_eq!(o.class, CargoFailureClass::Permanent);
277 }
278
279 #[test]
280 fn permanent_invalid() {
281 let o = classify_publish_failure("invalid package name", "");
282 assert_eq!(o.class, CargoFailureClass::Permanent);
283 }
284
285 #[test]
286 fn permanent_missing() {
287 let o = classify_publish_failure("missing field `version`", "");
288 assert_eq!(o.class, CargoFailureClass::Permanent);
289 }
290
291 #[test]
292 fn permanent_license() {
293 let o = classify_publish_failure("no `license` or `license-file` set", "");
294 assert_eq!(o.class, CargoFailureClass::Permanent);
295 }
296
297 #[test]
298 fn permanent_description() {
299 let o = classify_publish_failure("no `description` specified", "");
300 assert_eq!(o.class, CargoFailureClass::Permanent);
301 }
302
303 #[test]
304 fn permanent_readme() {
305 let o = classify_publish_failure("readme file not found", "");
306 assert_eq!(o.class, CargoFailureClass::Permanent);
307 }
308
309 #[test]
310 fn permanent_repository() {
311 let o = classify_publish_failure("no `repository` URL specified", "");
312 assert_eq!(o.class, CargoFailureClass::Permanent);
313 }
314
315 #[test]
316 fn permanent_could_not_compile() {
317 let o = classify_publish_failure("could not compile `my-crate`", "");
318 assert_eq!(o.class, CargoFailureClass::Permanent);
319 }
320
321 #[test]
322 fn permanent_compilation_failed() {
323 let o = classify_publish_failure("compilation failed", "");
324 assert_eq!(o.class, CargoFailureClass::Permanent);
325 }
326
327 #[test]
328 fn permanent_failed_to_verify() {
329 let o = classify_publish_failure("failed to verify package tarball", "");
330 assert_eq!(o.class, CargoFailureClass::Permanent);
331 }
332
333 #[test]
334 fn permanent_not_allowed_to_publish() {
335 let o = classify_publish_failure("package is not allowed to be published", "");
336 assert_eq!(o.class, CargoFailureClass::Permanent);
337 }
338
339 #[test]
340 fn permanent_publish_disabled() {
341 let o = classify_publish_failure("publish is disabled for this package", "");
342 assert_eq!(o.class, CargoFailureClass::Permanent);
343 }
344
345 #[test]
346 fn permanent_yanked() {
347 let o = classify_publish_failure("dependency `foo` has been yanked", "");
348 assert_eq!(o.class, CargoFailureClass::Permanent);
349 }
350
351 #[test]
352 fn permanent_forbidden() {
353 let o = classify_publish_failure("403 forbidden", "");
354 assert_eq!(o.class, CargoFailureClass::Permanent);
355 }
356
357 #[test]
358 fn permanent_permission_denied() {
359 let o = classify_publish_failure("permission denied (publickey)", "");
360 assert_eq!(o.class, CargoFailureClass::Permanent);
361 }
362
363 #[test]
364 fn permanent_not_authorized() {
365 let o = classify_publish_failure("not authorized to publish", "");
366 assert_eq!(o.class, CargoFailureClass::Permanent);
367 }
368
369 #[test]
370 fn permanent_unauthorized() {
371 let o = classify_publish_failure("401 unauthorized", "");
372 assert_eq!(o.class, CargoFailureClass::Permanent);
373 }
374
375 #[test]
376 fn permanent_version_already_exists() {
377 let o = classify_publish_failure("version already exists: 1.0.0", "");
378 assert_eq!(o.class, CargoFailureClass::Permanent);
379 }
380
381 #[test]
382 fn permanent_already_uploaded() {
383 let o = classify_publish_failure("crate version 1.0.0 is already uploaded", "");
384 assert_eq!(o.class, CargoFailureClass::Permanent);
385 }
386
387 #[test]
388 fn permanent_token_is_invalid() {
389 let o = classify_publish_failure("token is invalid", "");
390 assert_eq!(o.class, CargoFailureClass::Permanent);
391 }
392
393 #[test]
394 fn permanent_invalid_credentials() {
395 let o = classify_publish_failure("invalid credentials", "");
396 assert_eq!(o.class, CargoFailureClass::Permanent);
397 }
398
399 #[test]
400 fn permanent_checksum_mismatch() {
401 let o = classify_publish_failure("checksum mismatch for crate", "");
402 assert_eq!(o.class, CargoFailureClass::Permanent);
403 }
404
405 #[test]
408 fn rate_limit_via_429_status() {
409 let o = classify_publish_failure("received status 429 from registry", "");
410 assert_eq!(o.class, CargoFailureClass::Retryable);
411 }
412
413 #[test]
414 fn rate_limit_via_too_many_requests_mixed_case() {
415 let o = classify_publish_failure("Too Many Requests", "");
416 assert_eq!(o.class, CargoFailureClass::Retryable);
417 }
418
419 #[test]
420 fn rate_limit_embedded_in_longer_message() {
421 let o = classify_publish_failure(
422 "error: the registry responded with: 429 Too Many Requests; try again later",
423 "",
424 );
425 assert_eq!(o.class, CargoFailureClass::Retryable);
426 }
427
428 #[test]
431 fn timeout_with_surrounding_context() {
432 let o = classify_publish_failure("operation on socket timed out after 30s", "");
433 assert_eq!(o.class, CargoFailureClass::Retryable);
434 }
435
436 #[test]
437 fn timeout_uppercase() {
438 let o = classify_publish_failure("TIMEOUT waiting for registry", "");
439 assert_eq!(o.class, CargoFailureClass::Retryable);
440 }
441
442 #[test]
443 fn gateway_timeout_504() {
444 let o = classify_publish_failure("", "HTTP/1.1 504 Gateway Timeout");
445 assert_eq!(o.class, CargoFailureClass::Retryable);
446 }
447
448 #[test]
451 fn auth_failure_unauthorized_response() {
452 let o = classify_publish_failure("the registry returned 401 Unauthorized", "");
453 assert_eq!(o.class, CargoFailureClass::Permanent);
454 }
455
456 #[test]
457 fn auth_failure_invalid_token() {
458 let o = classify_publish_failure("error: token is invalid or expired", "");
459 assert_eq!(o.class, CargoFailureClass::Permanent);
460 }
461
462 #[test]
463 fn auth_failure_forbidden() {
464 let o = classify_publish_failure("HTTP 403 Forbidden: you do not own this crate", "");
465 assert_eq!(o.class, CargoFailureClass::Permanent);
466 }
467
468 #[test]
469 fn auth_failure_not_authorized() {
470 let o = classify_publish_failure("not authorized to perform this action", "");
471 assert_eq!(o.class, CargoFailureClass::Permanent);
472 }
473
474 #[test]
477 fn already_published_version_exists() {
478 let o = classify_publish_failure(
479 "error: crate version `1.2.3` version already exists in registry",
480 "",
481 );
482 assert_eq!(o.class, CargoFailureClass::Permanent);
483 }
484
485 #[test]
486 fn already_published_is_already_uploaded() {
487 let o = classify_publish_failure("crate `my-crate` is already uploaded at 0.1.0", "");
488 assert_eq!(o.class, CargoFailureClass::Permanent);
489 }
490
491 #[test]
492 fn already_published_in_stdout() {
493 let o = classify_publish_failure("", "version already exists");
494 assert_eq!(o.class, CargoFailureClass::Permanent);
495 }
496
497 #[test]
500 fn empty_stderr_and_stdout_is_ambiguous() {
501 let o = classify_publish_failure("", "");
502 assert_eq!(o.class, CargoFailureClass::Ambiguous);
503 }
504
505 #[test]
506 fn whitespace_only_is_ambiguous() {
507 let o = classify_publish_failure(" \n\t ", " \n ");
508 assert_eq!(o.class, CargoFailureClass::Ambiguous);
509 }
510
511 #[test]
512 fn unicode_content_without_patterns_is_ambiguous() {
513 let o = classify_publish_failure("エラーが発生しました 🚨", "出力なし");
514 assert_eq!(o.class, CargoFailureClass::Ambiguous);
515 }
516
517 #[test]
518 fn unicode_surrounding_retryable_keyword() {
519 let o = classify_publish_failure("⚠️ timeout while connecting ⚠️", "");
520 assert_eq!(o.class, CargoFailureClass::Retryable);
521 }
522
523 #[test]
524 fn unicode_surrounding_permanent_keyword() {
525 let o = classify_publish_failure("❌ permission denied ❌", "");
526 assert_eq!(o.class, CargoFailureClass::Permanent);
527 }
528
529 #[test]
530 fn partial_match_within_word_still_matches() {
531 let o = classify_publish_failure("no dns resolution possible", "");
533 assert_eq!(o.class, CargoFailureClass::Retryable);
534 }
535
536 #[test]
537 fn pattern_at_very_start_of_string() {
538 let o = classify_publish_failure("tls error occurred", "");
539 assert_eq!(o.class, CargoFailureClass::Retryable);
540 }
541
542 #[test]
543 fn pattern_at_very_end_of_string() {
544 let o = classify_publish_failure("failed because of broken pipe", "");
545 assert_eq!(o.class, CargoFailureClass::Retryable);
546 }
547
548 #[test]
549 fn very_long_output_with_pattern_buried_deep() {
550 let noise = "a]b[c ".repeat(2000);
551 let stderr = format!("{noise}connection refused{noise}");
552 let o = classify_publish_failure(&stderr, "");
553 assert_eq!(o.class, CargoFailureClass::Retryable);
554 }
555
556 #[test]
557 fn newlines_within_output_do_not_prevent_match() {
558 let o = classify_publish_failure("line1\nline2\nconnection reset\nline4", "");
559 assert_eq!(o.class, CargoFailureClass::Retryable);
560 }
561
562 #[test]
563 fn case_insensitive_matching_retryable() {
564 let o = classify_publish_failure("CONNECTION REFUSED", "");
565 assert_eq!(o.class, CargoFailureClass::Retryable);
566 }
567
568 #[test]
569 fn case_insensitive_matching_permanent() {
570 let o = classify_publish_failure("TOKEN IS INVALID", "");
571 assert_eq!(o.class, CargoFailureClass::Permanent);
572 }
573
574 #[test]
575 fn mixed_case_matching() {
576 let o = classify_publish_failure("Timed Out waiting for response", "");
577 assert_eq!(o.class, CargoFailureClass::Retryable);
578 }
579
580 #[test]
581 fn retryable_in_stdout_permanent_in_stderr_retryable_wins() {
582 let o = classify_publish_failure("permission denied", "503 unavailable");
583 assert_eq!(o.class, CargoFailureClass::Retryable);
584 }
585
586 #[test]
587 fn multiple_retryable_patterns_still_retryable() {
588 let o = classify_publish_failure("timeout and connection reset and 503", "");
589 assert_eq!(o.class, CargoFailureClass::Retryable);
590 }
591
592 #[test]
593 fn multiple_permanent_patterns_still_permanent() {
594 let o = classify_publish_failure("token is invalid and permission denied", "");
595 assert_eq!(o.class, CargoFailureClass::Permanent);
596 }
597
598 #[test]
599 fn numeric_pattern_500_not_in_port_number() {
600 let o = classify_publish_failure("listening on port 15003", "");
604 assert_eq!(o.class, CargoFailureClass::Retryable);
605 }
606
607 #[test]
608 fn unknown_exit_code_is_ambiguous() {
609 let o = classify_publish_failure("cargo exited with code 42", "");
610 assert_eq!(o.class, CargoFailureClass::Ambiguous);
611 }
612
613 #[test]
614 fn gibberish_is_ambiguous() {
615 let o = classify_publish_failure("asdlkfjasldf", "qpwoeiruty");
616 assert_eq!(o.class, CargoFailureClass::Ambiguous);
617 }
618
619 #[test]
620 fn pattern_split_across_stderr_and_stdout_does_not_match_accidentally() {
621 let o = classify_publish_failure("timed", "out");
624 assert_eq!(o.class, CargoFailureClass::Ambiguous);
625 }
626
627 #[test]
630 fn snapshot_retryable_classification() {
631 let outcome = classify_publish_failure("HTTP 429 too many requests", "");
632 insta::assert_debug_snapshot!("retryable_classification", outcome);
633 }
634
635 #[test]
636 fn snapshot_permanent_classification() {
637 let outcome = classify_publish_failure("permission denied", "");
638 insta::assert_debug_snapshot!("permanent_classification", outcome);
639 }
640
641 #[test]
642 fn snapshot_ambiguous_classification() {
643 let outcome = classify_publish_failure("unexpected output", "");
644 insta::assert_debug_snapshot!("ambiguous_classification", outcome);
645 }
646
647 #[test]
648 fn snapshot_retryable_precedence_over_permanent() {
649 let outcome = classify_publish_failure("permission denied and 429", "");
650 insta::assert_debug_snapshot!("retryable_precedence", outcome);
651 }
652
653 #[test]
654 fn snapshot_debug_retryable() {
655 let outcome = classify_publish_failure("connection reset", "");
656 insta::assert_snapshot!("debug_retryable", format!("{outcome:?}"));
657 }
658
659 #[test]
660 fn snapshot_debug_permanent() {
661 let outcome = classify_publish_failure("token is invalid", "");
662 insta::assert_snapshot!("debug_permanent", format!("{outcome:?}"));
663 }
664
665 #[test]
666 fn snapshot_debug_ambiguous() {
667 let outcome = classify_publish_failure("", "");
668 insta::assert_snapshot!("debug_ambiguous", format!("{outcome:?}"));
669 }
670
671 #[test]
672 fn snapshot_debug_failure_class_variants() {
673 insta::assert_snapshot!(
674 "debug_class_retryable",
675 format!("{:?}", CargoFailureClass::Retryable)
676 );
677 insta::assert_snapshot!(
678 "debug_class_permanent",
679 format!("{:?}", CargoFailureClass::Permanent)
680 );
681 insta::assert_snapshot!(
682 "debug_class_ambiguous",
683 format!("{:?}", CargoFailureClass::Ambiguous)
684 );
685 }
686
687 #[test]
688 fn snapshot_all_classification_messages() {
689 let retryable = classify_publish_failure("503", "");
690 let permanent = classify_publish_failure("forbidden", "");
691 let ambiguous = classify_publish_failure("???", "");
692 insta::assert_snapshot!(
693 "all_messages",
694 format!(
695 "retryable: {}\npermanent: {}\nambiguous: {}",
696 retryable.message, permanent.message, ambiguous.message
697 )
698 );
699 }
700
701 #[test]
702 fn snapshot_realistic_rate_limit() {
703 let outcome = classify_publish_failure(
704 "error: failed to publish to registry crates-io\n\
705 Caused by:\n the remote server responded with 429 Too Many Requests",
706 "",
707 );
708 insta::assert_debug_snapshot!("realistic_rate_limit", outcome);
709 }
710
711 #[test]
712 fn snapshot_realistic_already_published() {
713 let outcome = classify_publish_failure(
714 "error: failed to publish crate `my-crate v1.0.0`\n\
715 Caused by:\n the remote server responded: crate version `1.0.0` \
716 is already uploaded",
717 "",
718 );
719 insta::assert_debug_snapshot!("realistic_already_published", outcome);
720 }
721
722 #[test]
723 fn snapshot_realistic_compilation_failure() {
724 let outcome = classify_publish_failure(
725 "error[E0308]: mismatched types\n\
726 error: could not compile `my-crate` due to previous error",
727 "",
728 );
729 insta::assert_debug_snapshot!("realistic_compilation_failure", outcome);
730 }
731
732 #[test]
735 fn snapshot_realistic_network_connection_reset() {
736 let outcome = classify_publish_failure(
737 "error: failed to publish to registry\n\
738 Caused by:\n failed to send request: \
739 error sending request for url (https://crates.io/api/v1/crates/new): \
740 connection reset by peer",
741 "",
742 );
743 insta::assert_debug_snapshot!("realistic_network_connection_reset", outcome);
744 }
745
746 #[test]
747 fn snapshot_realistic_dns_resolution_failure() {
748 let outcome = classify_publish_failure(
749 "error: failed to publish to registry crates-io\n\
750 Caused by:\n dns error: failed to lookup address information: \
751 Name or service not known",
752 "",
753 );
754 insta::assert_debug_snapshot!("realistic_dns_resolution_failure", outcome);
755 }
756
757 #[test]
758 fn snapshot_realistic_tls_handshake_failure() {
759 let outcome = classify_publish_failure(
760 "error: failed to publish to registry crates-io\n\
761 Caused by:\n tls handshake failed: the certificate was not trusted",
762 "",
763 );
764 insta::assert_debug_snapshot!("realistic_tls_handshake_failure", outcome);
765 }
766
767 #[test]
768 fn snapshot_realistic_broken_pipe() {
769 let outcome = classify_publish_failure(
770 "error: failed to publish to registry crates-io\n\
771 Caused by:\n broken pipe (os error 32)",
772 "",
773 );
774 insta::assert_debug_snapshot!("realistic_broken_pipe", outcome);
775 }
776
777 #[test]
780 fn snapshot_realistic_auth_unauthorized() {
781 let outcome = classify_publish_failure(
782 "error: failed to publish to registry crates-io\n\
783 Caused by:\n the remote server responded with 401 Unauthorized\n\
784 Note: check your API token",
785 "",
786 );
787 insta::assert_debug_snapshot!("realistic_auth_unauthorized", outcome);
788 }
789
790 #[test]
791 fn snapshot_realistic_forbidden_not_owner() {
792 let outcome = classify_publish_failure(
793 "error: failed to publish to registry crates-io\n\
794 Caused by:\n the remote server responded with 403 Forbidden: \
795 you are not an owner of this crate",
796 "",
797 );
798 insta::assert_debug_snapshot!("realistic_forbidden_not_owner", outcome);
799 }
800
801 #[test]
802 fn snapshot_realistic_token_expired() {
803 let outcome = classify_publish_failure(
804 "error: failed to publish to registry crates-io\n\
805 Caused by:\n token is invalid or has expired; \
806 please generate a new token at https://crates.io/me",
807 "",
808 );
809 insta::assert_debug_snapshot!("realistic_token_expired", outcome);
810 }
811
812 #[test]
815 fn snapshot_realistic_manifest_missing_fields() {
816 let outcome = classify_publish_failure(
817 "",
818 "error: 3 fields are missing from `Cargo.toml`:\n\
819 - description\n- license\n- repository",
820 );
821 insta::assert_debug_snapshot!("realistic_manifest_missing_fields", outcome);
822 }
823
824 #[test]
825 fn snapshot_realistic_verification_failure() {
826 let outcome = classify_publish_failure(
827 "error: failed to verify package tarball\n\
828 Caused by:\n failed to compile `my-crate v0.1.0`",
829 "",
830 );
831 insta::assert_debug_snapshot!("realistic_verification_failure", outcome);
832 }
833
834 #[test]
835 fn snapshot_realistic_publish_disabled() {
836 let outcome = classify_publish_failure(
837 "error: `my-crate` cannot be published.\n\
838 `publish` is set to `false` or an empty list in Cargo.toml \
839 and prevents publishing.",
840 "",
841 );
842 insta::assert_debug_snapshot!("realistic_publish_disabled", outcome);
843 }
844
845 #[test]
846 fn snapshot_realistic_checksum_mismatch() {
847 let outcome = classify_publish_failure(
848 "error: failed to verify package tarball\n\
849 Caused by:\n checksum mismatch for crate `my-dep v0.2.0`",
850 "",
851 );
852 insta::assert_debug_snapshot!("realistic_checksum_mismatch", outcome);
853 }
854
855 #[test]
858 fn snapshot_stdout_retryable_detection() {
859 let outcome = classify_publish_failure("", "503 Service Unavailable");
860 insta::assert_debug_snapshot!("stdout_retryable_detection", outcome);
861 }
862
863 #[test]
864 fn snapshot_stdout_permanent_detection() {
865 let outcome = classify_publish_failure("", "version already exists");
866 insta::assert_debug_snapshot!("stdout_permanent_detection", outcome);
867 }
868
869 #[test]
870 fn snapshot_empty_input() {
871 let outcome = classify_publish_failure("", "");
872 insta::assert_debug_snapshot!("empty_input", outcome);
873 }
874
875 #[test]
876 fn snapshot_whitespace_only_input() {
877 let outcome = classify_publish_failure(" \n\t ", " \n ");
878 insta::assert_debug_snapshot!("whitespace_only_input", outcome);
879 }
880
881 #[test]
882 fn snapshot_case_insensitive_uppercase_retryable() {
883 let outcome = classify_publish_failure("CONNECTION REFUSED", "");
884 insta::assert_debug_snapshot!("case_insensitive_uppercase_retryable", outcome);
885 }
886
887 #[test]
888 fn snapshot_case_insensitive_uppercase_permanent() {
889 let outcome = classify_publish_failure("TOKEN IS INVALID", "");
890 insta::assert_debug_snapshot!("case_insensitive_uppercase_permanent", outcome);
891 }
892
893 #[test]
894 fn snapshot_cross_stream_retryable_precedence() {
895 let outcome = classify_publish_failure("permission denied", "503 unavailable");
896 insta::assert_debug_snapshot!("cross_stream_retryable_precedence", outcome);
897 }
898
899 #[test]
900 fn snapshot_multiline_noise_buried_pattern() {
901 let outcome = classify_publish_failure(
902 "Compiling my-crate v0.1.0\n\
903 Packaging my-crate v0.1.0\n\
904 Uploading my-crate v0.1.0\n\
905 error: failed to send request\n\
906 network unreachable",
907 "",
908 );
909 insta::assert_debug_snapshot!("multiline_noise_buried_pattern", outcome);
910 }
911
912 #[test]
915 fn realistic_crates_io_rate_limit() {
916 let o = classify_publish_failure(
917 "error: failed to publish to registry crates-io\n\
918 Caused by:\n the remote server responded with 429 Too Many Requests",
919 "",
920 );
921 assert_eq!(o.class, CargoFailureClass::Retryable);
922 }
923
924 #[test]
925 fn realistic_manifest_missing_description() {
926 let o = classify_publish_failure(
927 "",
928 "error: 3 fields are missing from `Cargo.toml`:\n\
929 - description\n- license\n- repository",
930 );
931 assert_eq!(o.class, CargoFailureClass::Permanent);
932 }
933
934 #[test]
935 fn realistic_already_published() {
936 let o = classify_publish_failure(
937 "error: failed to publish crate `my-crate v1.0.0`\n\
938 Caused by:\n the remote server responded: crate version `1.0.0` \
939 is already uploaded",
940 "",
941 );
942 assert_eq!(o.class, CargoFailureClass::Permanent);
943 }
944
945 #[test]
946 fn realistic_compilation_failure() {
947 let o = classify_publish_failure(
948 "error[E0308]: mismatched types\n\
949 error: could not compile `my-crate` due to previous error",
950 "",
951 );
952 assert_eq!(o.class, CargoFailureClass::Permanent);
953 }
954
955 #[test]
956 fn realistic_network_failure() {
957 let o = classify_publish_failure(
958 "error: failed to publish to registry\n\
959 Caused by:\n failed to send request: \
960 error sending request for url (https://crates.io/api/v1/crates/new): \
961 connection reset by peer",
962 "",
963 );
964 assert_eq!(o.class, CargoFailureClass::Retryable);
965 }
966
967 #[test]
970 fn ambiguous_upload_maybe_succeeded_process_killed() {
971 let o = classify_publish_failure("Uploading my-crate v0.1.0 (registry `crates-io`)", "");
973 assert_eq!(o.class, CargoFailureClass::Ambiguous);
974 }
975
976 #[test]
977 fn ambiguous_upload_sent_no_response() {
978 let o = classify_publish_failure("error: failed to get a response from the registry", "");
980 assert_eq!(o.class, CargoFailureClass::Ambiguous);
981 }
982
983 #[test]
984 fn ambiguous_signal_terminated() {
985 let o = classify_publish_failure("signal: killed", "");
987 assert_eq!(o.class, CargoFailureClass::Ambiguous);
988 }
989
990 #[test]
991 fn ambiguous_partial_json_response() {
992 let o = classify_publish_failure(r#"error: unexpected end of JSON: {"ok":tr"#, "");
994 assert_eq!(o.class, CargoFailureClass::Ambiguous);
995 }
996
997 #[test]
998 fn ambiguous_only_status_code_no_pattern() {
999 let o = classify_publish_failure("the server responded with status 409", "");
1001 assert_eq!(o.class, CargoFailureClass::Ambiguous);
1002 }
1003
1004 #[test]
1007 fn snapshot_ambiguous_process_killed_mid_upload() {
1008 let outcome =
1009 classify_publish_failure("Uploading my-crate v0.1.0 (registry `crates-io`)", "");
1010 insta::assert_debug_snapshot!("ambiguous_process_killed_mid_upload", outcome);
1011 }
1012
1013 #[test]
1014 fn snapshot_ambiguous_no_registry_response() {
1015 let outcome =
1016 classify_publish_failure("error: failed to get a response from the registry", "");
1017 insta::assert_debug_snapshot!("ambiguous_no_registry_response", outcome);
1018 }
1019
1020 #[test]
1021 fn snapshot_ambiguous_signal_terminated() {
1022 let outcome = classify_publish_failure("signal: killed", "");
1023 insta::assert_debug_snapshot!("ambiguous_signal_terminated", outcome);
1024 }
1025
1026 #[test]
1029 fn snapshot_realistic_ci_cancellation() {
1030 let outcome = classify_publish_failure(
1031 "Compiling my-crate v0.1.0\n\
1032 Packaging my-crate v0.1.0\n\
1033 Uploading my-crate v0.1.0\n\
1034 Received signal 15, shutting down",
1035 "",
1036 );
1037 insta::assert_debug_snapshot!("realistic_ci_cancellation", outcome);
1038 }
1039
1040 #[test]
1041 fn snapshot_realistic_partial_json_response() {
1042 let outcome = classify_publish_failure(r#"error: unexpected end of JSON: {"ok":tr"#, "");
1043 insta::assert_debug_snapshot!("realistic_partial_json_response", outcome);
1044 }
1045
1046 #[test]
1049 fn retryable_pattern_in_stderr_permanent_in_stdout_retryable_wins() {
1050 let o = classify_publish_failure("connection refused", "version already exists");
1051 assert_eq!(o.class, CargoFailureClass::Retryable);
1052 }
1053
1054 #[test]
1055 fn permanent_only_in_stdout_no_retryable_anywhere() {
1056 let o = classify_publish_failure("some other output", "is already uploaded");
1057 assert_eq!(o.class, CargoFailureClass::Permanent);
1058 }
1059
1060 #[test]
1061 fn null_byte_in_output_does_not_crash() {
1062 let o = classify_publish_failure("before\0after", "");
1063 assert_eq!(o.class, CargoFailureClass::Ambiguous);
1064 }
1065
1066 #[test]
1067 fn very_long_output_all_noise_is_ambiguous() {
1068 let noise = "xyzzy ".repeat(5000);
1069 let o = classify_publish_failure(&noise, &noise);
1070 assert_eq!(o.class, CargoFailureClass::Ambiguous);
1071 }
1072
1073 #[test]
1074 fn pattern_as_exact_input_retryable() {
1075 for pattern in &RETRYABLE_PATTERNS {
1077 let o = classify_publish_failure(pattern, "");
1078 assert_eq!(o.class, CargoFailureClass::Retryable, "pattern: {pattern}");
1079 }
1080 }
1081
1082 #[test]
1083 fn pattern_as_exact_input_permanent() {
1084 for pattern in &PERMANENT_PATTERNS {
1088 let o = classify_publish_failure(pattern, "");
1089 assert_ne!(
1090 o.class,
1091 CargoFailureClass::Ambiguous,
1092 "pattern {pattern} should not be ambiguous"
1093 );
1094 }
1095 }
1096
1097 #[test]
1098 fn snapshot_retryable_pattern_exhaustive() {
1099 let results: Vec<_> = RETRYABLE_PATTERNS
1100 .iter()
1101 .map(|p| {
1102 let o = classify_publish_failure(p, "");
1103 format!("{p} => {:?}", o.class)
1104 })
1105 .collect();
1106 insta::assert_snapshot!("retryable_pattern_exhaustive", results.join("\n"));
1107 }
1108
1109 #[test]
1110 fn snapshot_permanent_pattern_exhaustive() {
1111 let results: Vec<_> = PERMANENT_PATTERNS
1112 .iter()
1113 .map(|p| {
1114 let o = classify_publish_failure(p, "");
1115 format!("{p} => {:?}", o.class)
1116 })
1117 .collect();
1118 insta::assert_snapshot!("permanent_pattern_exhaustive", results.join("\n"));
1119 }
1120
1121 #[test]
1124 fn realworld_connection_reset_with_os_error() {
1125 let o = classify_publish_failure(
1126 "error: failed to publish to registry crates-io\n\
1127 Caused by:\n error sending request: \
1128 hyper::Error(SendRequest, ConnectError(\"tcp connect error\", \
1129 Os { code: 104, kind: ConnectionReset, message: \"Connection reset by peer\" }))",
1130 "",
1131 );
1132 assert_eq!(o.class, CargoFailureClass::Retryable);
1133 }
1134
1135 #[test]
1136 fn realworld_dns_failure_getaddrinfo() {
1137 let o = classify_publish_failure(
1138 "error: failed to publish to registry crates-io\n\
1139 Caused by:\n error trying to connect: \
1140 dns error: failed to lookup address information: \
1141 Temporary failure in name resolution",
1142 "",
1143 );
1144 assert_eq!(o.class, CargoFailureClass::Retryable);
1145 }
1146
1147 #[test]
1148 fn realworld_dns_failure_windows() {
1149 let o = classify_publish_failure(
1150 "error: failed to publish to registry crates-io\n\
1151 Caused by:\n dns error: No such host is known. (os error 11001)",
1152 "",
1153 );
1154 assert_eq!(o.class, CargoFailureClass::Retryable);
1155 }
1156
1157 #[test]
1158 fn realworld_crate_version_already_uploaded_exact() {
1159 let o = classify_publish_failure(
1160 "error: failed to publish to registry crates-io\n\
1161 Caused by:\n the remote server responded with an error: \
1162 crate version `0.3.7` is already uploaded",
1163 "",
1164 );
1165 assert_eq!(o.class, CargoFailureClass::Permanent);
1166 }
1167
1168 #[test]
1169 fn realworld_version_already_exists_with_crate_name() {
1170 let o = classify_publish_failure(
1171 "error: failed to publish to registry crates-io\n\
1172 Caused by:\n the remote server responded with an error (status 200 OK): \
1173 crate version already exists: `my-crate@1.2.3`",
1174 "",
1175 );
1176 assert_eq!(o.class, CargoFailureClass::Permanent);
1177 }
1178
1179 #[test]
1180 fn realworld_feature_resolution_failure() {
1181 let o = classify_publish_failure(
1182 "error: failed to verify package tarball\n\
1183 Caused by:\n failed to select a version for the requirement `tokio = \"^2.0\"`\n\
1184 candidate versions found which didn't match: 1.38.0, 1.37.0, 1.36.0\n\
1185 location searched: crates.io index\n\
1186 required by package `my-crate v0.1.0`",
1187 "",
1188 );
1189 assert_eq!(o.class, CargoFailureClass::Permanent);
1190 }
1191
1192 #[test]
1193 fn realworld_compilation_error_type_mismatch() {
1194 let o = classify_publish_failure(
1195 "error[E0308]: mismatched types\n\
1196 --> src/lib.rs:42:5\n |\n42 | foo()\n | ^^^^^ \
1197 expected `u32`, found `String`\n\n\
1198 error: could not compile `my-crate` (lib) due to 1 previous error\n\
1199 error: failed to verify package tarball",
1200 "",
1201 );
1202 assert_eq!(o.class, CargoFailureClass::Permanent);
1203 }
1204
1205 #[test]
1206 fn realworld_compilation_error_unresolved_import() {
1207 let o = classify_publish_failure(
1208 "error[E0432]: unresolved import `crate::foo`\n\
1209 --> src/lib.rs:1:5\n |\n1 | use crate::foo;\n | ^^^^^^^^^^ \
1210 no `foo` in the root\n\n\
1211 error: could not compile `my-crate` (lib) due to 1 previous error",
1212 "",
1213 );
1214 assert_eq!(o.class, CargoFailureClass::Permanent);
1215 }
1216
1217 #[test]
1218 fn realworld_ssl_certificate_not_trusted() {
1219 let o = classify_publish_failure(
1220 "error: failed to publish to registry custom-registry\n\
1221 Caused by:\n error sending request: \
1222 tls error: the certificate was not trusted: self-signed certificate",
1223 "",
1224 );
1225 assert_eq!(o.class, CargoFailureClass::Retryable);
1226 }
1227
1228 #[test]
1229 fn realworld_cargo_http_500_with_body() {
1230 let o = classify_publish_failure(
1231 "error: failed to publish to registry crates-io\n\
1232 Caused by:\n the remote server responded with an error: \
1233 500 Internal Server Error\n\
1234 <html><body>Internal Server Error</body></html>",
1235 "",
1236 );
1237 assert_eq!(o.class, CargoFailureClass::Retryable);
1238 }
1239
1240 #[test]
1241 fn realworld_cargo_http_502_cloudflare() {
1242 let o = classify_publish_failure(
1243 "error: failed to publish to registry crates-io\n\
1244 Caused by:\n the remote server responded with: \
1245 502 Bad Gateway\n\
1246 <html><head><title>502 Bad Gateway</title></head>\
1247 <body>cloudflare</body></html>",
1248 "",
1249 );
1250 assert_eq!(o.class, CargoFailureClass::Retryable);
1251 }
1252
1253 #[test]
1254 fn realworld_publish_disabled_in_manifest() {
1255 let o = classify_publish_failure(
1256 "error: `my-internal-crate` cannot be published.\n\
1257 publish is disabled for this crate in Cargo.toml",
1258 "",
1259 );
1260 assert_eq!(o.class, CargoFailureClass::Permanent);
1261 }
1262
1263 #[test]
1264 fn realworld_yanked_dependency() {
1265 let o = classify_publish_failure(
1266 "error: failed to verify package tarball\n\
1267 Caused by:\n failed to download `old-dep v0.1.0`\n\
1268 Caused by:\n version `0.1.0` of crate `old-dep` has been yanked",
1269 "",
1270 );
1271 assert_eq!(o.class, CargoFailureClass::Retryable);
1272 }
1273
1274 #[test]
1275 fn realworld_broken_pipe_on_large_crate() {
1276 let o = classify_publish_failure(
1277 "error: failed to publish to registry crates-io\n\
1278 Caused by:\n failed to send request body: \
1279 broken pipe (os error 32): the connection was closed by the server",
1280 "",
1281 );
1282 assert_eq!(o.class, CargoFailureClass::Retryable);
1283 }
1284
1285 #[test]
1286 fn realworld_connection_refused_localhost() {
1287 let o = classify_publish_failure(
1288 "error: failed to publish to registry custom-registry\n\
1289 Caused by:\n error trying to connect: tcp connect error: \
1290 Connection refused (os error 111)",
1291 "",
1292 );
1293 assert_eq!(o.class, CargoFailureClass::Retryable);
1294 }
1295
1296 #[test]
1297 fn realworld_network_unreachable_no_internet() {
1298 let o = classify_publish_failure(
1299 "error: failed to publish to registry crates-io\n\
1300 Caused by:\n error trying to connect: tcp connect error: \
1301 Network unreachable (os error 101)",
1302 "",
1303 );
1304 assert_eq!(o.class, CargoFailureClass::Retryable);
1305 }
1306
1307 #[test]
1308 fn realworld_invalid_credentials_from_credential_helper() {
1309 let o = classify_publish_failure(
1310 "error: failed to publish to registry crates-io\n\
1311 Caused by:\n invalid credentials: \
1312 the credential-process for registry `crates-io` returned an error",
1313 "",
1314 );
1315 assert_eq!(o.class, CargoFailureClass::Permanent);
1316 }
1317
1318 #[test]
1321 fn ambiguous_http_408_request_timeout_no_pattern() {
1322 let o = classify_publish_failure(
1324 "the remote server responded with status 408 Request Timeout",
1325 "",
1326 );
1327 assert_eq!(o.class, CargoFailureClass::Retryable);
1329 }
1330
1331 #[test]
1332 fn ambiguous_http_409_conflict() {
1333 let o =
1334 classify_publish_failure("the remote server responded with status 409 Conflict", "");
1335 assert_eq!(o.class, CargoFailureClass::Ambiguous);
1336 }
1337
1338 #[test]
1339 fn ambiguous_segfault_in_cargo() {
1340 let o = classify_publish_failure("", "Segmentation fault (core dumped)");
1341 assert_eq!(o.class, CargoFailureClass::Ambiguous);
1342 }
1343
1344 #[test]
1345 fn ambiguous_oom_killed() {
1346 let o = classify_publish_failure("", "Killed");
1347 assert_eq!(o.class, CargoFailureClass::Ambiguous);
1348 }
1349
1350 #[test]
1351 fn ambiguous_registry_returns_html_instead_of_json() {
1352 let o = classify_publish_failure(
1353 "error: failed to publish to registry crates-io\n\
1354 Caused by:\n expected JSON, got: \
1355 <html><head><title>Maintenance</title></head></html>",
1356 "",
1357 );
1358 assert_eq!(o.class, CargoFailureClass::Ambiguous);
1359 }
1360
1361 #[test]
1362 fn ambiguous_aborting_without_details() {
1363 let o = classify_publish_failure("error: aborting due to previous error", "");
1364 assert_eq!(o.class, CargoFailureClass::Ambiguous);
1365 }
1366
1367 #[test]
1368 fn ambiguous_exit_code_only() {
1369 let o = classify_publish_failure("", "process exited with code 1");
1370 assert_eq!(o.class, CargoFailureClass::Ambiguous);
1371 }
1372
1373 #[test]
1376 fn cross_stream_retryable_stderr_permanent_stdout() {
1377 let o = classify_publish_failure("503 Service Unavailable", "is already uploaded");
1378 assert_eq!(o.class, CargoFailureClass::Retryable);
1379 }
1380
1381 #[test]
1382 fn cross_stream_permanent_stderr_retryable_stdout() {
1383 let o = classify_publish_failure("token is invalid", "connection reset");
1384 assert_eq!(o.class, CargoFailureClass::Retryable);
1385 }
1386
1387 #[test]
1388 fn cross_stream_both_retryable_different_patterns() {
1389 let o = classify_publish_failure("connection refused", "broken pipe");
1390 assert_eq!(o.class, CargoFailureClass::Retryable);
1391 }
1392
1393 #[test]
1394 fn cross_stream_both_permanent_different_patterns() {
1395 let o = classify_publish_failure("unauthorized", "checksum mismatch");
1396 assert_eq!(o.class, CargoFailureClass::Permanent);
1397 }
1398
1399 #[test]
1400 fn cross_stream_stderr_ambiguous_stdout_retryable() {
1401 let o = classify_publish_failure("something went wrong", "dns resolution failed");
1402 assert_eq!(o.class, CargoFailureClass::Retryable);
1403 }
1404
1405 #[test]
1406 fn cross_stream_stderr_ambiguous_stdout_permanent() {
1407 let o = classify_publish_failure("something went wrong", "version already exists");
1408 assert_eq!(o.class, CargoFailureClass::Permanent);
1409 }
1410
1411 #[test]
1412 fn cross_stream_stderr_retryable_stdout_empty() {
1413 let o = classify_publish_failure("too many requests", "");
1414 assert_eq!(o.class, CargoFailureClass::Retryable);
1415 }
1416
1417 #[test]
1418 fn cross_stream_stderr_empty_stdout_permanent() {
1419 let o = classify_publish_failure("", "could not compile `my-crate`");
1420 assert_eq!(o.class, CargoFailureClass::Permanent);
1421 }
1422
1423 #[test]
1426 fn snapshot_realworld_feature_resolution_failure() {
1427 let outcome = classify_publish_failure(
1428 "error: failed to verify package tarball\n\
1429 Caused by:\n failed to select a version for the requirement `tokio = \"^2.0\"`\n\
1430 candidate versions found which didn't match: 1.38.0, 1.37.0\n\
1431 required by package `my-crate v0.1.0`",
1432 "",
1433 );
1434 insta::assert_debug_snapshot!("realworld_feature_resolution_failure", outcome);
1435 }
1436
1437 #[test]
1438 fn snapshot_realworld_connection_reset_os_error() {
1439 let outcome = classify_publish_failure(
1440 "error: failed to publish to registry crates-io\n\
1441 Caused by:\n error sending request: \
1442 hyper::Error(SendRequest, ConnectError(\"tcp connect error\", \
1443 Os { code: 104, kind: ConnectionReset, message: \"Connection reset by peer\" }))",
1444 "",
1445 );
1446 insta::assert_debug_snapshot!("realworld_connection_reset_os_error", outcome);
1447 }
1448
1449 #[test]
1450 fn snapshot_realworld_http_409_conflict() {
1451 let outcome =
1452 classify_publish_failure("the remote server responded with status 409 Conflict", "");
1453 insta::assert_debug_snapshot!("realworld_http_409_conflict", outcome);
1454 }
1455
1456 #[test]
1457 fn snapshot_cross_stream_mixed_signals() {
1458 let outcome = classify_publish_failure("token is invalid", "connection reset by peer");
1459 insta::assert_debug_snapshot!("cross_stream_mixed_signals", outcome);
1460 }
1461
1462 #[test]
1463 fn snapshot_realworld_oom_killed() {
1464 let outcome = classify_publish_failure("", "Killed");
1465 insta::assert_debug_snapshot!("realworld_oom_killed", outcome);
1466 }
1467
1468 #[test]
1471 fn snapshot_error_message_retryable_contains_action() {
1472 let outcome = classify_publish_failure("HTTP 429 too many requests", "");
1473 insta::assert_snapshot!("error_msg_retryable_action", outcome.message);
1474 }
1475
1476 #[test]
1477 fn snapshot_error_message_permanent_contains_action() {
1478 let outcome = classify_publish_failure("permission denied for crate my-crate", "");
1479 insta::assert_snapshot!("error_msg_permanent_action", outcome.message);
1480 }
1481
1482 #[test]
1483 fn snapshot_error_message_ambiguous_contains_context() {
1484 let outcome = classify_publish_failure("unexpected EOF during upload", "");
1485 insta::assert_snapshot!("error_msg_ambiguous_context", outcome.message);
1486 }
1487
1488 #[test]
1489 fn snapshot_error_message_version_already_exists() {
1490 let outcome =
1491 classify_publish_failure("crate version `my-crate@1.0.0` is already uploaded", "");
1492 insta::assert_snapshot!(
1493 "error_msg_version_already_exists",
1494 format!("[{}] {}", format!("{:?}", outcome.class), outcome.message)
1495 );
1496 }
1497
1498 #[test]
1499 fn snapshot_error_message_manifest_parse_failure() {
1500 let outcome = classify_publish_failure(
1501 "error: failed to parse manifest at `/path/to/Cargo.toml`\n\
1502 Caused by:\n missing field `name` in package",
1503 "",
1504 );
1505 insta::assert_snapshot!(
1506 "error_msg_manifest_parse_failure",
1507 format!("[{}] {}", format!("{:?}", outcome.class), outcome.message)
1508 );
1509 }
1510
1511 #[test]
1512 fn snapshot_error_message_network_dns_resolution() {
1513 let outcome = classify_publish_failure(
1514 "error: failed to publish to crates-io\n\
1515 Caused by:\n dns resolution failed: could not resolve host crates.io",
1516 "",
1517 );
1518 insta::assert_snapshot!(
1519 "error_msg_dns_resolution",
1520 format!("[{}] {}", format!("{:?}", outcome.class), outcome.message)
1521 );
1522 }
1523}
1524
1525#[cfg(test)]
1526mod property_tests {
1527 use super::*;
1528 use proptest::prelude::*;
1529
1530 fn ascii_text() -> impl Strategy<Value = String> {
1531 proptest::collection::vec(any::<u8>(), 0..256)
1532 .prop_map(|bytes| bytes.into_iter().map(char::from).collect())
1533 }
1534
1535 fn arbitrary_string() -> impl Strategy<Value = String> {
1536 prop::string::string_regex(".*").unwrap()
1537 }
1538
1539 proptest! {
1540 #[test]
1541 fn classification_is_deterministic(stderr in ascii_text(), stdout in ascii_text()) {
1542 let first = classify_publish_failure(&stderr, &stdout);
1543 let second = classify_publish_failure(&stderr, &stdout);
1544 prop_assert_eq!(first, second);
1545 }
1546
1547 #[test]
1548 fn classification_is_case_insensitive_for_ascii(stderr in ascii_text(), stdout in ascii_text()) {
1549 let lower = classify_publish_failure(
1550 &stderr.to_ascii_lowercase(),
1551 &stdout.to_ascii_lowercase(),
1552 );
1553 let upper = classify_publish_failure(
1554 &stderr.to_ascii_uppercase(),
1555 &stdout.to_ascii_uppercase(),
1556 );
1557 prop_assert_eq!(lower.class, upper.class);
1558 }
1559
1560 #[test]
1561 fn retryable_patterns_have_precedence(noise in ascii_text()) {
1562 let stderr = format!("{noise} permission denied and too many requests");
1563 let outcome = classify_publish_failure(&stderr, "");
1564 prop_assert_eq!(outcome.class, CargoFailureClass::Retryable);
1565 }
1566
1567 #[test]
1569 fn any_input_produces_valid_class(stderr in arbitrary_string(), stdout in arbitrary_string()) {
1570 let outcome = classify_publish_failure(&stderr, &stdout);
1571 prop_assert!(
1572 matches!(
1573 outcome.class,
1574 CargoFailureClass::Retryable
1575 | CargoFailureClass::Permanent
1576 | CargoFailureClass::Ambiguous
1577 ),
1578 "unexpected class: {:?}",
1579 outcome.class
1580 );
1581 }
1582
1583 #[test]
1585 fn message_is_never_empty(stderr in arbitrary_string(), stdout in arbitrary_string()) {
1586 let outcome = classify_publish_failure(&stderr, &stdout);
1587 prop_assert!(!outcome.message.is_empty());
1588 }
1589
1590 #[test]
1592 fn stderr_stdout_symmetry(stderr in ascii_text(), stdout in ascii_text()) {
1593 let normal = classify_publish_failure(&stderr, &stdout);
1594 let swapped = classify_publish_failure(&stdout, &stderr);
1595 prop_assert_eq!(normal.class, swapped.class);
1596 }
1597
1598 #[test]
1600 fn retryable_pattern_survives_noise(
1601 prefix in ascii_text(),
1602 suffix in ascii_text(),
1603 idx in 0..20usize,
1604 ) {
1605 let pattern = RETRYABLE_PATTERNS[idx];
1606 let stderr = format!("{prefix}{pattern}{suffix}");
1607 let outcome = classify_publish_failure(&stderr, "");
1608 prop_assert_eq!(outcome.class, CargoFailureClass::Retryable);
1609 }
1610
1611 #[test]
1614 fn permanent_pattern_survives_noise(
1615 prefix in "[a-z ]{0,50}",
1616 suffix in "[a-z ]{0,50}",
1617 idx in 0..22usize,
1618 ) {
1619 let pattern = PERMANENT_PATTERNS[idx];
1620 let stderr = format!("{prefix}{pattern}{suffix}");
1622 let outcome = classify_publish_failure(&stderr, "");
1623 prop_assert_ne!(outcome.class, CargoFailureClass::Ambiguous);
1626 }
1627
1628 #[test]
1631 fn retryable_always_dominates_permanent(
1632 r_idx in 0..20usize,
1633 p_idx in 0..22usize,
1634 sep in "[a-z ]{1,20}",
1635 ) {
1636 let retryable = RETRYABLE_PATTERNS[r_idx];
1637 let permanent = PERMANENT_PATTERNS[p_idx];
1638 let stderr_a = format!("{permanent}{sep}{retryable}");
1640 let outcome_a = classify_publish_failure(&stderr_a, "");
1641 prop_assert_eq!(outcome_a.class, CargoFailureClass::Retryable);
1642 let stderr_b = format!("{retryable}{sep}{permanent}");
1644 let outcome_b = classify_publish_failure(&stderr_b, "");
1645 prop_assert_eq!(outcome_b.class, CargoFailureClass::Retryable);
1646 }
1647
1648 #[test]
1650 fn message_matches_class(stderr in arbitrary_string(), stdout in arbitrary_string()) {
1651 let outcome = classify_publish_failure(&stderr, &stdout);
1652 match outcome.class {
1653 CargoFailureClass::Retryable => {
1654 prop_assert_eq!(outcome.message, "transient failure (retryable)");
1655 }
1656 CargoFailureClass::Permanent => {
1657 prop_assert_eq!(outcome.message, "permanent failure (fix required)");
1658 }
1659 CargoFailureClass::Ambiguous => {
1660 prop_assert_eq!(
1661 outcome.message,
1662 "publish outcome ambiguous; registry did not show version"
1663 );
1664 }
1665 }
1666 }
1667 }
1668}