Skip to main content

bob/
pkgstate.rs

1/*
2 * Copyright (c) 2026 Jonathan Perkin <jonathan@perkin.org.uk>
3 *
4 * Permission to use, copy, modify, and distribute this software for any
5 * purpose with or without fee is hereby granted, provided that the above
6 * copyright notice and this permission notice appear in all copies.
7 *
8 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
9 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
10 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
11 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
12 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
13 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
14 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
15 */
16
17//! Unified package state.
18//!
19//! [`PackageState`] represents every possible state of a package across
20//! the scan and build lifecycle, ordered by discovery phase.
21//!
22//! [`PackageStateKind`] is the plain discriminant enum, used for status
23//! labels, database IDs, and parsing without needing a full instance.
24
25use std::str::FromStr;
26use strum::{EnumCount, IntoEnumIterator};
27
28/// Plain discriminant for [`PackageState`], ordered by lifecycle phase.
29///
30/// Derives provide kebab-case status labels ([`IntoStaticStr`]/[`EnumString`]),
31/// integer conversion ([`FromRepr`] with `#[repr(i32)]`), and iteration
32/// ([`EnumIter`]).
33#[derive(
34    Clone,
35    Copy,
36    Debug,
37    Hash,
38    PartialEq,
39    Eq,
40    strum::EnumCount,
41    strum::EnumIter,
42    strum::EnumProperty,
43    strum::EnumString,
44    strum::FromRepr,
45    strum::AsRefStr,
46    strum::IntoStaticStr,
47)]
48#[strum(serialize_all = "kebab-case")]
49#[repr(i32)]
50pub enum PackageStateKind {
51    #[strum(props(pbulk = "prefailed", desc = "PKG_SKIP_REASON set"))]
52    PreSkipped = 0,
53    #[strum(props(pbulk = "prefailed", desc = "PKG_FAIL_REASON set"))]
54    PreFailed = 1,
55    #[strum(props(pbulk = "prefailed", desc = "Has unresolved dependencies"))]
56    Unresolved = 2,
57    #[strum(props(pbulk = "indirect-prefailed", desc = "Blocked by pre-skipped package"))]
58    IndirectPreSkipped = 3,
59    #[strum(props(pbulk = "indirect-prefailed", desc = "Blocked by pre-failed package"))]
60    IndirectPreFailed = 4,
61    #[strum(props(
62        pbulk = "indirect-prefailed",
63        desc = "Blocked by package with unresolved dependencies"
64    ))]
65    IndirectUnresolved = 5,
66    #[strum(props(pbulk = "open", desc = "Ready to build"))]
67    Pending = 6,
68    #[strum(props(pbulk = "done", desc = "Binary already exists"))]
69    UpToDate = 7,
70    #[strum(props(pbulk = "done", desc = "Built successfully"))]
71    Success = 8,
72    #[strum(props(pbulk = "failed", desc = "Build attempted and failed"))]
73    Failed = 9,
74    #[strum(props(
75        pbulk = "indirect-failed",
76        desc = "Blocked by package that failed to build"
77    ))]
78    IndirectFailed = 10,
79}
80
81impl PackageStateKind {
82    /// One-line description of the state, from the `desc` strum property.
83    pub fn desc(self) -> &'static str {
84        use strum::EnumProperty;
85        self.get_str("desc").expect("desc prop")
86    }
87}
88
89/// Aliases for filtering on multiple [`PackageStateKind`] values at once.
90///
91/// Used by the `bob status -s` filter and by aggregated count output.
92#[derive(
93    Clone,
94    Copy,
95    Debug,
96    PartialEq,
97    Eq,
98    strum::EnumIter,
99    strum::EnumProperty,
100    strum::EnumString,
101    strum::AsRefStr,
102    strum::IntoStaticStr,
103)]
104#[strum(serialize_all = "kebab-case")]
105pub enum PackageStateAlias {
106    #[strum(props(desc = "Any pre-skipped or pre-failed package"))]
107    Skipped,
108    #[strum(props(desc = "Any package blocked by another"))]
109    Blocked,
110    #[strum(props(desc = "Any successful outcome (freshly built or up-to-date)"))]
111    Ok,
112}
113
114impl PackageStateAlias {
115    /// The set of [`PackageStateKind`] values this alias expands to.
116    pub fn expands_to(self) -> &'static [PackageStateKind] {
117        use PackageStateKind::*;
118        match self {
119            Self::Skipped => &[PreSkipped, PreFailed],
120            Self::Blocked => &[
121                IndirectPreSkipped,
122                IndirectPreFailed,
123                IndirectUnresolved,
124                IndirectFailed,
125            ],
126            Self::Ok => &[Success, UpToDate],
127        }
128    }
129
130    /// One-line description of the alias.
131    pub fn desc(self) -> &'static str {
132        use strum::EnumProperty;
133        self.get_str("desc").expect("desc prop")
134    }
135}
136
137/**
138 * Parse a status filter string into one or more [`PackageStateKind`] values.
139 *
140 * Accepts either a canonical kind name (e.g. `pre-failed`) or an alias
141 * (e.g. `blocked`), returning the expanded set of kinds.
142 */
143pub fn parse_status_filter(s: &str) -> Result<Vec<PackageStateKind>, String> {
144    if let Ok(k) = s.parse::<PackageStateKind>() {
145        return Ok(vec![k]);
146    }
147    if let Ok(a) = s.parse::<PackageStateAlias>() {
148        return Ok(a.expands_to().to_vec());
149    }
150    Err(format!("unknown status '{s}'"))
151}
152
153/// State of a package across the scan and build lifecycle.
154///
155/// Variants are ordered by the phase in which they are first assigned:
156///
157/// 1. **Scan** -- `PreSkipped`, `PreFailed`
158/// 2. **Resolution** -- `Unresolved`
159/// 3. **Propagation** -- `IndirectPreSkipped`, `IndirectPreFailed`, `IndirectUnresolved`
160/// 4. **Buildable** -- `Pending`
161/// 5. **Up-to-date check** -- `UpToDate`
162/// 6. **Build** -- `Success`, `Failed`
163/// 7. **Build propagation** -- `IndirectFailed`
164#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
165pub enum PackageState {
166    /// Skipped due to PKG_SKIP_REASON.
167    PreSkipped(String),
168    /// Skipped due to PKG_FAIL_REASON.
169    PreFailed(String),
170    /// Has unresolved dependencies.
171    Unresolved(String),
172    /// Blocked by a pre-skipped dependency.
173    IndirectPreSkipped(String),
174    /// Blocked by a pre-failed dependency.
175    IndirectPreFailed(String),
176    /// Blocked by a dependency with unresolved deps.
177    IndirectUnresolved(String),
178    /// Buildable, awaiting build.
179    Pending,
180    /// Binary package already exists and is current.
181    UpToDate,
182    /// Built successfully.
183    Success,
184    /// Build failed.
185    Failed(String),
186    /// Blocked by a dependency that failed to build.
187    IndirectFailed(String),
188}
189
190impl PackageState {
191    /// The plain discriminant for this state.
192    pub fn kind(&self) -> PackageStateKind {
193        match self {
194            Self::PreSkipped(_) => PackageStateKind::PreSkipped,
195            Self::PreFailed(_) => PackageStateKind::PreFailed,
196            Self::Unresolved(_) => PackageStateKind::Unresolved,
197            Self::IndirectPreSkipped(_) => PackageStateKind::IndirectPreSkipped,
198            Self::IndirectPreFailed(_) => PackageStateKind::IndirectPreFailed,
199            Self::IndirectUnresolved(_) => PackageStateKind::IndirectUnresolved,
200            Self::Pending => PackageStateKind::Pending,
201            Self::UpToDate => PackageStateKind::UpToDate,
202            Self::Success => PackageStateKind::Success,
203            Self::Failed(_) => PackageStateKind::Failed,
204            Self::IndirectFailed(_) => PackageStateKind::IndirectFailed,
205        }
206    }
207
208    /// Construct from a kind and optional detail string.
209    fn from_kind(kind: PackageStateKind, detail: String) -> Self {
210        match kind {
211            PackageStateKind::PreSkipped => Self::PreSkipped(detail),
212            PackageStateKind::PreFailed => Self::PreFailed(detail),
213            PackageStateKind::Unresolved => Self::Unresolved(detail),
214            PackageStateKind::IndirectPreSkipped => Self::IndirectPreSkipped(detail),
215            PackageStateKind::IndirectPreFailed => Self::IndirectPreFailed(detail),
216            PackageStateKind::IndirectUnresolved => Self::IndirectUnresolved(detail),
217            PackageStateKind::Pending => Self::Pending,
218            PackageStateKind::UpToDate => Self::UpToDate,
219            PackageStateKind::Success => Self::Success,
220            PackageStateKind::Failed => Self::Failed(detail),
221            PackageStateKind::IndirectFailed => Self::IndirectFailed(detail),
222        }
223    }
224
225    /// Kebab-case status label.
226    pub fn status(&self) -> &'static str {
227        self.kind().into()
228    }
229
230    /**
231     * pbulk-compatible BUILD_STATUS value.
232     */
233    pub fn pbulk_status(&self) -> &'static str {
234        use strum::EnumProperty;
235        self.kind().get_str("pbulk").expect("pbulk prop")
236    }
237
238    /// Database integer ID, matching variant order.
239    pub fn db_id(&self) -> i32 {
240        self.kind() as i32
241    }
242
243    /// Reconstruct from DB integer + optional detail string.
244    pub fn from_db(id: i32, detail: Option<String>) -> Option<Self> {
245        PackageStateKind::from_repr(id).map(|k| Self::from_kind(k, detail.unwrap_or_default()))
246    }
247
248    /// Parse a status string into a default (empty-detail) instance.
249    pub fn from_status(s: &str) -> Option<Self> {
250        PackageStateKind::from_str(s)
251            .ok()
252            .map(|k| Self::from_kind(k, String::new()))
253    }
254
255    /// The detail/reason string, if any.
256    pub fn detail(&self) -> Option<&str> {
257        match self {
258            Self::Pending | Self::UpToDate | Self::Success => None,
259            Self::PreSkipped(s)
260            | Self::PreFailed(s)
261            | Self::Unresolved(s)
262            | Self::IndirectPreSkipped(s)
263            | Self::IndirectPreFailed(s)
264            | Self::IndirectUnresolved(s)
265            | Self::Failed(s)
266            | Self::IndirectFailed(s) => Some(s),
267        }
268    }
269
270    /// True for skip-phase states (not success, failed, or up-to-date).
271    pub fn is_skip(&self) -> bool {
272        !matches!(self, Self::Success | Self::Failed(_) | Self::UpToDate)
273    }
274
275    /// True for direct skip reasons (PreSkipped/PreFailed/Unresolved).
276    pub fn is_direct_skip(&self) -> bool {
277        matches!(
278            self,
279            Self::PreSkipped(_) | Self::PreFailed(_) | Self::Unresolved(_)
280        )
281    }
282
283    /// Map a skip state to its indirect equivalent, with new detail.
284    pub fn indirect(&self, detail: String) -> Self {
285        match self {
286            Self::PreSkipped(_) | Self::IndirectPreSkipped(_) => Self::IndirectPreSkipped(detail),
287            Self::PreFailed(_) | Self::IndirectPreFailed(_) => Self::IndirectPreFailed(detail),
288            Self::Unresolved(_) | Self::IndirectUnresolved(_) => Self::IndirectUnresolved(detail),
289            Self::IndirectFailed(_) => Self::IndirectFailed(detail),
290            other => other.clone(),
291        }
292    }
293
294    /// Generate SQL VALUES for the outcome_types lookup table.
295    ///
296    /// Excludes Pending since that is not stored in the database.
297    pub fn db_values() -> String {
298        PackageStateKind::iter()
299            .filter(|k| *k != PackageStateKind::Pending)
300            .map(|k| {
301                let s: &'static str = k.into();
302                format!("({}, '{}')", k as i32, s)
303            })
304            .collect::<Vec<_>>()
305            .join(", ")
306    }
307}
308
309/// Counts of packages by [`PackageStateKind`].
310///
311/// Backed by an array indexed by the kind discriminant, so adding a new
312/// variant to [`PackageStateKind`] automatically extends the counts with
313/// no additional code.
314#[derive(Clone, Debug)]
315pub struct PackageCounts([usize; PackageStateKind::COUNT]);
316
317impl Default for PackageCounts {
318    fn default() -> Self {
319        Self([0; PackageStateKind::COUNT])
320    }
321}
322
323impl PackageCounts {
324    /// Increment the counter for this state.
325    pub fn add(&mut self, state: &PackageState) {
326        self.0[state.kind() as usize] += 1;
327    }
328
329    /// Packages with a successful outcome: freshly built (`Success`) plus
330    /// already-current binaries (`UpToDate`).
331    pub fn successful(&self) -> usize {
332        self[PackageStateKind::Success] + self[PackageStateKind::UpToDate]
333    }
334
335    /// Packages that failed to build.
336    pub fn failed(&self) -> usize {
337        self[PackageStateKind::Failed]
338    }
339
340    /// Packages whose existing binary was up to date.
341    pub fn up_to_date(&self) -> usize {
342        self[PackageStateKind::UpToDate]
343    }
344
345    /// Sum of counts for all kinds in an alias expansion.
346    pub fn count_alias(&self, alias: PackageStateAlias) -> usize {
347        alias.expands_to().iter().map(|k| self[*k]).sum()
348    }
349
350    /// Packages not attempted: skip/fail reasons and indirect
351    /// dependents of failed or masked packages.  Does not include
352    /// Unresolved (those appear in scan failures).
353    pub fn masked(&self) -> usize {
354        use PackageStateKind::*;
355        self[PreSkipped]
356            + self[PreFailed]
357            + self[IndirectPreSkipped]
358            + self[IndirectPreFailed]
359            + self[IndirectUnresolved]
360            + self[IndirectFailed]
361    }
362
363    /// Total packages: successful + failed + masked.
364    /// Excludes scan failures and unresolved (listed separately).
365    pub fn total(&self) -> usize {
366        self.successful() + self.failed() + self.masked()
367    }
368}
369
370impl std::ops::Index<PackageStateKind> for PackageCounts {
371    type Output = usize;
372    fn index(&self, kind: PackageStateKind) -> &usize {
373        &self.0[kind as usize]
374    }
375}
376
377impl std::fmt::Display for PackageState {
378    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
379        match self.detail() {
380            Some(d) if !d.is_empty() => write!(f, "{}", d),
381            _ => write!(f, "{}", self.status()),
382        }
383    }
384}