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 } → reported, never blocking (version submitted, moderation queue)
14//! PRPending  → reported, never blocking (PR already open for this version)
15//! Unknown { reason } → warn-and-allow unless --strict
16//! ```
17
18use std::fmt;
19
20use crate::log::StageLogger;
21
22// ---------------------------------------------------------------------------
23// PublisherState
24// ---------------------------------------------------------------------------
25
26/// The state of a single publisher for the target version.
27#[derive(Debug, Clone, PartialEq)]
28pub enum PublisherState {
29    /// Version not present. Safe to publish.
30    Clean,
31    /// Version already published / approved. Idempotent skip (not a blocker).
32    Published,
33    /// Submitted but pending review / moderation. Reported, never blocking:
34    /// the publisher's own `reconcile()` decides whether to skip or dispatch.
35    /// `reason` is a short human-readable explanation.
36    InModeration { reason: String },
37    /// PR already open against the upstream registry. Reported, never
38    /// blocking — an open PR is exactly what a converged re-run expects.
39    PRPending(String),
40    /// Couldn't determine state. `reason` carries a short error description
41    /// for diagnostics.
42    Unknown { reason: String },
43}
44
45impl PublisherState {
46    /// A short human-readable label for table output.
47    pub fn label(&self) -> &'static str {
48        match self {
49            PublisherState::Clean => "clean",
50            PublisherState::Published => "published",
51            PublisherState::InModeration { .. } => "in-moderation",
52            PublisherState::PRPending(_) => "pr-pending",
53            PublisherState::Unknown { .. } => "unknown",
54        }
55    }
56
57    /// Which `StageLogger` register a report row for this state renders
58    /// under. `InModeration` and `PRPending` are reported states, not
59    /// failures — the publisher's own `reconcile()` decides whether to skip
60    /// or dispatch, so only `Published` earns the `✓` success marker.
61    pub fn row_kind(&self) -> RowKind {
62        match self {
63            PublisherState::Published => RowKind::Ok,
64            PublisherState::Clean
65            | PublisherState::InModeration { .. }
66            | PublisherState::PRPending(_)
67            | PublisherState::Unknown { .. } => RowKind::Info,
68        }
69    }
70
71    /// Trailing summary text for a report row, e.g. `"in-moderation —
72    /// package in moderation queue"`.
73    pub fn row_summary(&self) -> String {
74        match self {
75            PublisherState::Clean => "clean".to_string(),
76            PublisherState::Published => "published".to_string(),
77            PublisherState::InModeration { reason } => format!("in-moderation — {reason}"),
78            PublisherState::PRPending(url) => format!("pr-pending — {url}"),
79            PublisherState::Unknown { reason } => format!("unknown — {reason}"),
80        }
81    }
82}
83
84/// Which `StageLogger` register a preflight report row renders under.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum RowKind {
87    /// Green `✓` — `log.success`.
88    Ok,
89    /// Cyan `•` — `log.status`.
90    Info,
91}
92
93impl fmt::Display for PublisherState {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        match self {
96            PublisherState::Clean => write!(f, "clean"),
97            PublisherState::Published => write!(f, "already published (idempotent skip)"),
98            PublisherState::InModeration { reason } => {
99                write!(f, "in moderation queue: {}", reason)
100            }
101            PublisherState::PRPending(url) => write!(f, "PR already open: {}", url),
102            PublisherState::Unknown { reason } => write!(f, "unknown ({})", reason),
103        }
104    }
105}
106
107// ---------------------------------------------------------------------------
108// PreflightEntry
109// ---------------------------------------------------------------------------
110
111/// One publisher's result in the preflight report.
112#[derive(Debug, Clone)]
113pub struct PreflightEntry {
114    /// Short publisher name for display (e.g. "cargo", "chocolatey").
115    pub publisher: String,
116    /// Crate / package name being checked.
117    pub package: String,
118    /// Version that was queried.
119    pub version: String,
120    /// Result of the state query.
121    pub state: PublisherState,
122}
123
124// ---------------------------------------------------------------------------
125// PreflightReport
126// ---------------------------------------------------------------------------
127
128/// Aggregated results for all one-way-door publishers.
129///
130/// `entries` carries one row per checked publisher (cargo / chocolatey /
131/// winget / aur). `warnings` and `blockers` are free-form, publisher-agnostic
132/// messages produced by the release-resilience preflight extension: rollback
133/// token scope checks and per-publisher `Publisher::preflight()` hook
134/// results. The two channels are kept separate from `entries` so that
135/// the report-only publisher-state channel (queried via `clean_count`)
136/// stays focused on publisher state, while the
137/// CLI's operator-facing output can still surface every warning and blocker
138/// the preflight pipeline produced.
139#[derive(Debug, Default)]
140pub struct PreflightReport {
141    pub entries: Vec<PreflightEntry>,
142    /// Non-blocking concerns surfaced during preflight (missing rollback
143    /// scope in default mode, `Publisher::preflight()` returning Warning).
144    pub warnings: Vec<String>,
145    /// Hard blockers surfaced during preflight (missing rollback scope in
146    /// `--strict` mode, `Publisher::preflight()` returning Blocker).
147    pub blockers: Vec<String>,
148}
149
150impl PreflightReport {
151    pub fn new() -> Self {
152        Self::default()
153    }
154
155    pub fn push(&mut self, entry: PreflightEntry) {
156        self.entries.push(entry);
157    }
158
159    /// Entries whose state is `Clean`.
160    pub fn clean_count(&self) -> usize {
161        self.entries
162            .iter()
163            .filter(|e| e.state == PublisherState::Clean)
164            .count()
165    }
166
167    /// One `(kind, text)` row per entry, ready for `StageLogger` dispatch.
168    ///
169    /// Each `text` is the subject `{publisher} {package}@{version}`
170    /// left-aligned and padded to the width of the widest subject present,
171    /// followed by two spaces and [`PublisherState::row_summary`], so the
172    /// summaries line up in a column across every row.
173    pub fn entry_rows(&self) -> Vec<(RowKind, String)> {
174        let subjects: Vec<String> = self
175            .entries
176            .iter()
177            .map(|e| format!("{} {}@{}", e.publisher, e.package, e.version))
178            .collect();
179        let width = subjects.iter().map(String::len).max().unwrap_or(0);
180        self.entries
181            .iter()
182            .zip(subjects)
183            .map(|(entry, subject)| {
184                (
185                    entry.state.row_kind(),
186                    format!("{subject:width$}  {}", entry.state.row_summary()),
187                )
188            })
189            .collect()
190    }
191
192    /// Render this report through `log`, one `StageLogger` call per row.
193    ///
194    /// Publisher entries route through [`PublisherState::row_kind`]
195    /// (`✓`/`•`); free-form `warnings` and `blockers` from the
196    /// release-resilience preflight extension route through the logger's
197    /// own `Warning` / `Error` labels.
198    pub fn emit(&self, log: &StageLogger) {
199        log.status("Pre-flight publisher check");
200        for (kind, text) in self.entry_rows() {
201            match kind {
202                RowKind::Ok => log.success(&text),
203                RowKind::Info => log.status(&text),
204            }
205        }
206        for w in &self.warnings {
207            log.warn(w);
208        }
209        for b in &self.blockers {
210            log.error(b);
211        }
212    }
213}
214
215// ---------------------------------------------------------------------------
216// Tests
217// ---------------------------------------------------------------------------
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    fn entry(publisher: &str, state: PublisherState) -> PreflightEntry {
224        PreflightEntry {
225            publisher: publisher.to_string(),
226            package: "mypkg".to_string(),
227            version: "1.2.3".to_string(),
228            state,
229        }
230    }
231
232    /// Preflight is report-only: every publisher state, including the two
233    /// that used to hard-abort a release, must survive into the report the
234    /// operator reads and none of them may gate. Convergence moved the
235    /// decision to each publisher's own `reconcile()`, and a pending PR or
236    /// moderation entry is exactly what a re-run of an in-flight release is
237    /// supposed to find.
238    #[test]
239    fn report_aggregation_four_publishers() {
240        let mut report = PreflightReport::new();
241        report.push(entry("cargo", PublisherState::Clean));
242        report.push(entry(
243            "chocolatey",
244            PublisherState::InModeration {
245                reason: "package in moderation queue".into(),
246            },
247        ));
248        report.push(entry(
249            "winget",
250            PublisherState::PRPending("https://github.com/microsoft/winget-pkgs/pull/123".into()),
251        ));
252        report.push(entry(
253            "aur",
254            PublisherState::Unknown {
255                reason: "AUR RPC returned 503".into(),
256            },
257        ));
258
259        assert_eq!(report.clean_count(), 1);
260        assert_eq!(report.entries.len(), 4);
261        let rows = report.entry_rows();
262        for label in ["clean", "in-moderation", "pr-pending", "unknown"] {
263            assert!(
264                rows.iter().any(|(_, text)| text.contains(label)),
265                "every state must reach the operator's report: {label} missing from {rows:?}"
266            );
267        }
268    }
269
270    /// State→`RowKind` mapping: only `Published` earns the `Ok` (`✓`)
271    /// marker. `InModeration` and `PRPending` are reported states, not
272    /// blockers — a converged re-run expects to find them.
273    #[test]
274    fn row_kind_matches_state() {
275        assert_eq!(PublisherState::Published.row_kind(), RowKind::Ok);
276        assert_eq!(PublisherState::Clean.row_kind(), RowKind::Info);
277        assert_eq!(
278            PublisherState::InModeration {
279                reason: "queue".into()
280            }
281            .row_kind(),
282            RowKind::Info
283        );
284        assert_eq!(
285            PublisherState::PRPending("https://example.com/pr/1".into()).row_kind(),
286            RowKind::Info
287        );
288        assert_eq!(
289            PublisherState::Unknown {
290                reason: "503".into()
291            }
292            .row_kind(),
293            RowKind::Info
294        );
295    }
296
297    /// Subjects of different lengths must still align: every row's summary
298    /// starts at the same column.
299    #[test]
300    fn entry_rows_align_summaries_to_widest_subject() {
301        let mut report = PreflightReport::new();
302        report.push(PreflightEntry {
303            publisher: "cargo".to_string(),
304            package: "cfgd".to_string(),
305            version: "0.6.0".to_string(),
306            state: PublisherState::Clean,
307        });
308        report.push(PreflightEntry {
309            publisher: "chocolatey".to_string(),
310            package: "cfgd-core".to_string(),
311            version: "0.6.0".to_string(),
312            state: PublisherState::Published,
313        });
314
315        let rows = report.entry_rows();
316        assert_eq!(rows.len(), 2);
317        let starts: Vec<usize> = rows
318            .iter()
319            .zip(&report.entries)
320            .map(|((_, text), entry)| {
321                text.find(&entry.state.row_summary())
322                    .expect("summary substring must be found in its own row text")
323            })
324            .collect();
325        assert_eq!(
326            starts[0], starts[1],
327            "summaries must start at the same column across rows of different subject length: {rows:?}"
328        );
329    }
330
331    #[test]
332    fn entry_rows_empty_report_returns_empty_vec() {
333        let report = PreflightReport::new();
334        assert!(report.entry_rows().is_empty());
335    }
336
337    /// `InModeration`/`PRPending` are non-blocking reported states; the
338    /// `Display` message must not assert a `BLOCKER` label that no longer
339    /// applies.
340    #[test]
341    fn display_does_not_label_reported_states_as_blocker() {
342        let in_moderation = PublisherState::InModeration {
343            reason: "package in moderation queue".into(),
344        }
345        .to_string();
346        let pr_pending = PublisherState::PRPending("https://example.com/pr/1".into()).to_string();
347
348        assert!(!in_moderation.contains("BLOCKER"), "{in_moderation}");
349        assert!(!pr_pending.contains("BLOCKER"), "{pr_pending}");
350    }
351
352    #[test]
353    fn report_all_clean_counts_every_entry() {
354        let mut report = PreflightReport::new();
355        report.push(entry("cargo", PublisherState::Clean));
356        report.push(entry("aur", PublisherState::Clean));
357
358        assert_eq!(report.clean_count(), 2);
359    }
360
361    #[test]
362    fn published_is_not_counted_clean() {
363        let mut report = PreflightReport::new();
364        report.push(entry("cargo", PublisherState::Published));
365
366        assert_eq!(
367            report.clean_count(),
368            0,
369            "`clean` means nothing is upstream yet; an already-published \
370             version is a different state and must not inflate the count"
371        );
372    }
373}