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