Skip to main content

anodizer_core/
publisher.rs

1//! Publisher trait + preflight result type.
2//!
3//! Defines the polymorphic interface that every publisher (cargo, homebrew,
4//! scoop, chocolatey, nix, AUR, krew, winget, snapcraft, blob, release, ...)
5//! implements. Lives in `anodizer-core` rather than `stage-publish` so that
6//! `stage-blob`, `stage-release`, and `stage-snapcraft` can implement
7//! `Publisher` without taking a circular dependency on `stage-publish`.
8
9use crate::context::Context;
10use crate::{PublishEvidence, PublisherGroup};
11
12/// Outcome of a publisher's pre-flight self-check.
13///
14/// Each variant signals a different release-pipeline reaction:
15///
16/// * `Pass` — no concern detected; publishing may proceed.
17/// * `Warning(msg)` — surface the message to the operator (and review log)
18///   but do not block the publish. Use for soft signals like "remote
19///   already has a tag at this version but contents match".
20/// * `Blocker(msg)` — abort before the publish stage runs. Use for hard
21///   prerequisites the publisher knows it cannot satisfy at runtime, e.g.
22///   "homebrew tap repo not reachable", "winget-pkgs fork not configured".
23///
24/// Named `Pass` (not `Clean`) to avoid nominal collision with
25/// [`crate::preflight::PublisherState::Clean`], which describes the
26/// already-published state of a publisher rather than a self-check result.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum PreflightCheck {
29    /// Publisher's pre-flight checks completed with no concerns.
30    Pass,
31    /// Publisher detected a non-blocking concern; surface it but continue.
32    Warning(String),
33    /// Publisher detected a blocking concern; abort before the publish stage.
34    Blocker(String),
35}
36
37impl PreflightCheck {
38    /// Fold two pre-flight outcomes into the most severe, escalating
39    /// `Blocker` > `Warning` > `Pass`. Within a severity the first-seen
40    /// message (`self`'s) wins, so a left-fold over many targets yields a
41    /// stable, deterministic line rather than whichever target iterated last.
42    pub fn merge(self, next: Self) -> Self {
43        use PreflightCheck::{Blocker, Pass, Warning};
44        match (self, next) {
45            (Blocker(m), _) => Blocker(m),
46            (_, Blocker(m)) => Blocker(m),
47            (Warning(m), _) => Warning(m),
48            (_, Warning(m)) => Warning(m),
49            (Pass, Pass) => Pass,
50        }
51    }
52}
53
54/// A publisher's upstream state for the exact version+content this run would
55/// publish — the answer to "am I already done?", owned by ONE trait method
56/// ([`Publisher::reconcile`]) instead of being re-derived ad hoc by per-`run()`
57/// self-skips, the global preflight gate, and the burn-guard probes.
58///
59/// Consumed by two surfaces with one contract:
60/// * the publish dispatch loop — `Complete` skips `run()`
61///   (`SkipReason::AlreadyPublished`), `Diverged` records a `Failed` result
62///   (for a required publisher that closes the Submitter gate and the run
63///   exits nonzero — bump the version; for an optional one it is a tolerated
64///   failure), and `Absent`/`Unknown` fall through to `run()`. A divergence
65///   never short-circuits the loop: dispatch continues to the remaining
66///   publishers unless `--fail-fast` was passed;
67/// * `anodizer preflight` — prints the per-publisher reconcile table.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum ReconcileState {
70    /// Not present upstream. `run()` must publish.
71    Absent,
72    /// This exact version+content is already published/submitted for this
73    /// target (cargo version live with matching cksum, chocolatey submission
74    /// in moderation with matching hash, winget open PR for this exact
75    /// version, …). `run()` must be skipped — recording it is a no-op
76    /// success/pending, and a re-run of a partially-failed release converges
77    /// instead of wedging on the publisher's own prior success.
78    ///
79    /// `Complete` requires a POSITIVE upstream match (version present with
80    /// matching content, or an open submission for this exact version) —
81    /// never an inference from absence, which would silently under-publish.
82    Complete {
83        /// Operator-facing context ("in moderation since …", "open PR #123").
84        note: String,
85    },
86    /// The version exists upstream but the LOCAL artifact bytes differ from
87    /// what was published. The one true blocker at any time: the operator must bump
88    /// the version. Honors [`Publisher::required`] at the dispatch arm — a
89    /// required publisher's divergence fails the run via the Submitter gate
90    /// (the result is recorded as `Failed` and the run exits nonzero) while
91    /// dispatch continues unless `--fail-fast`; an optional publisher's
92    /// divergence records a gate-neutral tolerated failure.
93    Diverged {
94        /// What diverged (hash mismatch details, comparison evidence).
95        detail: String,
96    },
97    /// Could not determine (network failure, unparseable feed). NEVER blocks
98    /// a release: `run()` proceeds and the registry's own conflict handling
99    /// is the backstop — fail-safe toward publishing, not skipping.
100    Unknown {
101        /// Why the probe was inconclusive.
102        reason: String,
103    },
104}
105
106/// Publisher contract — one implementer per upstream registry / channel.
107///
108/// Required methods describe the publisher's identity, behavior, and how
109/// it participates in [`PublisherGroup`]-based scheduling:
110///
111/// * [`Publisher::name`] — stable identifier used in logs, evidence, and
112///   review findings (e.g. `"cargo"`, `"homebrew"`, `"winget"`).
113/// * [`Publisher::run`] — perform the actual publish and emit a
114///   [`PublishEvidence`] record describing what was sent upstream.
115/// * [`Publisher::group`] — which [`PublisherGroup`] this publisher belongs
116///   to; used by the publish stage to order and parallelize work.
117/// * [`Publisher::required`] — whether a failure in this publisher should
118///   fail the overall release.
119///
120/// Default-implemented hooks describe optional behavior:
121///
122/// * [`Publisher::rollback`] — best-effort undo of a successful publish.
123///   The default is a no-op so publishers that target irreversible
124///   registries (most of them) do not need to override.
125/// * [`Publisher::preflight`] — fast self-check executed before any
126///   publisher in the pipeline runs. Defaults to [`PreflightCheck::Pass`].
127/// * [`Publisher::rollback_scope_needed`] — declare an opt-in OAuth /
128///   token scope that rollback would require (e.g. `"delete_repo"` for
129///   GitHub-fork-based publishers). Defaults to `None`. Surfaced by
130///   the CLI when explaining why a rollback path is unavailable.
131///
132/// Implementations must be `Send + Sync` so the publish stage can fan out
133/// across publisher groups in parallel. Wrap non-`Send` clients (Rc-based,
134/// thread-local channels) behind an `Arc<Mutex<_>>` or move them inside
135/// `run()`'s scope rather than holding them on `self`.
136pub trait Publisher: Send + Sync {
137    /// Stable, lowercase identifier for this publisher (e.g. `"cargo"`).
138    fn name(&self) -> &str;
139
140    /// Execute the publish and emit evidence describing what was sent.
141    fn run(&self, ctx: &mut Context) -> anyhow::Result<PublishEvidence>;
142
143    /// Scheduling group — controls ordering and parallelism in the publish stage.
144    fn group(&self) -> PublisherGroup;
145
146    /// Whether a failure here should fail the overall release.
147    fn required(&self) -> bool;
148
149    /// Best-effort rollback of a successful publish, given its evidence.
150    ///
151    /// Default is a no-op: most upstream registries are append-only or
152    /// require human moderation to revoke, so the publisher opts in by
153    /// overriding only when it actually has a rollback path.
154    fn rollback(&self, _ctx: &mut Context, _evidence: &PublishEvidence) -> anyhow::Result<()> {
155        Ok(())
156    }
157
158    /// Fast self-check executed before any publisher runs.
159    ///
160    /// Default returns [`PreflightCheck::Pass`]. Override to surface
161    /// publisher-specific blockers (missing tap, missing fork, network
162    /// unreachable) or warnings (duplicate-but-matching upload).
163    fn preflight(&self, _ctx: &Context) -> anyhow::Result<PreflightCheck> {
164        Ok(PreflightCheck::Pass)
165    }
166
167    /// Cheap, read-only "am I already done for this exact version+content?"
168    ///
169    /// The single owner of the reconcile question (see [`ReconcileState`]).
170    /// Default probes nothing (`Absent`) so a publisher with no idempotency
171    /// concern — every same-version-overwrite Manager, the idempotent Assets
172    /// re-uploads — needs no override. Publishers that self-skip (cargo's
173    /// already-published index check, chocolatey's in-moderation hash match,
174    /// winget's open-PR probe) implement this with that exact logic so the
175    /// forward dispatch and the `anodizer preflight` report consume ONE
176    /// probe instead of three drifting ones.
177    ///
178    /// Must be read-only toward upstream (probes, never writes): dispatch
179    /// calls it before `run()` on every non-skipped publisher of a real
180    /// (non-dry-run) release. `&mut Context` matches `run()` — probes may
181    /// need template-var crate scoping or local staging (cargo's local
182    /// `.crate` checksum), but never a registry write.
183    fn reconcile(&self, _ctx: &mut Context) -> anyhow::Result<ReconcileState> {
184        Ok(ReconcileState::Absent)
185    }
186
187    /// Opt-in OAuth / token scope rollback would require, if any.
188    ///
189    /// Default is `None`. Used by `anodizer tag rollback` to explain why
190    /// it cannot withdraw a given publisher without elevating the release
191    /// token's permissions.
192    fn rollback_scope_needed(&self) -> Option<&'static str> {
193        None
194    }
195
196    /// The rollback credential this context cannot reach, if any.
197    ///
198    /// `Some(label)` when [`Publisher::rollback_scope_needed`] names a scope
199    /// and the env var its label opens with is unset (or empty) in `ctx`'s
200    /// env source. `None` when no scope is needed, when the credential is
201    /// present, or when [`Publisher::retain_on_rollback`] is set — a
202    /// publisher whose work is never unwound needs no credential to unwind
203    /// it. Both the preflight and `anodizer tag rollback` ask this one
204    /// question, so a publisher whose credential is issued at publish time
205    /// (Trusted Publishing) or resolved per entry overrides it here and the
206    /// two paths agree.
207    fn missing_rollback_scope(&self, ctx: &Context) -> Option<&'static str> {
208        if self.retain_on_rollback() {
209            return None;
210        }
211        self.rollback_scope_needed()
212            .filter(|label| !rollback_scope_label_available(label, ctx.env_source()))
213    }
214
215    /// Environment requirements this publisher derives from the resolved
216    /// config: CLI tools it spawns, env vars/secrets it reads, endpoints
217    /// it talks to, key material it loads.
218    ///
219    /// Consumed by the config-aware preflight (`anodizer preflight` and the
220    /// in-process phase at the head of `anodizer release`). Declared next
221    /// to each publisher's implementation — derived from the same config
222    /// fields `run()` reads — so the preflight cannot drift from the
223    /// publish path. Default is empty for publishers with no external
224    /// prerequisites beyond what their stage already declares.
225    fn requirements(&self, _ctx: &Context) -> Vec<crate::env_preflight::EnvRequirement> {
226        Vec::new()
227    }
228
229    /// Environment requirements whose absence DEGRADES this publisher's run
230    /// rather than failing it: optional validators (`ruby -c`, `bash -n`,
231    /// `nix-instantiate --parse`) that warn+skip when missing, or a preferred
232    /// transport with a full fallback (`gh` vs the GitHub REST API).
233    ///
234    /// Collected alongside [`Publisher::requirements`] but surfaced as
235    /// ADVISORY: preflight warns instead of blocking, and `anodizer tools`
236    /// reports them as recommended so an auto-provisioned runner installs
237    /// them and gets the stronger validation/transport. Hard needs (the run
238    /// path errors without the tool) belong in `requirements()` instead.
239    /// Default is empty.
240    fn advisory_requirements(&self, _ctx: &Context) -> Vec<crate::env_preflight::EnvRequirement> {
241        Vec::new()
242    }
243
244    /// True when this publisher was registered (a config block exists) but
245    /// every configured entry evaluates skip-inactive under the CURRENT
246    /// config/env — `skip:`/`skip_upload:` truthy or `if:` falsy on all of
247    /// them. Checked at the dispatch chokepoint BEFORE [`Publisher::run`]
248    /// runs, so a `run()` that unconditionally returns `Ok(evidence)` even
249    /// with zero active entries is never recorded as `Succeeded`.
250    ///
251    /// Default `false`: publishers with no skip/enable knob need no
252    /// override. Publishers that do have one implement this by reusing the
253    /// exact active-entries predicate their [`Publisher::requirements`]
254    /// already applies — never a second, independently-derived skip check —
255    /// so the two cannot drift.
256    fn config_fully_inactive(&self, _ctx: &Context) -> bool {
257        false
258    }
259
260    /// Whether this publisher opts out of nightly runs (the
261    /// `customization/publish/nightlies.md` skip-list).
262    ///
263    /// Each `Publisher` must declare its nightly behavior explicitly — there
264    /// is no default — so adding a new publisher forces a deliberate decision.
265    /// Return `true` for publishers that push to long-lived registries where a
266    /// nightly clobber is either disruptive (homebrew taps, scoop buckets,
267    /// AUR, krew-index, nix overlays) or outright forbidden by registry policy.
268    fn skips_on_nightly(&self) -> bool;
269
270    /// When `true`, this publisher's successful work is left in place even
271    /// when a rollback is triggered — it is never passed to `rollback()`.
272    /// Default `false` (rollback runs if the publisher implements it).
273    fn retain_on_rollback(&self) -> bool {
274        false
275    }
276}
277
278/// Whether the env var a rollback-scope label opens with is set to a
279/// non-empty value.
280///
281/// A label reads `"CARGO_REGISTRY_TOKEN yank"`: the first whitespace-separated
282/// token names the variable, and the rest describes the scope for the
283/// operator (it cannot be verified against the token without an API
284/// round-trip). `GITHUB_TOKEN` resolves through the canonical GitHub-token
285/// chain, so its `ANODIZER_GITHUB_TOKEN` alias counts.
286pub fn rollback_scope_label_available<E: crate::EnvSource + ?Sized>(label: &str, env: &E) -> bool {
287    let env_var = label.split_once(' ').map(|(v, _)| v).unwrap_or(label);
288    if env_var == "GITHUB_TOKEN" {
289        return crate::git::resolve_github_token_with_env(None, &|key| env.var(key)).is_some();
290    }
291    env.var(env_var).map(|v| !v.is_empty()).unwrap_or(false)
292}
293
294/// The exact warn message a publisher emits when `rollback()` is invoked
295/// with no evidence to act on (empty `artifact_paths`, no `primary_ref`).
296/// Each publisher's empty-evidence branch calls this helper; tests can
297/// assert on the returned string without having to intercept stderr
298/// (`eprintln!` cannot be portably captured from the same process).
299///
300/// A run that publishes nothing — dry-run or snapshot — reaches the same empty
301/// branch, and there the manual-verification wording sends the operator to
302/// inspect remote state the run never created. The distinction is made here,
303/// on the evidence, so every publisher says the right thing rather than one
304/// of them carrying a special case.
305///
306/// Lives in `anodizer_core` because the rollback shape is shared across
307/// publishers spread between `stage-publish` and `stage-blob` (and any
308/// future stage crate that implements `Publisher`).
309pub fn rollback_empty_warning_msg(ctx: &Context, publisher: &str, target_label: &str) -> String {
310    if ctx.is_dry_run() || ctx.is_snapshot() {
311        return format!(
312            "no {target_label} recorded in {publisher} evidence — this run published nothing to \
313             {publisher}, so there is no state to undo"
314        );
315    }
316    format!(
317        "no {target_label} recorded in {publisher} evidence — verify {publisher} state manually"
318    )
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    struct MinimalPublisher;
326    impl Publisher for MinimalPublisher {
327        fn name(&self) -> &str {
328            "minimal"
329        }
330        fn run(&self, _ctx: &mut Context) -> anyhow::Result<PublishEvidence> {
331            Ok(PublishEvidence::new("minimal"))
332        }
333        fn group(&self) -> PublisherGroup {
334            PublisherGroup::Manager
335        }
336        fn required(&self) -> bool {
337            false
338        }
339        fn skips_on_nightly(&self) -> bool {
340            false
341        }
342    }
343
344    #[test]
345    fn rollback_default_is_noop_ok() {
346        let p = MinimalPublisher;
347        let mut ctx = Context::test_fixture();
348        let evidence = PublishEvidence::new("minimal");
349        assert!(p.rollback(&mut ctx, &evidence).is_ok());
350    }
351
352    #[test]
353    fn preflight_default_is_pass() {
354        let p = MinimalPublisher;
355        let ctx = Context::test_fixture();
356        assert!(matches!(p.preflight(&ctx).unwrap(), PreflightCheck::Pass));
357    }
358
359    #[test]
360    fn rollback_scope_needed_default_is_none() {
361        let p = MinimalPublisher;
362        assert!(p.rollback_scope_needed().is_none());
363    }
364
365    #[test]
366    fn pending_outcome_round_trips_through_context() {
367        // The slot is single-shot: write once, take once, then empty.
368        // Without single-shot semantics, a chocolatey moderation skip
369        // would bleed into the next publisher's row at dispatch time.
370        let mut ctx = Context::test_fixture();
371        assert!(ctx.take_pending_outcome().is_none());
372
373        ctx.record_publisher_outcome(crate::PublisherOutcome::PendingModeration);
374        assert!(matches!(
375            ctx.take_pending_outcome(),
376            Some(crate::PublisherOutcome::PendingModeration)
377        ));
378        assert!(
379            ctx.take_pending_outcome().is_none(),
380            "slot must be empty after take"
381        );
382
383        // Overwrite semantics: last writer wins (no implicit accumulation).
384        ctx.record_publisher_outcome(crate::PublisherOutcome::PendingModeration);
385        ctx.record_publisher_outcome(crate::PublisherOutcome::PendingValidation);
386        assert!(matches!(
387            ctx.take_pending_outcome(),
388            Some(crate::PublisherOutcome::PendingValidation)
389        ));
390    }
391
392    #[test]
393    fn rollback_empty_warning_msg_interpolates_all_three_slots() {
394        let ctx = Context::test_fixture();
395        let msg = rollback_empty_warning_msg(&ctx, "homebrew", "tap commit");
396        assert_eq!(
397            msg,
398            "no tap commit recorded in homebrew evidence — verify homebrew state manually"
399        );
400    }
401
402    #[test]
403    fn rollback_empty_warning_msg_distinct_per_publisher() {
404        let ctx = Context::test_fixture();
405        let a = rollback_empty_warning_msg(&ctx, "cargo", "crate");
406        let b = rollback_empty_warning_msg(&ctx, "aur", "commit");
407        assert_ne!(a, b);
408        assert!(a.contains("cargo") && a.contains("crate"));
409        assert!(b.contains("aur") && b.contains("commit"));
410    }
411
412    /// A run that published nothing has no remote state to inspect, so the
413    /// empty-evidence line must not send the operator to verify any. Decided
414    /// on the evidence, once, rather than in the publisher that happened to
415    /// surface it.
416    #[test]
417    fn a_run_that_published_nothing_is_not_asked_to_verify_remote_state() {
418        for mode in ["dry_run", "snapshot"] {
419            let mut ctx = Context::test_fixture();
420            match mode {
421                "dry_run" => ctx.options.dry_run = true,
422                _ => ctx.options.snapshot = true,
423            }
424            let msg = rollback_empty_warning_msg(&ctx, "winget", "submitted PR targets");
425            assert!(
426                msg.starts_with("no submitted PR targets recorded in winget evidence"),
427                "{mode}: {msg}"
428            );
429            assert!(
430                !msg.contains("verify") && !msg.contains("manually"),
431                "{mode}: a run that published nothing has no state to verify: {msg}"
432            );
433            assert!(msg.contains("no state to undo"), "{mode}: {msg}");
434        }
435    }
436
437    #[test]
438    fn a_retained_publisher_has_no_missing_rollback_scope() {
439        struct Retained;
440        impl Publisher for Retained {
441            fn name(&self) -> &str {
442                "retained"
443            }
444            fn group(&self) -> PublisherGroup {
445                PublisherGroup::Manager
446            }
447            fn required(&self) -> bool {
448                false
449            }
450            fn rollback_scope_needed(&self) -> Option<&'static str> {
451                Some("RETAINED_TOKEN delete")
452            }
453            fn retain_on_rollback(&self) -> bool {
454                true
455            }
456            fn skips_on_nightly(&self) -> bool {
457                false
458            }
459            fn run(&self, _ctx: &mut Context) -> anyhow::Result<PublishEvidence> {
460                Ok(PublishEvidence::new("retained"))
461            }
462        }
463        let mut ctx = Context::test_fixture();
464        ctx.set_env_source(crate::MapEnvSource::new());
465        assert_eq!(Retained.missing_rollback_scope(&ctx), None);
466        assert!(!rollback_scope_label_available(
467            "RETAINED_TOKEN delete",
468            &crate::MapEnvSource::new()
469        ));
470        assert!(rollback_scope_label_available(
471            "RETAINED_TOKEN delete",
472            &crate::MapEnvSource::new().with("RETAINED_TOKEN", "x")
473        ));
474        assert!(rollback_scope_label_available(
475            "GITHUB_TOKEN contents:write",
476            &crate::MapEnvSource::new().with("ANODIZER_GITHUB_TOKEN", "x")
477        ));
478    }
479
480    #[test]
481    fn retain_on_rollback_defaults_false() {
482        assert!(!MinimalPublisher.retain_on_rollback());
483    }
484
485    #[test]
486    fn requirements_default_is_empty() {
487        let p = MinimalPublisher;
488        let ctx = Context::test_fixture();
489        assert!(p.requirements(&ctx).is_empty());
490    }
491
492    #[test]
493    fn config_fully_inactive_defaults_false() {
494        let p = MinimalPublisher;
495        let ctx = Context::test_fixture();
496        assert!(!p.config_fully_inactive(&ctx));
497    }
498
499    #[test]
500    fn preflight_check_variants_compare_by_value() {
501        assert_eq!(PreflightCheck::Pass, PreflightCheck::Pass);
502        assert_eq!(
503            PreflightCheck::Warning("dup".into()),
504            PreflightCheck::Warning("dup".into())
505        );
506        // same variant, different payload, must not be equal
507        assert_ne!(
508            PreflightCheck::Blocker("a".into()),
509            PreflightCheck::Blocker("b".into())
510        );
511        // different variants with same string must not be equal
512        assert_ne!(
513            PreflightCheck::Warning("x".into()),
514            PreflightCheck::Blocker("x".into())
515        );
516    }
517
518    #[test]
519    fn minimal_publisher_carries_its_declared_identity() {
520        let p = MinimalPublisher;
521        assert_eq!(p.name(), "minimal");
522        assert_eq!(p.group(), PublisherGroup::Manager);
523        assert!(!p.required());
524        assert!(!p.skips_on_nightly());
525    }
526
527    /// A publisher that overrides every default-implemented hook, so the
528    /// trait dispatch is proven to reach the override (not silently shadowed
529    /// by the default body).
530    struct OverridingPublisher;
531    impl Publisher for OverridingPublisher {
532        fn name(&self) -> &str {
533            "overriding"
534        }
535        fn run(&self, _ctx: &mut Context) -> anyhow::Result<PublishEvidence> {
536            Ok(PublishEvidence::new("overriding"))
537        }
538        fn group(&self) -> PublisherGroup {
539            PublisherGroup::Assets
540        }
541        fn required(&self) -> bool {
542            true
543        }
544        fn skips_on_nightly(&self) -> bool {
545            true
546        }
547        fn preflight(&self, _ctx: &Context) -> anyhow::Result<PreflightCheck> {
548            Ok(PreflightCheck::Blocker("fork missing".into()))
549        }
550        fn rollback_scope_needed(&self) -> Option<&'static str> {
551            Some("delete_repo")
552        }
553        fn retain_on_rollback(&self) -> bool {
554            true
555        }
556    }
557
558    #[test]
559    fn override_publisher_preflight_returns_blocker() {
560        let p = OverridingPublisher;
561        let ctx = Context::test_fixture();
562        assert_eq!(
563            p.preflight(&ctx).unwrap(),
564            PreflightCheck::Blocker("fork missing".into())
565        );
566    }
567
568    #[test]
569    fn override_publisher_exposes_rollback_scope_and_flags() {
570        let p = OverridingPublisher;
571        assert_eq!(p.rollback_scope_needed(), Some("delete_repo"));
572        assert!(p.retain_on_rollback());
573        assert!(p.required());
574        assert!(p.skips_on_nightly());
575        assert_eq!(p.group(), PublisherGroup::Assets);
576    }
577
578    #[test]
579    fn preflight_check_clone_preserves_payload() {
580        let warn = PreflightCheck::Warning("dup upload".into());
581        assert_eq!(warn.clone(), warn);
582        let blocker = PreflightCheck::Blocker("no tap".into());
583        let cloned = blocker.clone();
584        assert_eq!(cloned, PreflightCheck::Blocker("no tap".into()));
585        // Clone must not collapse a Warning into the same value as a Blocker.
586        assert_ne!(warn, blocker);
587    }
588
589    #[test]
590    fn merge_escalates_to_worst_severity_keeping_first_message() {
591        use PreflightCheck::{Blocker, Pass, Warning};
592
593        // Blocker dominates regardless of position, keeping its own message.
594        assert_eq!(
595            Blocker("b".into()).merge(Warning("w".into())),
596            Blocker("b".into())
597        );
598        assert_eq!(
599            Warning("w".into()).merge(Blocker("b".into())),
600            Blocker("b".into())
601        );
602        assert_eq!(Pass.merge(Blocker("b".into())), Blocker("b".into()));
603        assert_eq!(Blocker("b".into()).merge(Pass), Blocker("b".into()));
604
605        // Warning dominates Pass.
606        assert_eq!(Warning("w".into()).merge(Pass), Warning("w".into()));
607        assert_eq!(Pass.merge(Warning("w".into())), Warning("w".into()));
608
609        // Pass + Pass stays Pass.
610        assert_eq!(Pass.merge(Pass), Pass);
611
612        // Within a severity the first-seen (left) message wins.
613        assert_eq!(
614            Blocker("first".into()).merge(Blocker("second".into())),
615            Blocker("first".into())
616        );
617        assert_eq!(
618            Warning("first".into()).merge(Warning("second".into())),
619            Warning("first".into())
620        );
621    }
622}