Skip to main content

shipper_cargo_failure/
lib.rs

1//! Cargo publish failure classification.
2//!
3//! This crate isolates error classification heuristics used by shipper's
4//! publish engine so they can be reused and tested independently.
5
6/// Error class for cargo publish failures.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum CargoFailureClass {
9    /// Transient failure that can succeed on retry.
10    Retryable,
11    /// Persistent failure requiring user changes before retry.
12    Permanent,
13    /// Outcome is unclear and must be confirmed against the registry.
14    Ambiguous,
15}
16
17/// Classifier output for a cargo publish failure.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct CargoFailureOutcome {
20    /// Derived failure class.
21    pub class: CargoFailureClass,
22    /// Human-readable summary used in logs/receipts.
23    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
74/// Classify cargo publish output into retry behavior categories.
75///
76/// Matching is case-insensitive and scans both stderr and stdout.
77/// Retryable patterns take precedence over permanent ones.
78pub 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    // ── basic classification ────────────────────────────────────────────
112
113    #[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    // ── every retryable pattern individually ────────────────────────────
150
151    #[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    // ── every permanent pattern individually ────────────────────────────
272
273    #[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    // ── rate limiting detection ─────────────────────────────────────────
406
407    #[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    // ── network timeout detection ───────────────────────────────────────
429
430    #[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    // ── authentication failure detection ────────────────────────────────
449
450    #[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    // ── already-published detection ─────────────────────────────────────
475
476    #[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    // ── edge cases ──────────────────────────────────────────────────────
498
499    #[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        // "dns" appears within "no dns resolution" — substring match should work
532        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        // "500" as a substring will match even in "port 15003" — this confirms
601        // the classifier uses simple substring matching, which is the intended
602        // behavior as documented.
603        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        // "timed out" won't match if "timed" is in stderr and "out" is in stdout,
622        // because the haystack is "timed\nout" — substring "timed out" is not present.
623        let o = classify_publish_failure("timed", "out");
624        assert_eq!(o.class, CargoFailureClass::Ambiguous);
625    }
626
627    // ── insta snapshot tests ────────────────────────────────────────────
628
629    #[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    // ── snapshot: realistic network / transient failures ────────────────
733
734    #[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    // ── snapshot: realistic auth / permission failures ──────────────────
778
779    #[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    // ── snapshot: realistic manifest / config failures ──────────────────
813
814    #[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    // ── snapshot: edge-case and cross-stream detection ──────────────────
856
857    #[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    // ── realistic cargo publish error messages ──────────────────────────
913
914    #[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    // ── ambiguous: "upload maybe succeeded" scenarios ───────────────────
968
969    #[test]
970    fn ambiguous_upload_maybe_succeeded_process_killed() {
971        // Cargo killed mid-upload — no retryable/permanent pattern in truncated output
972        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        // Upload request was dispatched but process exited before a response arrived
979        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        // Process terminated by signal (e.g. CI cancellation)
986        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        // Registry returned truncated JSON — unclear if publish landed
993        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        // Status 409 doesn't match any known pattern
1000        let o = classify_publish_failure("the server responded with status 409", "");
1001        assert_eq!(o.class, CargoFailureClass::Ambiguous);
1002    }
1003
1004    // ── snapshot: ambiguous upload-maybe-succeeded scenarios ────────────
1005
1006    #[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    // ── snapshot: realistic mixed-stream scenarios ──────────────────────
1027
1028    #[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    // ── additional edge cases ───────────────────────────────────────────
1047
1048    #[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        // Each retryable pattern, when given as the *exact* input, classifies correctly
1076        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        // Each permanent pattern, when given as the *exact* input, classifies correctly.
1085        // However, some permanent patterns are substrings of retryable patterns
1086        // (e.g. "invalid" appears in both), so we skip patterns that overlap.
1087        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    // ── real-world cargo publish error messages ─────────────────────────
1122
1123    #[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    // ── ambiguous failure edge cases ────────────────────────────────────
1319
1320    #[test]
1321    fn ambiguous_http_408_request_timeout_no_pattern() {
1322        // 408 doesn't match any defined numeric pattern
1323        let o = classify_publish_failure(
1324            "the remote server responded with status 408 Request Timeout",
1325            "",
1326        );
1327        // "timeout" is in the message, so this is retryable
1328        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    // ── cross-stream classification ─────────────────────────────────────
1374
1375    #[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    // ── snapshot: real-world cargo errors ────────────────────────────────
1424
1425    #[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    // ── error message quality snapshots ──────────────────────────────────
1469
1470    #[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        /// Any input always classifies to one of the three known categories.
1568        #[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        /// The message field is always non-empty for any classification.
1584        #[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        /// Swapping stderr/stdout does not change the class — both are scanned equally.
1591        #[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        /// Prepending/appending noise to a retryable pattern keeps it retryable.
1599        #[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        /// Prepending/appending noise to a permanent pattern (with no retryable
1612        /// pattern present) keeps it permanent.
1613        #[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            // Ensure no retryable substring sneaks in via prefix/suffix
1621            let stderr = format!("{prefix}{pattern}{suffix}");
1622            let outcome = classify_publish_failure(&stderr, "");
1623            // May be retryable if noise accidentally contains a retryable pattern,
1624            // but must never be ambiguous when a permanent pattern is explicitly present.
1625            prop_assert_ne!(outcome.class, CargoFailureClass::Ambiguous);
1626        }
1627
1628        /// When both a retryable and permanent pattern are present in random
1629        /// positions, retryable always wins regardless of ordering.
1630        #[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            // permanent before retryable
1639            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            // retryable before permanent
1643            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        /// Classification output message always corresponds to the class.
1649        #[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}