use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DeliverableStatus {
Proposed,
InProgress,
Blocked,
Complete,
Delivered,
Shipped,
}
impl DeliverableStatus {
pub const ALL: [DeliverableStatus; 6] = [
DeliverableStatus::Proposed,
DeliverableStatus::InProgress,
DeliverableStatus::Blocked,
DeliverableStatus::Complete,
DeliverableStatus::Delivered,
DeliverableStatus::Shipped,
];
pub const fn as_str(self) -> &'static str {
match self {
DeliverableStatus::Proposed => "proposed",
DeliverableStatus::InProgress => "in-progress",
DeliverableStatus::Blocked => "blocked",
DeliverableStatus::Complete => "complete",
DeliverableStatus::Delivered => "delivered",
DeliverableStatus::Shipped => "shipped",
}
}
pub const fn allowed_next(self) -> &'static [DeliverableStatus] {
match self {
DeliverableStatus::Proposed => &[DeliverableStatus::InProgress],
DeliverableStatus::InProgress => {
&[DeliverableStatus::Blocked, DeliverableStatus::Complete]
}
DeliverableStatus::Blocked => &[DeliverableStatus::InProgress],
DeliverableStatus::Complete => {
&[DeliverableStatus::Delivered, DeliverableStatus::Shipped]
}
DeliverableStatus::Delivered => &[],
DeliverableStatus::Shipped => &[],
}
}
pub fn can_transition(self, to: DeliverableStatus) -> bool {
self.allowed_next().contains(&to)
}
}
impl std::fmt::Display for DeliverableStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub struct TransitionError {
pub from: DeliverableStatus,
pub to: DeliverableStatus,
pub allowed: Vec<DeliverableStatus>,
}
impl std::fmt::Display for TransitionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let allowed = if self.allowed.is_empty() {
"none (terminal state)".to_string()
} else {
self.allowed
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
};
write!(
f,
"invalid status transition {} \u{2192} {}; legal next states from {}: [{}]",
self.from, self.to, self.from, allowed
)
}
}
pub fn validate_transition(
from: DeliverableStatus,
to: DeliverableStatus,
) -> Result<(), TransitionError> {
if from.can_transition(to) {
Ok(())
} else {
Err(TransitionError {
from,
to,
allowed: from.allowed_next().to_vec(),
})
}
}
#[cfg(test)]
mod tests {
use super::DeliverableStatus::*;
use super::*;
const LEGAL_PAIRS: [(DeliverableStatus, DeliverableStatus); 6] = [
(Proposed, InProgress),
(InProgress, Blocked),
(InProgress, Complete),
(Blocked, InProgress),
(Complete, Delivered),
(Complete, Shipped),
];
#[test]
fn all_lists_every_variant() {
assert_eq!(DeliverableStatus::ALL.len(), 6);
}
#[test]
fn transition_table_covers_every_ordered_pair() {
let legal: std::collections::HashSet<(DeliverableStatus, DeliverableStatus)> =
LEGAL_PAIRS.iter().copied().collect();
let mut checked = 0usize;
for &from in &DeliverableStatus::ALL {
for &to in &DeliverableStatus::ALL {
let expect_ok = legal.contains(&(from, to));
assert_eq!(
from.can_transition(to),
expect_ok,
"can_transition({from} -> {to}) disagreed with the §10.3 legal set"
);
match validate_transition(from, to) {
Ok(()) => assert!(expect_ok, "validate accepted illegal {from} -> {to}"),
Err(e) => {
assert!(!expect_ok, "validate rejected legal {from} -> {to}");
assert_eq!(e.from, from);
assert_eq!(e.to, to);
assert_eq!(e.allowed, from.allowed_next().to_vec());
}
}
checked += 1;
}
}
assert_eq!(checked, 36, "must exhaustively check all 36 ordered pairs");
}
#[test]
fn rejects_proposed_to_complete() {
let err = validate_transition(Proposed, Complete).expect_err("must reject");
assert_eq!(err.from, Proposed);
assert_eq!(err.to, Complete);
assert_eq!(err.allowed, vec![InProgress]);
}
#[test]
fn rejects_self_transition() {
for &s in &DeliverableStatus::ALL {
assert!(
validate_transition(s, s).is_err(),
"self-transition {s} -> {s} must be rejected"
);
}
}
#[test]
fn terminal_states_have_no_successors() {
assert!(Delivered.allowed_next().is_empty());
assert!(Shipped.allowed_next().is_empty());
for &to in &DeliverableStatus::ALL {
assert!(validate_transition(Delivered, to).is_err());
assert!(validate_transition(Shipped, to).is_err());
}
}
#[test]
fn blocked_branch_round_trips() {
assert!(InProgress.can_transition(Blocked));
assert!(Blocked.can_transition(InProgress));
assert!(!Blocked.can_transition(Complete));
}
#[test]
fn error_message_names_legal_states() {
let err = validate_transition(Proposed, Shipped).expect_err("reject");
let msg = err.to_string();
assert!(msg.contains("proposed"), "message names from-state: {msg}");
assert!(msg.contains("shipped"), "message names to-state: {msg}");
assert!(
msg.contains("in-progress"),
"message names the legal next state: {msg}"
);
}
#[test]
fn status_wire_format_is_kebab_case() {
assert_eq!(
serde_json::to_string(&InProgress).unwrap(),
"\"in-progress\""
);
assert_eq!(serde_json::to_string(&Proposed).unwrap(), "\"proposed\"");
let back: DeliverableStatus = serde_json::from_str("\"delivered\"").unwrap();
assert_eq!(back, Delivered);
for &s in &DeliverableStatus::ALL {
let json = serde_json::to_string(&s).unwrap();
assert_eq!(json, format!("\"{}\"", s.as_str()));
}
}
}