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}