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}