use crate::EntityType;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum LifecycleState {
Available,
Reserved,
Active,
Suspended,
Deactivated,
Quarantined,
Expired,
Tombstoned,
}
ppoppo_schema_constrained::impl_schema_constrained!(LifecycleState via as_str {
all: [
Available, Reserved, Active, Suspended,
Deactivated, Quarantined, Expired, Tombstoned,
],
constraints: [
"ck_ppnums_lifecycle_state_enum",
"ck_ppnum_lifecycle_events_from_state_enum",
"ck_ppnum_lifecycle_events_to_state_enum",
],
});
impl LifecycleState {
pub const ALL: [LifecycleState; 8] = [
Self::Available,
Self::Reserved,
Self::Active,
Self::Suspended,
Self::Deactivated,
Self::Quarantined,
Self::Expired,
Self::Tombstoned,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Available => "available",
Self::Reserved => "reserved",
Self::Active => "active",
Self::Suspended => "suspended",
Self::Deactivated => "deactivated",
Self::Quarantined => "quarantined",
Self::Expired => "expired",
Self::Tombstoned => "tombstoned",
}
}
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|st| st.as_str() == s)
}
#[must_use]
pub const fn is_active(self) -> bool {
matches!(self, Self::Active)
}
#[must_use]
pub const fn valid_transitions(self) -> &'static [LifecycleState] {
match self {
Self::Available => &[Self::Reserved],
Self::Reserved => &[Self::Available, Self::Active],
Self::Active => &[Self::Suspended, Self::Deactivated, Self::Expired],
Self::Suspended => &[Self::Active, Self::Deactivated],
Self::Deactivated => &[Self::Quarantined, Self::Tombstoned],
Self::Quarantined => &[Self::Available],
Self::Expired => &[Self::Available],
Self::Tombstoned => &[],
}
}
#[must_use]
pub fn can_transition_to(self, target: Self) -> bool {
self.valid_transitions().contains(&target)
}
#[must_use]
pub fn can_transition_for_entity(self, target: Self, entity_type: EntityType) -> bool {
if !self.can_transition_to(target) {
return false;
}
if target == Self::Expired && entity_type != EntityType::Mask {
return false;
}
true
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[test]
fn available_allows_reserved_only() {
assert!(LifecycleState::Available.can_transition_to(LifecycleState::Reserved));
assert!(!LifecycleState::Available.can_transition_to(LifecycleState::Active));
}
#[test]
fn reserved_transitions() {
assert!(LifecycleState::Reserved.can_transition_to(LifecycleState::Available));
assert!(LifecycleState::Reserved.can_transition_to(LifecycleState::Active));
assert!(!LifecycleState::Reserved.can_transition_to(LifecycleState::Suspended));
}
#[test]
fn active_transitions() {
assert!(LifecycleState::Active.can_transition_to(LifecycleState::Suspended));
assert!(LifecycleState::Active.can_transition_to(LifecycleState::Deactivated));
assert!(LifecycleState::Active.can_transition_to(LifecycleState::Expired));
assert!(!LifecycleState::Active.can_transition_to(LifecycleState::Quarantined));
}
#[test]
fn suspended_transitions() {
assert!(LifecycleState::Suspended.can_transition_to(LifecycleState::Active));
assert!(LifecycleState::Suspended.can_transition_to(LifecycleState::Deactivated));
assert!(!LifecycleState::Suspended.can_transition_to(LifecycleState::Expired));
}
#[test]
fn deactivated_transitions() {
assert!(LifecycleState::Deactivated.can_transition_to(LifecycleState::Quarantined));
assert!(!LifecycleState::Deactivated.can_transition_to(LifecycleState::Active));
}
#[test]
fn quarantined_transitions() {
assert!(LifecycleState::Quarantined.can_transition_to(LifecycleState::Available));
assert!(!LifecycleState::Quarantined.can_transition_to(LifecycleState::Active));
}
#[test]
fn expired_transitions() {
assert!(LifecycleState::Expired.can_transition_to(LifecycleState::Available));
assert!(!LifecycleState::Expired.can_transition_to(LifecycleState::Active));
}
#[test]
fn deactivated_can_transition_to_tombstoned() {
assert!(LifecycleState::Deactivated.can_transition_to(LifecycleState::Tombstoned));
assert!(LifecycleState::Deactivated.can_transition_to(LifecycleState::Quarantined));
assert!(!LifecycleState::Deactivated.can_transition_to(LifecycleState::Active));
}
#[test]
fn tombstoned_is_terminal() {
assert_eq!(LifecycleState::Tombstoned.valid_transitions(), &[]);
assert!(!LifecycleState::Tombstoned.can_transition_to(LifecycleState::Available));
assert!(!LifecycleState::Tombstoned.can_transition_to(LifecycleState::Deactivated));
}
#[test]
fn parse_is_the_inverse_of_as_str() {
for state in LifecycleState::ALL {
assert_eq!(LifecycleState::parse(state.as_str()), Some(state));
}
}
#[test]
fn unknown_strings_do_not_parse() {
for s in ["", "legacy_unknown", "ACTIVE", "active ", "unspecified"] {
assert_eq!(LifecycleState::parse(s), None, "{s:?} must not parse");
}
}
#[test]
fn active_is_the_only_live_state() {
for state in LifecycleState::ALL {
assert_eq!(state.is_active(), state == LifecycleState::Active);
}
}
#[test]
fn entity_aware_refuses_expired_for_non_mask() {
for entity in EntityType::ALL {
if entity == EntityType::Mask {
continue;
}
assert!(
!LifecycleState::Active.can_transition_for_entity(LifecycleState::Expired, entity),
"{entity:?} should not be eligible for Expired"
);
}
}
#[test]
fn entity_aware_allows_expired_for_mask() {
assert!(
LifecycleState::Active
.can_transition_for_entity(LifecycleState::Expired, EntityType::Mask)
);
}
#[test]
fn entity_aware_preserves_other_transitions() {
for entity in EntityType::ALL {
assert!(
LifecycleState::Active.can_transition_for_entity(LifecycleState::Suspended, entity)
);
assert!(
LifecycleState::Active
.can_transition_for_entity(LifecycleState::Deactivated, entity)
);
assert!(
!LifecycleState::Active.can_transition_for_entity(LifecycleState::Reserved, entity)
);
}
}
#[cfg(feature = "serde")]
#[test]
fn serde_encoding_is_exactly_as_str() {
for state in LifecycleState::ALL {
let json = serde_json::to_string(&state).expect("serialize");
assert_eq!(
json,
format!("\"{}\"", state.as_str()),
"the serde derive drifted from `as_str` for {state:?} — this \
breaks every JSON-cached PpnumAccount and mints a second \
spelling of the DB strings"
);
assert_eq!(
serde_json::from_str::<LifecycleState>(&json).expect("deserialize"),
state,
);
}
}
}