From f165077b77d953f6c58fd4b194cc067a52203506 Mon Sep 17 00:00:00 2001 From: Richard Schneeman Date: Tue, 23 Jun 2026 11:03:46 -0500 Subject: [PATCH 1/9] Add shared error-accumulation module (maybe_err) Introduces the MaybeErrors/NonEmptyErrors/OkMaybe primitives used to accumulate multiple errors instead of bailing on the first one. --- shared/src/lib.rs | 1 + shared/src/maybe_err.rs | 771 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 772 insertions(+) create mode 100644 shared/src/maybe_err.rs diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 1d68e4c..468ff50 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -36,6 +36,7 @@ where mod base_image; mod download_ruby_version; mod inventory_help; +pub mod maybe_err; pub use base_image::{BaseImage, build_matrix}; pub use download_ruby_version::RubyDownloadVersion; diff --git a/shared/src/maybe_err.rs b/shared/src/maybe_err.rs new file mode 100644 index 0000000..4fd53a3 --- /dev/null +++ b/shared/src/maybe_err.rs @@ -0,0 +1,771 @@ +//! Error-accumulation building blocks. +//! +//! Typically Rust's Result based errors fail fast, but sometimes you want to accumulate +//! as many errors as possible and present them all at once. That's the core philosophy explored in +//! the blog post ["A daft proc-macro trick"](https://schneems.com/2025/03/26/a-daft-procmacro-trick-how-to-emit-partialcode-errors/). +//! +//! An example would be parsing multiple versions from a file. If one version is unparseable, the program +//! might still want to continue execution on the ones that were valid rather than returning early. +//! The structures in this module make such deferred error decision making easier. +//! +//! ## Structs +//! +//! - [`MaybeErrors`] - Zero or more errors (the empty-able accumulator you push into). +//! - [`NonEmptyErrors`] - One or more errors (the non-empty value you return). +//! - [`OkMaybe`] -- A value plus maybe one-or-more errors. +//! +//! ## Example +//! +//! In a function that returns an [`OkMaybe`], it's common to build an error accumulator +//! early and delay evaluation of the return result until the end. Push into the +//! accumulator as you go (or use [`OkMaybe::drain_unwrap`] to drain a sub-result's +//! errors into it while keeping the value), then hand it the value with +//! [`MaybeErrors::ok_maybe`]: +//! +//! ``` +//! use shared::maybe_err::{MaybeErrors, NonEmptyErrors, OkMaybe}; +//! +//! /// Parse every input, keeping the ones that succeed and collecting the rest as errors. +//! fn parse_all(inputs: &[&str]) -> OkMaybe, NonEmptyErrors> { +//! let mut errors = MaybeErrors::new(); +//! let mut values = Vec::new(); +//! +//! for input in inputs { +//! match input.parse::() { +//! Ok(value) => values.push(value), +//! Err(err) => errors.push(format!("{input:?}: {err}")), +//! } +//! } +//! +//! errors.ok_maybe(values) +//! } +//! +//! // All inputs valid: a value and no errors. +//! assert_eq!(parse_all(&["1", "2", "3"]), OkMaybe(vec![1, 2, 3], None)); +//! +//! // Some inputs invalid: the good values plus the accumulated errors. +//! let OkMaybe(values, maybe) = parse_all(&["1", "nope", "3", "also nope"]); +//! assert_eq!(values, vec![1, 3]); +//! assert_eq!(maybe.expect("two errors").len().get(), 2); +//! ``` +//! +//! ## Guidance +//! +//! The error behavior of a function is encoded in its return type: +//! +//! - Errors that block producing a value: `Result>`. This represents either a valid +//! type `T` or 1 or more errors. +//! - Errors that never block producing a value — the caller decides whether to surface them: +//! `OkMaybe>`. In this representation `T` is always +//! available, but there may or may not also be errors. If there are errors, `NonEmptyErrors` represents +//! 1 or more error. +//! - Errors that may or may not block: `Result>, NonEmptyErrors>`. +//! - `Ok(OkMaybe(value, None))` -- no errors. +//! - `Ok(OkMaybe(value, Some(multi_errors)))` -- error(s) that did not block. +//! - `Err(multi_errors)` -- could not produce a value due to error(s). + +use std::fmt::{self, Display}; +use std::num::NonZeroUsize; + +/// One or more errors, guaranteed non-empty by construction. +/// +/// This is the value you *return* when you have at least one error. If you need to represent +/// zero or more errors you can use [`MaybeErrors`] instead. +/// +/// Build the first error with [`NonEmptyErrors::new`], then add more with +/// [`NonEmptyErrors::push`] or [`Extend`]. To collapse a possibly-empty pile of errors +/// into `Option>`, accumulate into a [`MaybeErrors`] instead. +/// +/// ``` +/// use shared::maybe_err::NonEmptyErrors; +/// +/// let mut multi_errors = NonEmptyErrors::new("first".to_string()); +/// multi_errors.push("second".to_string()); +/// +/// assert_eq!(multi_errors.len().get(), 2); +/// let collected: Vec = multi_errors.into_iter().collect(); +/// assert_eq!(collected, vec!["first".to_string(), "second".to_string()]); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NonEmptyErrors { + head: E, + tail: Vec, +} + +impl NonEmptyErrors { + /// Create a non-empty collection from a single error. + /// + /// ``` + /// use shared::maybe_err::NonEmptyErrors; + /// + /// let multi_errors = NonEmptyErrors::new("boom".to_string()); + /// assert_eq!(multi_errors.len().get(), 1); + /// ``` + pub fn new(first: E) -> Self { + NonEmptyErrors { + head: first, + tail: Vec::new(), + } + } + + /// Append another error. + /// + /// ``` + /// use shared::maybe_err::NonEmptyErrors; + /// + /// let mut multi_errors = NonEmptyErrors::new("a".to_string()); + /// multi_errors.push("b".to_string()); + /// assert_eq!(multi_errors.len().get(), 2); + /// ``` + pub fn push(&mut self, err: E) { + self.tail.push(err); + } + + /// The number of errors held, always at least one. + /// + /// Returning [`NonZeroUsize`] surfaces the non-empty guarantee in the type. + /// + /// ``` + /// use shared::maybe_err::NonEmptyErrors; + /// + /// let multi_errors = NonEmptyErrors::new(()); + /// assert_eq!(multi_errors.len().get(), 1); + /// ``` + pub fn len(&self) -> NonZeroUsize { + NonZeroUsize::new(1 + self.tail.len()) + .expect("NonEmptyErrors always holds at least one error") + } + + /// Borrow each error in turn, head first then the rest in push order. + /// + /// ``` + /// use shared::maybe_err::NonEmptyErrors; + /// + /// let mut multi_errors = NonEmptyErrors::new(1); + /// multi_errors.push(2); + /// multi_errors.push(3); + /// assert_eq!(multi_errors.iter().copied().collect::>(), vec![1, 2, 3]); + /// ``` + pub fn iter(&self) -> std::iter::Chain, std::slice::Iter<'_, E>> { + std::iter::once(&self.head).chain(self.tail.iter()) + } +} + +impl IntoIterator for NonEmptyErrors { + type Item = E; + type IntoIter = std::iter::Chain, std::vec::IntoIter>; + + /// Iterate over every error, head first then the rest in push order. + /// + /// ``` + /// use shared::maybe_err::NonEmptyErrors; + /// + /// let mut multi_errors = NonEmptyErrors::new(1); + /// multi_errors.push(2); + /// multi_errors.push(3); + /// assert_eq!(multi_errors.into_iter().collect::>(), vec![1, 2, 3]); + /// ``` + fn into_iter(self) -> Self::IntoIter { + std::iter::once(self.head).chain(self.tail) + } +} + +impl<'a, E> IntoIterator for &'a NonEmptyErrors { + type Item = &'a E; + type IntoIter = std::iter::Chain, std::slice::Iter<'a, E>>; + + /// Borrowing iteration, so `for err in &multi_errors` works. + /// + /// ``` + /// use shared::maybe_err::NonEmptyErrors; + /// + /// let mut multi_errors = NonEmptyErrors::new(1); + /// multi_errors.push(2); + /// let seen: Vec = (&multi_errors).into_iter().copied().collect(); + /// assert_eq!(seen, vec![1, 2]); + /// ``` + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +impl Extend for NonEmptyErrors { + /// Append many errors at once. Also serves as a "combine": extend one + /// `NonEmptyErrors` with the contents of another via its [`IntoIterator`]. + /// + /// ``` + /// use shared::maybe_err::NonEmptyErrors; + /// + /// let mut a = NonEmptyErrors::new(1); + /// let mut b = NonEmptyErrors::new(2); + /// b.push(3); + /// a.extend(b); + /// assert_eq!(a.into_iter().collect::>(), vec![1, 2, 3]); + /// ``` + fn extend>(&mut self, iter: I) { + self.tail.extend(iter); + } +} + +impl From for NonEmptyErrors { + /// Lift a single error into a `NonEmptyErrors`, enabling `.into()` and `?`. + /// + /// ``` + /// use shared::maybe_err::NonEmptyErrors; + /// + /// let errs: NonEmptyErrors = "boom".to_string().into(); + /// assert_eq!(errs.len().get(), 1); + /// ``` + fn from(err: E) -> Self { + NonEmptyErrors::new(err) + } +} + +impl Display for NonEmptyErrors { + /// A single error renders as that error; multiple render as a numbered block. + /// + /// ``` + /// use shared::maybe_err::NonEmptyErrors; + /// + /// let one = NonEmptyErrors::new("only".to_string()); + /// assert_eq!(one.to_string(), "only"); + /// + /// let mut many = NonEmptyErrors::new("first".to_string()); + /// many.push("second".to_string()); + /// assert_eq!(many.to_string(), "2 errors:\n 1. first\n 2. second"); + /// ``` + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let len = self.len(); + if len.get() == 1 { + write!(f, "{}", self.head) + } else { + write!(f, "{} errors:", len)?; + for (index, err) in self.iter().enumerate() { + write!(f, "\n {}. {}", index + 1, err)?; + } + Ok(()) + } + } +} + +impl std::error::Error for NonEmptyErrors { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + None + } +} + +/// Zero or more errors: the empty-able accumulator you push into. +/// +/// It starts empty and, once you are done accumulating, [`MaybeErrors::into_option`] collapses it into +/// `Option>`: `None` means there were no errors, `Some` means one or +/// more. +/// +/// ``` +/// use shared::maybe_err::{NonEmptyErrors, MaybeErrors}; +/// +/// let mut errors = MaybeErrors::new(); +/// assert!(errors.is_empty()); +/// +/// errors.push("nope".to_string()); +/// errors.push("also nope".to_string()); +/// +/// let multi_errors: NonEmptyErrors = errors.into_option().expect("two errors"); +/// assert_eq!(multi_errors.len().get(), 2); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MaybeErrors(Option>); + +impl MaybeErrors { + /// Create an empty accumulator. + /// + /// ``` + /// use shared::maybe_err::MaybeErrors; + /// + /// let errors: MaybeErrors = MaybeErrors::new(); + /// assert!(errors.is_empty()); + /// ``` + pub fn new() -> Self { + MaybeErrors(None) + } + + /// Accumulate one error. The first push creates the underlying + /// [`NonEmptyErrors`]; later pushes append to it. + /// + /// ``` + /// use shared::maybe_err::MaybeErrors; + /// + /// let mut errors = MaybeErrors::new(); + /// errors.push("boom".to_string()); + /// assert!(!errors.is_empty()); + /// ``` + pub fn push(&mut self, err: E) { + match &mut self.0 { + Some(multi_errors) => multi_errors.push(err), + none => *none = Some(NonEmptyErrors::new(err)), + } + } + + /// Whether any error has been accumulated yet. + /// + /// ``` + /// use shared::maybe_err::MaybeErrors; + /// + /// let mut errors = MaybeErrors::new(); + /// assert!(errors.is_empty()); + /// errors.push(()); + /// assert!(!errors.is_empty()); + /// ``` + pub fn is_empty(&self) -> bool { + self.0.is_none() + } + + /// How many errors have been accumulated, `0` when empty. + /// + /// ``` + /// use shared::maybe_err::MaybeErrors; + /// + /// let mut errors = MaybeErrors::new(); + /// assert_eq!(errors.len(), 0); + /// errors.push("a".to_string()); + /// errors.push("b".to_string()); + /// assert_eq!(errors.len(), 2); + /// ``` + pub fn len(&self) -> usize { + self.0 + .as_ref() + .map_or(0, |multi_errors| multi_errors.len().get()) + } + + /// Borrow each accumulated error in turn; yields nothing when empty. + /// + /// ``` + /// use shared::maybe_err::MaybeErrors; + /// + /// let mut errors = MaybeErrors::new(); + /// errors.push("a".to_string()); + /// errors.push("b".to_string()); + /// let seen: Vec<&String> = errors.iter().collect(); + /// assert_eq!(seen, vec![&"a".to_string(), &"b".to_string()]); + /// ``` + pub fn iter(&self) -> impl Iterator { + self.into_iter() + } + + /// Collapse into `Option>`: `None` if empty, otherwise the + /// accumulated non-empty [`NonEmptyErrors`]. + /// + /// ``` + /// use shared::maybe_err::MaybeErrors; + /// + /// let empty: MaybeErrors = MaybeErrors::new(); + /// assert!(empty.into_option().is_none()); + /// + /// let mut errors = MaybeErrors::new(); + /// errors.push("boom".to_string()); + /// assert!(errors.into_option().is_some()); + /// ``` + pub fn into_option(self) -> Option> { + self.0 + } + + /// Pair the accumulated errors with a value, producing an [`OkMaybe`]. + /// + /// This is the bridge from the accumulate phase to the return phase: once + /// you have finished pushing into a `MaybeErrors` and have produced a value, + /// `ok_maybe` collapses the accumulator into the error half of an + /// `OkMaybe>`. An empty accumulator yields + /// `OkMaybe(value, None)`; a non-empty one yields `OkMaybe(value, + /// Some(multi_errors))`. + /// + /// ``` + /// use shared::maybe_err::{MaybeErrors, OkMaybe}; + /// + /// let errors: MaybeErrors = MaybeErrors::new(); + /// assert_eq!(errors.ok_maybe(1), OkMaybe(1, None)); + /// + /// let mut errors = MaybeErrors::new(); + /// errors.push("boom".to_string()); + /// let OkMaybe(value, maybe) = errors.ok_maybe(2); + /// assert_eq!(value, 2); + /// assert_eq!(maybe.expect("one error").len().get(), 1); + /// ``` + pub fn ok_maybe(self, t: T) -> OkMaybe> { + match self.0 { + Some(inner) => OkMaybe(t, Some(inner)), + None => OkMaybe(t, None), + } + } +} + +impl Default for MaybeErrors { + fn default() -> Self { + MaybeErrors::new() + } +} + +impl<'a, E> IntoIterator for &'a MaybeErrors { + type Item = &'a E; + type IntoIter = std::iter::Flatten< + std::option::IntoIter, std::slice::Iter<'a, E>>>, + >; + + /// Borrowing iteration, so `for err in &errors` works. An empty accumulator + /// yields no items. + /// + /// ``` + /// use shared::maybe_err::MaybeErrors; + /// + /// let empty: MaybeErrors = MaybeErrors::new(); + /// assert_eq!((&empty).into_iter().count(), 0); + /// + /// let mut errors = MaybeErrors::new(); + /// errors.push(1); + /// errors.push(2); + /// let seen: Vec = (&errors).into_iter().copied().collect(); + /// assert_eq!(seen, vec![1, 2]); + /// ``` + fn into_iter(self) -> Self::IntoIter { + self.0 + .as_ref() + .map(IntoIterator::into_iter) + .into_iter() + .flatten() + } +} + +impl Extend for MaybeErrors { + /// Accumulate many errors at once. This is what makes a `MaybeErrors` a + /// valid [`OkMaybe::drain_unwrap`] target. + /// + /// ``` + /// use shared::maybe_err::MaybeErrors; + /// + /// let mut errors = MaybeErrors::new(); + /// errors.extend(vec!["a".to_string(), "b".to_string()]); + /// assert_eq!(errors.into_option().expect("two errors").len().get(), 2); + /// ``` + fn extend>(&mut self, iter: I) { + for err in iter { + self.push(err); + } + } +} + +impl FromIterator for MaybeErrors { + /// Collect errors into an accumulator, so `iter.collect::>()` works. + /// + /// ``` + /// use shared::maybe_err::MaybeErrors; + /// + /// let errors: MaybeErrors = + /// vec!["a".to_string(), "b".to_string()].into_iter().collect(); + /// assert_eq!(errors.len(), 2); + /// + /// let empty: MaybeErrors = std::iter::empty().collect(); + /// assert!(empty.is_empty()); + /// ``` + fn from_iter>(iter: I) -> Self { + let mut errors = MaybeErrors::new(); + errors.extend(iter); + errors + } +} + +/// A value paired with maybe an error. +/// +/// A replacement for `Result` when we always want to produce `T` and +/// sometimes emit error(s) alongside it. +/// +/// A function returning `Result` may finish without ever constructing a +/// `T`. In a function returning `OkMaybe`: every return path must +/// produce a `T`. This rules out `?`-style short-circuit returns and +/// reinforces error accumulation at the type-signature level. +/// +/// Variants: +/// +/// - `OkMaybe(value, None)` carries a value with no error +/// - `OkMaybe(value, Some(err))` carries a value AND an error. +/// +/// To return multiple errors we suggest using `OkMaybe>`. +/// +/// ``` +/// use shared::maybe_err::OkMaybe; +/// +/// let ok: OkMaybe = OkMaybe(1, None); +/// assert_eq!(ok.to_result(), Ok(1)); +/// +/// let bad: OkMaybe = OkMaybe(2, Some("nope".to_string())); +/// assert_eq!(bad.to_result(), Err("nope".to_string())); +/// ``` +/// +/// In a function that returns an [`OkMaybe`], it's common to build an error accumulator +/// early and delay evaluation of the return result until the end. Push into the +/// accumulator as you go (or [`OkMaybe::drain_unwrap`] sub-results into it), then +/// hand it the value with [`MaybeErrors::ok_maybe`]: +/// +/// ``` +/// use shared::maybe_err::{MaybeErrors, NonEmptyErrors, OkMaybe}; +/// +/// /// Parse every input, keeping the ones that succeed and collecting the rest as errors. +/// fn parse_all(inputs: &[&str]) -> OkMaybe, NonEmptyErrors> { +/// let mut errors = MaybeErrors::new(); +/// let mut values = Vec::new(); +/// +/// for input in inputs { +/// match input.parse::() { +/// Ok(value) => values.push(value), +/// Err(err) => errors.push(format!("{input:?}: {err}")), +/// } +/// } +/// +/// errors.ok_maybe(values) +/// } +/// +/// // All inputs valid: a value and no errors. +/// assert_eq!(parse_all(&["1", "2", "3"]), OkMaybe(vec![1, 2, 3], None)); +/// +/// // Some inputs invalid: the good values plus the accumulated errors. +/// let OkMaybe(values, maybe) = parse_all(&["1", "nope", "3", "also nope"]); +/// assert_eq!(values, vec![1, 3]); +/// assert_eq!(maybe.expect("two errors").len().get(), 2); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OkMaybe(pub T, pub Option); + +impl OkMaybe { + /// Convert into a `Result`: `Some` error becomes `Err`, `None` becomes + /// `Ok(value)`. Use this when a partial value is not usable. + /// + /// ``` + /// use shared::maybe_err::OkMaybe; + /// + /// assert_eq!(OkMaybe::<_, String>((), None).to_result(), Ok(())); + /// assert_eq!(OkMaybe((), Some("e".to_string())).to_result(), Err("e".to_string())); + /// ``` + pub fn to_result(self) -> Result { + let OkMaybe(value, maybe) = self; + match maybe { + Some(err) => Err(err), + None => Ok(value), + } + } + + /// Drain any error into an accumulator and return the value. + /// + /// The error type must be [`IntoIterator`] (as [`NonEmptyErrors`] is), so its + /// errors can be pushed into any [`Extend`] target such as a + /// [`MaybeErrors`] or a plain `Vec`. This is the key ergonomic for + /// accumulating across many fallible steps in a loop. + /// + /// The target chooses the error type it stores: each drained error is + /// converted with [`Into`], so a sub-result with a concrete error type can + /// be funneled into a wider accumulator such as + /// `MaybeErrors>`. When the types already match, + /// the conversion is the no-op reflexive [`From`]. + /// + /// ``` + /// use shared::maybe_err::{NonEmptyErrors, MaybeErrors, OkMaybe}; + /// + /// let mut errors: MaybeErrors = MaybeErrors::new(); + /// + /// // Same type drains as-is. + /// let value = OkMaybe(10, Some(NonEmptyErrors::new("bad".to_string()))) + /// .drain_unwrap(&mut errors); + /// assert_eq!(value, 10); + /// assert!(!errors.is_empty()); + /// + /// // Source errors are `&str`, accumulator holds `String`: converted via `Into`. + /// let value = OkMaybe(20, Some(NonEmptyErrors::new("worse"))) + /// .drain_unwrap(&mut errors); + /// assert_eq!(value, 20); + /// assert_eq!(errors.len(), 2); + /// ``` + pub fn drain_unwrap(self, push_to: &mut impl Extend) -> T + where + E: IntoIterator, + G: Into, + { + let OkMaybe(value, maybe) = self; + if let Some(err) = maybe { + push_to.extend(err.into_iter().map(Into::into)); + } + value + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn multi_errors_len_starts_at_one() { + let multi_errors = NonEmptyErrors::new("a"); + assert_eq!(multi_errors.len().get(), 1); + } + + #[test] + fn multi_errors_push_grows_len() { + let mut multi_errors = NonEmptyErrors::new("a"); + multi_errors.push("b"); + multi_errors.push("c"); + assert_eq!(multi_errors.len().get(), 3); + } + + #[test] + fn multi_errors_into_iter_is_head_then_tail() { + let mut multi_errors = NonEmptyErrors::new(1); + multi_errors.push(2); + multi_errors.push(3); + assert_eq!(multi_errors.into_iter().collect::>(), vec![1, 2, 3]); + } + + #[test] + fn multi_errors_extend_appends() { + let mut multi_errors = NonEmptyErrors::new(1); + multi_errors.extend(vec![2, 3]); + + let mut other = NonEmptyErrors::new(4); + other.push(5); + multi_errors.extend(other); + + assert_eq!( + multi_errors.into_iter().collect::>(), + vec![1, 2, 3, 4, 5] + ); + } + + #[test] + fn multi_errors_display_single() { + let multi_errors = NonEmptyErrors::new("only".to_string()); + assert_eq!(multi_errors.to_string(), "only"); + } + + #[test] + fn multi_errors_display_multiple() { + let mut multi_errors = NonEmptyErrors::new("first".to_string()); + multi_errors.push("second".to_string()); + assert_eq!( + multi_errors.to_string(), + "2 errors:\n 1. first\n 2. second" + ); + } + + #[test] + fn multi_errors_is_usable_as_boxed_error() { + #[derive(Debug)] + struct MyError(&'static str); + impl Display for MyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } + } + impl std::error::Error for MyError {} + + let multi_errors = NonEmptyErrors::new(MyError("boom")); + let boxed: Box = Box::new(multi_errors); + assert_eq!(boxed.to_string(), "boom"); + assert!(boxed.source().is_none()); + } + + #[test] + fn maybe_errors_empty_into_option_is_none() { + let errors: MaybeErrors = MaybeErrors::new(); + assert!(errors.is_empty()); + assert!(errors.into_option().is_none()); + } + + #[test] + fn maybe_errors_push_then_into_option_is_some() { + let mut errors = MaybeErrors::new(); + errors.push("a".to_string()); + errors.push("b".to_string()); + assert!(!errors.is_empty()); + + let multi_errors = errors.into_option().expect("two errors accumulated"); + assert_eq!(multi_errors.len().get(), 2); + } + + #[test] + fn maybe_errors_default_is_empty() { + let errors: MaybeErrors = MaybeErrors::default(); + assert!(errors.is_empty()); + } + + #[test] + fn maybe_errors_extend_accumulates() { + let mut errors = MaybeErrors::new(); + errors.extend(vec!["a".to_string(), "b".to_string(), "c".to_string()]); + assert_eq!(errors.into_option().expect("three errors").len().get(), 3); + } + + #[test] + fn ok_maybe_to_result_ok_arm() { + let value: OkMaybe = OkMaybe(7, None); + assert_eq!(value.to_result(), Ok(7)); + } + + #[test] + fn ok_maybe_to_result_err_arm() { + let value: OkMaybe = OkMaybe(7, Some("bad".to_string())); + assert_eq!(value.to_result(), Err("bad".to_string())); + } + + #[test] + fn ok_maybe_push_unwrap_drains_into_maybe_errors() { + let mut errors: MaybeErrors = MaybeErrors::new(); + + let first = OkMaybe::>(1, None).drain_unwrap(&mut errors); + let second = + OkMaybe(2, Some(NonEmptyErrors::new("boom".to_string()))).drain_unwrap(&mut errors); + + assert_eq!(first, 1); + assert_eq!(second, 2); + + let multi_errors = errors.into_option().expect("one error accumulated"); + assert_eq!( + multi_errors.into_iter().collect::>(), + vec!["boom".to_string()] + ); + } + + #[test] + fn ok_maybe_push_unwrap_accepts_vec_target() { + let mut errors: Vec = Vec::new(); + let value = OkMaybe("data", Some(NonEmptyErrors::new("oops".to_string()))) + .drain_unwrap(&mut errors); + assert_eq!(value, "data"); + assert_eq!(errors, vec!["oops".to_string()]); + } + + #[test] + fn non_empty_errors_from_single_error() { + let multi_errors: NonEmptyErrors = "boom".to_string().into(); + assert_eq!(multi_errors.len().get(), 1); + assert_eq!( + multi_errors.into_iter().collect::>(), + vec!["boom".to_string()] + ); + } + + #[test] + fn maybe_errors_from_iter_non_empty() { + let errors: MaybeErrors = + vec!["a".to_string(), "b".to_string()].into_iter().collect(); + assert_eq!(errors.len(), 2); + assert_eq!( + errors + .into_option() + .expect("two errors") + .into_iter() + .collect::>(), + vec!["a".to_string(), "b".to_string()] + ); + } + + #[test] + fn maybe_errors_from_iter_empty() { + let empty: MaybeErrors = std::iter::empty().collect(); + assert!(empty.is_empty()); + assert!(empty.into_option().is_none()); + } +} From b17396f4e9e8592c5ece3957a08fae7359c9f8a3 Mon Sep 17 00:00:00 2001 From: Richard Schneeman Date: Tue, 23 Jun 2026 11:07:02 -0500 Subject: [PATCH 2/9] ruby_release_check: fault-tolerant Ruby releases parsing Split fetching from parsing and make YAML parsing tolerant of individual bad entries: typed FlatYamlError/RubyLangEntryError plus parse_flat_yaml and ruby_lang_versions, which collect per-entry failures instead of silently dropping them. --- ruby_executable/src/bin/ruby_release_check.rs | 115 +++++++++++++----- 1 file changed, 85 insertions(+), 30 deletions(-) diff --git a/ruby_executable/src/bin/ruby_release_check.rs b/ruby_executable/src/bin/ruby_release_check.rs index 5ccc965..151c187 100644 --- a/ruby_executable/src/bin/ruby_release_check.rs +++ b/ruby_executable/src/bin/ruby_release_check.rs @@ -1,7 +1,8 @@ use bullet_stream::global::print; use clap::Parser; use fs_err as fs; -use reqwest::Url; +use reqwest::{Client, Url}; +use shared::maybe_err::{MaybeErrors, NonEmptyErrors, OkMaybe}; use shared::{RubyDownloadVersion, S3_BASE_URL, build_matrix, output_ruby_tar_path}; use std::{ error::Error, @@ -10,7 +11,7 @@ use std::{ }; use tokio::task::JoinSet; use tokio::time::sleep; -use yaml_rust2::YamlLoader; +use yaml_rust2::{ScanError, Yaml, YamlLoader}; static RELEASES_URL: std::sync::LazyLock = std::sync::LazyLock::new(|| { Url::parse("https://raw.githubusercontent.com/ruby/www.ruby-lang.org/master/_data/releases.yml") @@ -36,11 +37,25 @@ struct Args { output: PathBuf, } -async fn fetch_releases(url: &Url) -> Result, Box> { +async fn get_body(client: &Client, url: Url) -> Result { + client + .get(url) + .send() + .await? + .error_for_status()? + .text() + .await +} + +async fn fetch_ruby_lang_body(url: &Url) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + let mut attempts = 0; loop { attempts += 1; - match fetch_releases_inner(url).await { + match get_body(&client, url.clone()).await { Ok(val) => return Ok(val), Err(error) => { if attempts >= MAX_RETRY_ATTEMPTS { @@ -52,32 +67,66 @@ async fn fetch_releases(url: &Url) -> Result, Box Result, Box> { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .build()?; - let body = client - .get(url.clone()) - .send() - .await? - .error_for_status()? - .text() - .await?; +#[derive(Debug, thiserror::Error)] +enum FlatYamlError { + #[error("Cannot parse yaml due to error {1} from input:\n{0}")] + NotYaml(String, ScanError), + #[error("Expected first yaml element to be a vec but it was not: {1:?} from input:\n{0}")] + FirstNotVec(String, Vec), +} - let docs = YamlLoader::load_from_str(&body)?; - let releases = docs[0] - .as_vec() - .unwrap_or(&Vec::new()) - .iter() - .filter_map(|entry| { - entry["version"] - .as_str() - .and_then(|v| RubyDownloadVersion::new(v).ok()) +#[derive(Debug, thiserror::Error)] +enum RubyLangEntryError { + #[error(transparent)] + DocError(#[from] FlatYamlError), + + #[error("expected yaml to have a `version` field but it did not: {0:?}")] + MissingVersion(Yaml), + + #[error(transparent)] + CannotParse(#[from] shared::Error), +} + +/// Parse output from +fn parse_flat_yaml(body: String) -> Result, FlatYamlError> { + YamlLoader::load_from_str(&body) + .map_err(|error| FlatYamlError::NotYaml(body.clone(), error)) + .and_then(|docs| { + docs.first() + .and_then(|doc| doc.as_vec()) + .cloned() + .ok_or(FlatYamlError::FirstNotVec(body.clone(), docs.clone())) }) - .collect(); - Ok(releases) +} + +/// Parses output from Ruby Lang into Ruby Versions +/// +/// Fault tolerant parse result of +fn ruby_lang_versions( + body: String, +) -> OkMaybe, NonEmptyErrors> { + let mut errors = MaybeErrors::new(); + let mut releases = Vec::new(); + + match parse_flat_yaml(body) { + Ok(entries) => { + for entry in entries { + match entry["version"] + .as_str() + .ok_or_else(|| RubyLangEntryError::MissingVersion(entry.clone())) + .and_then(|v| { + RubyDownloadVersion::new(v).map_err(RubyLangEntryError::CannotParse) + }) { + Ok(v) => releases.push(v), + Err(error) => errors.push(error), + } + } + } + Err(error) => { + errors.push(error.into()); + } + } + errors.ok_maybe(releases) } fn version_gte(version: &RubyDownloadVersion, minimum: &RubyDownloadVersion) -> bool { @@ -168,8 +217,14 @@ async fn call(args: Args) -> Result<(), Box> { print::bullet(format!("Minimum version: {}", args.minimum_version)); print::h2(format!("Fetching releases from {}", *RELEASES_URL)); - let releases = match fetch_releases(&RELEASES_URL).await { - Ok(r) => r, + let releases = match fetch_ruby_lang_body(&RELEASES_URL).await { + Ok(body) => match ruby_lang_versions(body).to_result() { + Ok(r) => r, + Err(e) => { + print::error(format!("Failed to parse releases: {e}")); + std::process::exit(1); + } + }, Err(e) => { print::error(format!("Failed to fetch releases: {e}")); std::process::exit(1); From 09f52969cd79445e8ed85b91c441aca78968618a Mon Sep 17 00:00:00 2001 From: Richard Schneeman Date: Tue, 23 Jun 2026 11:07:43 -0500 Subject: [PATCH 3/9] ruby_release_check: accumulate errors instead of exiting call() now returns OkMaybe and threads a MaybeErrors accumulator through fetching, per-version S3 checks, and the output write, so one failure no longer aborts the run. main() reports all accumulated errors and exits non-zero only at the end. --- ruby_executable/src/bin/ruby_release_check.rs | 64 +++++++++---------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/ruby_executable/src/bin/ruby_release_check.rs b/ruby_executable/src/bin/ruby_release_check.rs index 151c187..7de09dd 100644 --- a/ruby_executable/src/bin/ruby_release_check.rs +++ b/ruby_executable/src/bin/ruby_release_check.rs @@ -212,22 +212,18 @@ async fn check_version_on_s3( Ok((version, missing)) } -async fn call(args: Args) -> Result<(), Box> { +async fn call(args: Args) -> OkMaybe<(), NonEmptyErrors>> { print::h2("Checking for new Ruby releases"); print::bullet(format!("Minimum version: {}", args.minimum_version)); + let mut errors: MaybeErrors> = MaybeErrors::new(); + print::h2(format!("Fetching releases from {}", *RELEASES_URL)); let releases = match fetch_ruby_lang_body(&RELEASES_URL).await { - Ok(body) => match ruby_lang_versions(body).to_result() { - Ok(r) => r, - Err(e) => { - print::error(format!("Failed to parse releases: {e}")); - std::process::exit(1); - } - }, + Ok(body) => ruby_lang_versions(body).drain_unwrap(&mut errors), Err(e) => { - print::error(format!("Failed to fetch releases: {e}")); - std::process::exit(1); + errors.push(e.into()); + Vec::new() } }; print::bullet(format!("Found {} total releases", releases.len())); @@ -246,46 +242,48 @@ async fn call(args: Args) -> Result<(), Box> { let mut versions_to_build = Vec::new(); while let Some(result) = set.join_next().await { - match result? { - Ok((version, missing)) if missing.is_empty() => { - print::sub_bullet(format!("{version}: all binaries present")); - } - Ok((version, missing)) => { - print::sub_bullet(format!( - "{version}: missing {} combo(s): {}", - missing.len(), - missing.join(", ") - )); - versions_to_build.push(version); - } - Err(e) => { - print::warning(format!("Error checking version: {e}")); + match result.map_err(|e| e.into()) { + Ok(Ok((version, missing))) => { + if missing.is_empty() { + print::sub_bullet(format!("{version}: all binaries present")); + } else { + print::sub_bullet(format!( + "{version}: missing {} combo(s): {}", + missing.len(), + missing.join(", ") + )); + versions_to_build.push(version); + } } + Err(e) | Ok(Err(e)) => errors.push(e), } } - fs::write( - &args.output, - &serde_json::to_string_pretty(&versions_to_build)?, - )?; + if let Err(error) = serde_json::to_string_pretty(&versions_to_build) + .map_err(|e| e.into()) + .and_then(|json| fs::write(&args.output, &json).map_err(|e| Box::new(e) as Box)) + { + errors.push(error) + }; + if versions_to_build.is_empty() { - print::bullet("All checked versions are present on S3"); + print::bullet("No versions to build found"); } else { print::h2("Versions needing builds"); for version in &versions_to_build { print::sub_bullet(format!("{version}")); } } - Ok(()) + errors.ok_maybe(()) } #[tokio::main] async fn main() { let args = Args::parse(); match call(args).await { - Ok(_) => print::bullet("Done"), - Err(e) => { - print::error(format!("Failed {e}")); + OkMaybe(_, None) => print::bullet("Done"), + OkMaybe(_, Some(errors)) => { + print::error(format!("Failed {errors}")); std::process::exit(1); } } From c053e7bad374b7b1a3e4404f5eab3d7bfcb6a688 Mon Sep 17 00:00:00 2001 From: Richard Schneeman Date: Tue, 23 Jun 2026 11:21:28 -0500 Subject: [PATCH 4/9] ruby_release_check: test partial parse results Cover ruby_lang_versions returning valid versions alongside accumulated errors when a single entry fails to parse. --- ruby_executable/src/bin/ruby_release_check.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/ruby_executable/src/bin/ruby_release_check.rs b/ruby_executable/src/bin/ruby_release_check.rs index 7de09dd..212ed43 100644 --- a/ruby_executable/src/bin/ruby_release_check.rs +++ b/ruby_executable/src/bin/ruby_release_check.rs @@ -292,6 +292,32 @@ async fn main() { #[cfg(test)] mod tests { use super::*; + use std::assert_matches; + + #[test] + fn ruby_lang_parsing_returns_partial_result_on_parse_failure() { + let body = indoc::indoc! {" + - version: 4.0.5 + - version: 4.doesnotparse.5 + "} + .to_string(); + + let mut errors = MaybeErrors::::new(); + assert_eq!( + vec![String::from("4.0.5")], + ruby_lang_versions(body) + .drain_unwrap(&mut errors) + .iter() + .map(|v| v.to_string()) + .collect::>() + ); + + assert_eq!(1, errors.len()); + assert_matches!( + errors.into_iter().next().unwrap(), + RubyLangEntryError::CannotParse(_) + ); + } #[test] fn test_version_gte() { From fe2766624c15c75757f012f612a2c77bc91c4c64 Mon Sep 17 00:00:00 2001 From: Richard Schneeman Date: Tue, 23 Jun 2026 11:21:43 -0500 Subject: [PATCH 5/9] ruby_release_check: order assert_eq as (expected, actual) Match the rest of the suite by putting the expected value first in test_retain_releases_gte. --- ruby_executable/src/bin/ruby_release_check.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ruby_executable/src/bin/ruby_release_check.rs b/ruby_executable/src/bin/ruby_release_check.rs index 212ed43..048bb9e 100644 --- a/ruby_executable/src/bin/ruby_release_check.rs +++ b/ruby_executable/src/bin/ruby_release_check.rs @@ -369,6 +369,6 @@ mod tests { let min = RubyDownloadVersion::new("3.2.0").unwrap(); let filtered = retain_releases_gte(&releases, &min); let names: Vec = filtered.iter().map(|v| v.to_string()).collect(); - assert_eq!(names, vec!["3.4.1", "3.3.7", "3.2.0"]); + assert_eq!(vec!["3.4.1", "3.3.7", "3.2.0"], names); } } From 0948056e322d7c610a9fc81a375aeb953b3f67bb Mon Sep 17 00:00:00 2001 From: Richard Schneeman Date: Tue, 23 Jun 2026 11:21:49 -0500 Subject: [PATCH 6/9] check_new_ruby_releases: dispatch builds on partial success Use !cancelled() so the build-dispatch step still runs when the version check partially fails, and tolerate a missing/empty versions.json, so versions that did resolve still get built while the job stays failed. --- .github/workflows/check_new_ruby_releases.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check_new_ruby_releases.yml b/.github/workflows/check_new_ruby_releases.yml index 47b31e0..a50e608 100644 --- a/.github/workflows/check_new_ruby_releases.yml +++ b/.github/workflows/check_new_ruby_releases.yml @@ -35,11 +35,15 @@ jobs: --output versions.json \ 2>"$GITHUB_STEP_SUMMARY" - name: Trigger builds for missing versions - if: ${{ !inputs.dry_run }} + # Generating the versions list can partially succeed: some versions + # resolve while others fail. `!cancelled()` lets this step run even when + # the check step failed, so the versions that did succeed still get + # dispatched. The job's overall status stays failed so we know to look. + if: ${{ !cancelled() && !inputs.dry_run }} env: GH_TOKEN: ${{ github.token }} run: | - VERSIONS=$(jq -r '.[]' versions.json) + VERSIONS=$(jq -r '.[]' versions.json 2>/dev/null || true) if [ -z "$VERSIONS" ]; then echo "No versions to build" exit 0 From 7d8de303bc83c0641041379d64f8d27391be917f Mon Sep 17 00:00:00 2001 From: Richard Schneeman Date: Wed, 24 Jun 2026 13:18:32 -0500 Subject: [PATCH 7/9] Better safe than sorry If this file doesn't exist for some reason, it's fine if the error was in the prior step. An investigating developer will see it failing. Never erroring if there's a missing file could hide a typo, or someone updating one step but not the other. It is safer to error here. Now the error output would look like this: ``` $ jq -r '.[]' versions.json jq: error: Could not open file versions.json: No such file or directory ``` --- .github/workflows/check_new_ruby_releases.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check_new_ruby_releases.yml b/.github/workflows/check_new_ruby_releases.yml index a50e608..d3e801d 100644 --- a/.github/workflows/check_new_ruby_releases.yml +++ b/.github/workflows/check_new_ruby_releases.yml @@ -43,7 +43,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - VERSIONS=$(jq -r '.[]' versions.json 2>/dev/null || true) + VERSIONS=$(jq -r '.[]' versions.json) if [ -z "$VERSIONS" ]; then echo "No versions to build" exit 0 From 8bb7827fc27bdd59a9f91e39b50ded1d090cc22a Mon Sep 17 00:00:00 2001 From: Richard Schneeman Date: Wed, 24 Jun 2026 13:33:15 -0500 Subject: [PATCH 8/9] workflows: tee step output to both summary and live log Switch ruby_release_check to `2>&1 | tee -a "$GITHUB_STEP_SUMMARY"` so its stderr output appears in the live job log instead of only the step summary. Use `tee -a` consistently across the other step-summary pipes to match GitHub's append convention. --- .github/workflows/build_jruby.yml | 2 +- .github/workflows/build_ruby.yml | 2 +- .github/workflows/check_new_ruby_releases.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build_jruby.yml b/.github/workflows/build_jruby.yml index f573de8..a89822a 100644 --- a/.github/workflows/build_jruby.yml +++ b/.github/workflows/build_jruby.yml @@ -86,7 +86,7 @@ jobs: --base-image ${{matrix.base_image}} \ --arch amd64 \ --artifact-dir ./output \ - | tee $GITHUB_STEP_SUMMARY + | tee -a $GITHUB_STEP_SUMMARY - name: Upload JRuby runtime archive to S3 if: steps.build.outputs.status == 'success' run: aws s3 sync ./output "s3://${S3_BUCKET}" ${{ case(inputs.dry_run, '--dryrun', '') }} diff --git a/.github/workflows/build_ruby.yml b/.github/workflows/build_ruby.yml index 938095d..5221d14 100644 --- a/.github/workflows/build_ruby.yml +++ b/.github/workflows/build_ruby.yml @@ -91,7 +91,7 @@ jobs: --base-image ${{matrix.base_image}} \ --arch ${{matrix.arch}} \ --artifact-dir ./output \ - | tee $GITHUB_STEP_SUMMARY + | tee -a $GITHUB_STEP_SUMMARY - name: Upload Ruby runtime archive to S3 if: steps.build.outputs.status == 'success' run: aws s3 sync ./output "s3://${S3_BUCKET}" ${{ case(inputs.dry_run, '--dryrun', '') }} diff --git a/.github/workflows/check_new_ruby_releases.yml b/.github/workflows/check_new_ruby_releases.yml index d3e801d..907e546 100644 --- a/.github/workflows/check_new_ruby_releases.yml +++ b/.github/workflows/check_new_ruby_releases.yml @@ -33,7 +33,7 @@ jobs: cargo run --locked --bin ruby_release_check -- \ --minimum-version 3.2.1 \ --output versions.json \ - 2>"$GITHUB_STEP_SUMMARY" + 2>&1 | tee -a "$GITHUB_STEP_SUMMARY" - name: Trigger builds for missing versions # Generating the versions list can partially succeed: some versions # resolve while others fail. `!cancelled()` lets this step run even when From ae4a9696069e157f3699dfb26c7b619a323cf658 Mon Sep 17 00:00:00 2001 From: Richard Schneeman Date: Wed, 24 Jun 2026 13:46:57 -0500 Subject: [PATCH 9/9] Add tests --- ruby_executable/src/bin/ruby_release_check.rs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/ruby_executable/src/bin/ruby_release_check.rs b/ruby_executable/src/bin/ruby_release_check.rs index 048bb9e..35bbe46 100644 --- a/ruby_executable/src/bin/ruby_release_check.rs +++ b/ruby_executable/src/bin/ruby_release_check.rs @@ -319,6 +319,43 @@ mod tests { ); } + #[test] + fn parse_flat_yaml_errors_on_unparseable_yaml() { + let body = String::from("cannot_parse: 'unterminated_string"); + assert_matches!(parse_flat_yaml(body), Err(FlatYamlError::NotYaml(_, _))); + } + + #[test] + fn parse_flat_yaml_errors_when_top_level_not_vec() { + let body = String::from("version: 4.0.5"); + assert_matches!(parse_flat_yaml(body), Err(FlatYamlError::FirstNotVec(_, _))); + } + + #[test] + fn ruby_lang_versions_errors_on_missing_version_field() { + let body = indoc::indoc! {" + - name: ruby + - version: 4.0.5 + "} + .to_string(); + + let mut errors = MaybeErrors::::new(); + assert_eq!( + vec![String::from("4.0.5")], + ruby_lang_versions(body) + .drain_unwrap(&mut errors) + .iter() + .map(|v| v.to_string()) + .collect::>() + ); + + assert_eq!(1, errors.len()); + assert_matches!( + errors.into_iter().next().unwrap(), + RubyLangEntryError::MissingVersion(_) + ); + } + #[test] fn test_version_gte() { let min = RubyDownloadVersion::new("3.2.0").unwrap();