Skip to main content

anodizer_core/
preflight.rs

1//! Pre-flight publisher-state types shared between `core` and `stage-publish`.
2//!
3//! The preflight check runs before any stage in the release pipeline to detect
4//! one-way-door publishers (crates.io, Chocolatey, WinGet, AUR) that already
5//! have the target version submitted or approved. Discovering this before the
6//! build prevents an entire wasted release cycle.
7//!
8//! # State machine
9//!
10//! ```text
11//! Clean      → safe to publish
12//! Published  → idempotent skip (not a blocker)
13//! InModeration { reason } → blocker (version submitted, moderation queue)
14//! PRPending  → blocker (PR already open for this version)
15//! Unknown { reason } → warn-and-allow unless --strict-preflight
16//! ```
17
18use std::fmt;
19
20// ---------------------------------------------------------------------------
21// PublisherState
22// ---------------------------------------------------------------------------
23
24/// The state of a single publisher for the target version.
25#[derive(Debug, Clone, PartialEq)]
26pub enum PublisherState {
27    /// Version not present. Safe to publish.
28    Clean,
29    /// Version already published / approved. Idempotent skip (not a blocker).
30    Published,
31    /// Submitted but pending review / moderation. Blocker. `reason` is a
32    /// short human-readable explanation (e.g. "package in moderation queue").
33    InModeration { reason: String },
34    /// PR already open against the upstream registry. Blocker.
35    PRPending(String),
36    /// Couldn't determine state. Warn-and-allow unless `--strict-preflight`.
37    /// `reason` carries a short error description for diagnostics.
38    Unknown { reason: String },
39}
40
41impl PublisherState {
42    /// Returns `true` when this state blocks the release.
43    ///
44    /// `InModeration` and `PRPending` are hard blockers.
45    /// `Unknown` only blocks when `strict` is `true`.
46    pub fn is_blocker(&self, strict: bool) -> bool {
47        match self {
48            PublisherState::InModeration { .. } | PublisherState::PRPending(_) => true,
49            PublisherState::Unknown { .. } => strict,
50            _ => false,
51        }
52    }
53
54    /// A short human-readable label for table output.
55    pub fn label(&self) -> &'static str {
56        match self {
57            PublisherState::Clean => "clean",
58            PublisherState::Published => "published",
59            PublisherState::InModeration { .. } => "in-moderation",
60            PublisherState::PRPending(_) => "pr-pending",
61            PublisherState::Unknown { .. } => "unknown",
62        }
63    }
64}
65
66impl fmt::Display for PublisherState {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            PublisherState::Clean => write!(f, "clean"),
70            PublisherState::Published => write!(f, "already published (idempotent skip)"),
71            PublisherState::InModeration { reason } => {
72                write!(f, "in moderation queue: {} — BLOCKER", reason)
73            }
74            PublisherState::PRPending(url) => write!(f, "PR already open: {} — BLOCKER", url),
75            PublisherState::Unknown { reason } => write!(f, "unknown ({})", reason),
76        }
77    }
78}
79
80// ---------------------------------------------------------------------------
81// PreflightEntry
82// ---------------------------------------------------------------------------
83
84/// One publisher's result in the preflight report.
85#[derive(Debug, Clone)]
86pub struct PreflightEntry {
87    /// Short publisher name for display (e.g. "cargo", "chocolatey").
88    pub publisher: String,
89    /// Crate / package name being checked.
90    pub package: String,
91    /// Version that was queried.
92    pub version: String,
93    /// Result of the state query.
94    pub state: PublisherState,
95}
96
97// ---------------------------------------------------------------------------
98// PreflightReport
99// ---------------------------------------------------------------------------
100
101/// Aggregated results for all one-way-door publishers.
102///
103/// `entries` carries one row per checked publisher (cargo / chocolatey /
104/// winget / aur). `warnings` and `blockers` are free-form, publisher-agnostic
105/// messages produced by the release-resilience preflight extension: rollback
106/// token scope checks and per-publisher `Publisher::preflight()` hook
107/// results. The two channels are kept separate from `entries` so that
108/// the existing one-way-door consumers (state-machine queries like
109/// `has_blockers` / `clean_count`) stay focused on publisher state, while the
110/// CLI's operator-facing output can still surface every warning and blocker
111/// the preflight pipeline produced.
112#[derive(Debug, Default)]
113pub struct PreflightReport {
114    pub entries: Vec<PreflightEntry>,
115    /// Non-blocking concerns surfaced during preflight (missing rollback
116    /// scope in default mode, `Publisher::preflight()` returning Warning).
117    pub warnings: Vec<String>,
118    /// Hard blockers surfaced during preflight (missing rollback scope in
119    /// `--strict` mode, `Publisher::preflight()` returning Blocker).
120    pub blockers: Vec<String>,
121}
122
123impl PreflightReport {
124    pub fn new() -> Self {
125        Self::default()
126    }
127
128    pub fn push(&mut self, entry: PreflightEntry) {
129        self.entries.push(entry);
130    }
131
132    /// Entries whose state is `Clean`.
133    pub fn clean_count(&self) -> usize {
134        self.entries
135            .iter()
136            .filter(|e| e.state == PublisherState::Clean)
137            .count()
138    }
139
140    /// Whether any entry is a blocker given the strict flag.
141    pub fn has_blockers(&self, strict: bool) -> bool {
142        self.entries.iter().any(|e| e.state.is_blocker(strict))
143    }
144
145    /// Entries that are blockers.
146    pub fn blockers(&self, strict: bool) -> Vec<&PreflightEntry> {
147        self.entries
148            .iter()
149            .filter(|e| e.state.is_blocker(strict))
150            .collect()
151    }
152}
153
154impl fmt::Display for PreflightReport {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        writeln!(f, "Pre-flight publisher check:")?;
157        for entry in &self.entries {
158            writeln!(
159                f,
160                "  [{:>14}]  {} {}@{}",
161                entry.state.label(),
162                entry.publisher,
163                entry.package,
164                entry.version
165            )?;
166            // Print extra detail for states that carry context.
167            match &entry.state {
168                PublisherState::PRPending(url) => {
169                    writeln!(f, "               PR: {}", url)?;
170                }
171                PublisherState::Unknown { reason } | PublisherState::InModeration { reason } => {
172                    writeln!(f, "               reason: {}", reason)?;
173                }
174                _ => {}
175            }
176        }
177        // Surface free-form warnings/blockers from the resilience extension
178        // (rollback-scope checks + `Publisher::preflight()` results) so they
179        // flow through the same Display channel the CLI prints. Suppressed
180        // when both are empty to preserve the existing one-line-per-entry
181        // cadence for clean reports.
182        for w in &self.warnings {
183            writeln!(f, "  [       warning]  {}", w)?;
184        }
185        for b in &self.blockers {
186            writeln!(f, "  [       blocker]  {}", b)?;
187        }
188        Ok(())
189    }
190}
191
192// ---------------------------------------------------------------------------
193// Tests
194// ---------------------------------------------------------------------------
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    fn entry(publisher: &str, state: PublisherState) -> PreflightEntry {
201        PreflightEntry {
202            publisher: publisher.to_string(),
203            package: "mypkg".to_string(),
204            version: "1.2.3".to_string(),
205            state,
206        }
207    }
208
209    #[test]
210    fn report_aggregation_four_publishers() {
211        // Mock 4 publishers, one in each non-trivial state, assert categorisation.
212        let mut report = PreflightReport::new();
213        report.push(entry("cargo", PublisherState::Clean));
214        report.push(entry(
215            "chocolatey",
216            PublisherState::InModeration {
217                reason: "package in moderation queue".into(),
218            },
219        ));
220        report.push(entry(
221            "winget",
222            PublisherState::PRPending("https://github.com/microsoft/winget-pkgs/pull/123".into()),
223        ));
224        report.push(entry(
225            "aur",
226            PublisherState::Unknown {
227                reason: "AUR RPC returned 503".into(),
228            },
229        ));
230
231        // clean_count
232        assert_eq!(report.clean_count(), 1);
233
234        // non-strict: Unknown is not a blocker
235        assert!(report.has_blockers(false));
236        let blockers = report.blockers(false);
237        assert_eq!(blockers.len(), 2);
238        assert!(blockers.iter().any(|e| e.publisher == "chocolatey"));
239        assert!(blockers.iter().any(|e| e.publisher == "winget"));
240
241        // strict: Unknown also blocks
242        assert!(report.has_blockers(true));
243        let strict_blockers = report.blockers(true);
244        assert_eq!(strict_blockers.len(), 3);
245    }
246
247    #[test]
248    fn report_all_clean_no_blockers() {
249        let mut report = PreflightReport::new();
250        report.push(entry("cargo", PublisherState::Clean));
251        report.push(entry("aur", PublisherState::Clean));
252
253        assert!(!report.has_blockers(false));
254        assert!(!report.has_blockers(true));
255        assert_eq!(report.clean_count(), 2);
256    }
257
258    #[test]
259    fn published_is_not_blocker() {
260        let mut report = PreflightReport::new();
261        report.push(entry("cargo", PublisherState::Published));
262
263        assert!(!report.has_blockers(false));
264        assert!(!report.has_blockers(true));
265    }
266
267    #[test]
268    fn unknown_only_blocks_when_strict() {
269        let mut report = PreflightReport::new();
270        report.push(entry(
271            "aur",
272            PublisherState::Unknown {
273                reason: "timeout".into(),
274            },
275        ));
276
277        assert!(!report.has_blockers(false));
278        assert!(report.has_blockers(true));
279    }
280
281    #[test]
282    fn display_includes_blocker_label() {
283        let mut report = PreflightReport::new();
284        report.push(entry(
285            "chocolatey",
286            PublisherState::InModeration {
287                reason: "package in moderation queue".into(),
288            },
289        ));
290
291        let s = report.to_string();
292        assert!(s.contains("in-moderation"), "display: {s}");
293        assert!(s.contains("chocolatey"), "display: {s}");
294    }
295}