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}