use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum StatusCategory {
Queued,
Active,
Stalled,
Resolved,
Cancelled,
#[default]
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseStatusCategoryError {
LegacyToken {
token: String,
replacement_hint: &'static str,
},
}
impl fmt::Display for ParseStatusCategoryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ParseStatusCategoryError::LegacyToken {
token,
replacement_hint,
} => write!(
f,
"category {token:?} is no longer accepted; \
replace with {replacement_hint}"
),
}
}
}
impl std::error::Error for ParseStatusCategoryError {}
impl StatusCategory {
pub fn parse(s: &str) -> Result<Self, ParseStatusCategoryError> {
match s {
"queued" => Ok(StatusCategory::Queued),
"active" => Ok(StatusCategory::Active),
"stalled" => Ok(StatusCategory::Stalled),
"resolved" => Ok(StatusCategory::Resolved),
"cancelled" => Ok(StatusCategory::Cancelled),
"ongoing" => Err(ParseStatusCategoryError::LegacyToken {
token: s.to_owned(),
replacement_hint: "\"active\"",
}),
"pending" => Err(ParseStatusCategoryError::LegacyToken {
token: s.to_owned(),
replacement_hint: "\"queued\" (pre-work) or \"stalled\" (post-start wait)",
}),
_ => Ok(StatusCategory::Unknown),
}
}
pub fn as_str(self) -> &'static str {
match self {
StatusCategory::Queued => "queued",
StatusCategory::Active => "active",
StatusCategory::Stalled => "stalled",
StatusCategory::Resolved => "resolved",
StatusCategory::Cancelled => "cancelled",
StatusCategory::Unknown => "unknown",
}
}
pub fn is_known(self) -> bool {
!matches!(self, StatusCategory::Unknown)
}
pub fn is_terminal(self) -> bool {
matches!(self, StatusCategory::Resolved | StatusCategory::Cancelled)
}
fn rank(self) -> u8 {
match self {
StatusCategory::Unknown => 0,
StatusCategory::Cancelled => 1,
StatusCategory::Resolved => 2,
StatusCategory::Queued => 3,
StatusCategory::Stalled => 4,
StatusCategory::Active => 5,
}
}
pub fn join(a: Self, b: Self) -> Self {
if a.rank() >= b.rank() {
a
} else {
b
}
}
pub fn fold_categories(it: impl IntoIterator<Item = Self>) -> Option<Self> {
it.into_iter().reduce(Self::join)
}
}
impl fmt::Display for StatusCategory {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[cfg(test)]
pub mod strategy {
use super::StatusCategory;
use proptest::prelude::*;
pub fn status_category() -> impl Strategy<Value = StatusCategory> {
prop_oneof![
Just(StatusCategory::Queued),
Just(StatusCategory::Active),
Just(StatusCategory::Stalled),
Just(StatusCategory::Resolved),
Just(StatusCategory::Cancelled),
Just(StatusCategory::Unknown),
]
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn parse_known_tokens() {
assert_eq!(StatusCategory::parse("queued"), Ok(StatusCategory::Queued));
assert_eq!(StatusCategory::parse("active"), Ok(StatusCategory::Active));
assert_eq!(
StatusCategory::parse("stalled"),
Ok(StatusCategory::Stalled)
);
assert_eq!(
StatusCategory::parse("resolved"),
Ok(StatusCategory::Resolved)
);
assert_eq!(
StatusCategory::parse("cancelled"),
Ok(StatusCategory::Cancelled)
);
}
#[test]
fn parse_empty_or_garbage_yields_unknown() {
assert_eq!(StatusCategory::parse(""), Ok(StatusCategory::Unknown));
assert_eq!(StatusCategory::parse("done"), Ok(StatusCategory::Unknown));
assert_eq!(StatusCategory::parse("Queued"), Ok(StatusCategory::Unknown));
}
#[test]
fn parse_rejects_legacy_ongoing() {
let err = StatusCategory::parse("ongoing").unwrap_err();
let ParseStatusCategoryError::LegacyToken {
token,
replacement_hint,
} = err;
assert_eq!(token, "ongoing");
assert_eq!(replacement_hint, "\"active\"");
}
#[test]
fn parse_rejects_legacy_pending() {
let err = StatusCategory::parse("pending").unwrap_err();
let ParseStatusCategoryError::LegacyToken {
token,
replacement_hint,
} = err;
assert_eq!(token, "pending");
assert!(replacement_hint.contains("queued"));
assert!(replacement_hint.contains("stalled"));
}
#[test]
fn legacy_error_display_names_the_replacement() {
let err = StatusCategory::parse("ongoing").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("\"ongoing\""), "got: {msg}");
assert!(msg.contains("\"active\""), "got: {msg}");
}
#[test]
fn as_str_canonical_tokens() {
assert_eq!(StatusCategory::Queued.as_str(), "queued");
assert_eq!(StatusCategory::Active.as_str(), "active");
assert_eq!(StatusCategory::Stalled.as_str(), "stalled");
assert_eq!(StatusCategory::Resolved.as_str(), "resolved");
assert_eq!(StatusCategory::Cancelled.as_str(), "cancelled");
assert_eq!(StatusCategory::Unknown.as_str(), "unknown");
}
#[test]
fn is_known() {
for cat in [
StatusCategory::Queued,
StatusCategory::Active,
StatusCategory::Stalled,
StatusCategory::Resolved,
StatusCategory::Cancelled,
] {
assert!(cat.is_known(), "{cat} should be known");
}
assert!(!StatusCategory::Unknown.is_known());
}
#[test]
fn is_terminal() {
assert!(StatusCategory::Resolved.is_terminal());
assert!(StatusCategory::Cancelled.is_terminal());
assert!(!StatusCategory::Queued.is_terminal());
assert!(!StatusCategory::Active.is_terminal());
assert!(!StatusCategory::Stalled.is_terminal());
assert!(!StatusCategory::Unknown.is_terminal());
}
#[test]
fn default_is_unknown() {
assert_eq!(StatusCategory::default(), StatusCategory::Unknown);
}
proptest! {
#[test]
fn known_categories_round_trip_through_parse(cat in strategy::status_category()) {
if cat.is_known() {
prop_assert_eq!(StatusCategory::parse(cat.as_str()), Ok(cat));
}
}
}
use StatusCategory::{Active as A, Cancelled as C, Queued as Q, Resolved as R, Stalled as S};
#[test]
fn join_table_matches_ddr() {
#[rustfmt::skip]
let table = [
(Q, Q, Q), (Q, A, A), (Q, S, S), (Q, R, Q), (Q, C, Q),
(A, Q, A), (A, A, A), (A, S, A), (A, R, A), (A, C, A),
(S, Q, S), (S, A, A), (S, S, S), (S, R, S), (S, C, S),
(R, Q, Q), (R, A, A), (R, S, S), (R, R, R), (R, C, R),
(C, Q, Q), (C, A, A), (C, S, S), (C, R, R), (C, C, C),
];
for (a, b, expected) in table {
assert_eq!(
StatusCategory::join(a, b),
expected,
"join({a}, {b}) expected {expected}"
);
}
}
#[test]
fn join_unknown_is_bottom() {
for x in [Q, A, S, R, C, StatusCategory::Unknown] {
assert_eq!(StatusCategory::join(StatusCategory::Unknown, x), x);
assert_eq!(StatusCategory::join(x, StatusCategory::Unknown), x);
}
}
#[test]
fn fold_empty_iterator_is_none() {
assert_eq!(StatusCategory::fold_categories(std::iter::empty()), None);
}
#[test]
fn fold_single_child_is_its_category() {
assert_eq!(StatusCategory::fold_categories([Q]), Some(Q));
assert_eq!(StatusCategory::fold_categories([R]), Some(R));
}
#[test]
fn fold_worked_examples_from_ddr() {
let cases: &[(&[StatusCategory], StatusCategory)] = &[
(&[Q, Q, Q, Q], Q),
(&[S, S, S], S),
(&[R, R, R, R, R], R),
(&[C, C], C),
(&[A, A, A], A),
(&[Q, Q, S], S),
(&[Q, Q, Q, A], A),
(&[Q, S, A], A),
(&[S, S, R], S),
(&[Q, R], Q),
(&[Q, C], Q),
(&[R, R, R, R, R, R, R, R, R, S], S),
(&[R, R, R, R, R, R, R, R, R, C], R),
(&[R, C, C], R),
(&[C, C, C, C], C),
(&[Q, A, S, R, C], A),
];
for (children, expected) in cases {
let got = StatusCategory::fold_categories(children.iter().copied());
assert_eq!(
got,
Some(*expected),
"fold({children:?}) expected {expected}, got {got:?}"
);
}
}
proptest! {
#[test]
fn join_is_idempotent(a in strategy::status_category()) {
prop_assert_eq!(StatusCategory::join(a, a), a);
}
#[test]
fn join_is_commutative(
a in strategy::status_category(),
b in strategy::status_category(),
) {
prop_assert_eq!(StatusCategory::join(a, b), StatusCategory::join(b, a));
}
#[test]
fn join_is_associative(
a in strategy::status_category(),
b in strategy::status_category(),
c in strategy::status_category(),
) {
let left = StatusCategory::join(StatusCategory::join(a, b), c);
let right = StatusCategory::join(a, StatusCategory::join(b, c));
prop_assert_eq!(left, right);
}
#[test]
fn fold_is_order_independent(
a in strategy::status_category(),
b in strategy::status_category(),
c in strategy::status_category(),
) {
let abc = StatusCategory::fold_categories([a, b, c]);
let cba = StatusCategory::fold_categories([c, b, a]);
prop_assert_eq!(abc, cba);
}
}
}