use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(
Clone,
Copy,
Debug,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
JsonSchema,
tatara_closed_set::DeriveClosedSet,
)]
#[closed_set(
via = "as_str",
unknown = "UnknownPhase",
display,
generate_unknown = "process phase"
)]
pub enum ProcessPhase {
Pending,
Forking,
Execing,
Running,
Attested,
Reconverging,
Releasing,
Exiting,
Failed,
Zombie,
Reaped,
}
impl Default for ProcessPhase {
fn default() -> Self {
Self::Pending
}
}
impl ProcessPhase {
pub const ALL: [Self; 11] = [
Self::Pending,
Self::Forking,
Self::Execing,
Self::Running,
Self::Attested,
Self::Reconverging,
Self::Releasing,
Self::Exiting,
Self::Failed,
Self::Zombie,
Self::Reaped,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::Pending => "Pending",
Self::Forking => "Forking",
Self::Execing => "Execing",
Self::Running => "Running",
Self::Attested => "Attested",
Self::Reconverging => "Reconverging",
Self::Releasing => "Releasing",
Self::Exiting => "Exiting",
Self::Failed => "Failed",
Self::Zombie => "Zombie",
Self::Reaped => "Reaped",
}
}
pub const fn is_terminal(self) -> bool {
matches!(self, Self::Reaped)
}
pub const fn is_running(self) -> bool {
matches!(self, Self::Running | Self::Attested)
}
pub const fn is_alive(self) -> bool {
!matches!(self, Self::Zombie | Self::Reaped | Self::Failed)
}
pub const fn has_exited(self) -> bool {
!self.is_alive()
}
pub const fn is_releasing(self) -> bool {
matches!(self, Self::Releasing)
}
pub const fn is_terminal_reached(self) -> bool {
matches!(self, Self::Attested | Self::Failed)
}
pub const fn released_from_label(self) -> &'static str {
match self {
Self::Failed => Self::Failed.as_str(),
_ => Self::Attested.as_str(),
}
}
pub fn parse_released_from(s: Option<&str>) -> Self {
match s {
Some(v) if v == Self::Failed.as_str() => Self::Failed,
_ => Self::Attested,
}
}
pub const fn can_transition_to(self, next: Self) -> bool {
use ProcessPhase::*;
matches!(
(self, next),
(Pending, Forking)
| (Forking, Execing)
| (Execing, Running)
| (Execing, Failed)
| (Running, Attested)
| (Running, Exiting)
| (Running, Failed)
| (Running, Reconverging)
| (Attested, Reconverging)
| (Attested, Releasing)
| (Attested, Exiting)
| (Failed, Releasing)
| (Failed, Zombie)
| (Releasing, Exiting)
| (Releasing, Zombie)
| (Reconverging, Execing)
| (Exiting, Zombie)
| (Zombie, Reaped)
)
}
}
#[cfg(test)]
mod tests {
use super::ProcessPhase::*;
#[test]
fn canonical_path_is_legal() {
assert!(Pending.can_transition_to(Forking));
assert!(Forking.can_transition_to(Execing));
assert!(Execing.can_transition_to(Running));
assert!(Running.can_transition_to(Attested));
assert!(Attested.can_transition_to(Reconverging));
assert!(Reconverging.can_transition_to(Execing));
assert!(Attested.can_transition_to(Exiting));
assert!(Exiting.can_transition_to(Zombie));
assert!(Zombie.can_transition_to(Reaped));
}
#[test]
fn releasing_path_is_legal() {
assert!(Attested.can_transition_to(Releasing));
assert!(Failed.can_transition_to(Releasing));
assert!(Releasing.can_transition_to(Exiting));
assert!(Releasing.can_transition_to(Zombie));
assert!(Releasing.is_alive());
assert!(!Releasing.is_terminal_reached());
}
#[test]
fn terminal_reached_gates_are_attested_and_failed() {
assert!(Attested.is_terminal_reached());
assert!(Failed.is_terminal_reached());
for p in super::ProcessPhase::ALL {
if matches!(p, Attested | Failed) {
continue;
}
assert!(!p.is_terminal_reached(), "{p:?} is not a terminal gate");
}
}
#[test]
fn releasing_can_only_be_entered_from_terminal_gates() {
let entries: Vec<_> = super::ProcessPhase::ALL
.into_iter()
.filter(|p| p.can_transition_to(Releasing))
.collect();
assert_eq!(entries, vec![Attested, Failed]);
}
#[test]
fn reaped_is_sink() {
assert!(Reaped.is_terminal());
for next in super::ProcessPhase::ALL {
if next == Reaped {
continue;
}
assert!(
!Reaped.can_transition_to(next),
"Reaped → {next:?} should be illegal"
);
}
}
#[test]
fn cannot_skip_forking() {
assert!(!Pending.can_transition_to(Execing));
assert!(!Pending.can_transition_to(Running));
}
#[test]
fn running_is_alive() {
assert!(Running.is_alive());
assert!(Attested.is_alive());
assert!(!Zombie.is_alive());
assert!(!Reaped.is_alive());
}
#[test]
fn has_exited_sinks_to_failed_zombie_reaped() {
for p in super::ProcessPhase::ALL {
let expected = matches!(p, Failed | Zombie | Reaped);
assert_eq!(
p.has_exited(),
expected,
"{p:?}.has_exited() should be {expected}"
);
}
}
#[test]
fn has_exited_is_complement_of_is_alive() {
for p in super::ProcessPhase::ALL {
assert_eq!(
p.has_exited(),
!p.is_alive(),
"{p:?}: has_exited should equal !is_alive"
);
}
}
#[test]
fn is_running_and_has_exited_are_disjoint() {
for p in super::ProcessPhase::ALL {
assert!(
!(p.is_running() && p.has_exited()),
"{p:?}: cannot be both is_running() and has_exited()"
);
}
}
#[test]
fn process_phase_is_well_formed_closed_set() {
tatara_closed_set::assert_closed_set_well_formed::<super::ProcessPhase>();
}
#[test]
fn display_matches_as_str() {
for phase in super::ProcessPhase::ALL {
assert_eq!(phase.to_string(), phase.as_str());
}
}
#[test]
fn unknown_phase_errors() {
use std::str::FromStr;
for bad in ["attested", "FAILED", "Cancelled", "Reapped"] {
let err = super::ProcessPhase::from_str(bad).unwrap_err();
assert_eq!(err.0, bad, "error payload should echo input verbatim");
}
}
#[test]
fn released_from_label_maps_failed_to_failed_string() {
assert_eq!(super::ProcessPhase::Failed.released_from_label(), "Failed");
}
#[test]
fn released_from_label_maps_attested_to_attested_string() {
assert_eq!(
super::ProcessPhase::Attested.released_from_label(),
"Attested"
);
}
#[test]
fn released_from_label_collapses_non_gate_phases_to_attested() {
for p in super::ProcessPhase::ALL {
if matches!(p, super::ProcessPhase::Failed) {
continue;
}
assert_eq!(
p.released_from_label(),
"Attested",
"{p:?} must collapse to \"Attested\" under released_from_label",
);
}
}
#[test]
fn parse_released_from_matches_hardcoded_pre_lift_reader() {
assert_eq!(
super::ProcessPhase::parse_released_from(Some("Failed")),
super::ProcessPhase::Failed,
);
assert_eq!(
super::ProcessPhase::parse_released_from(Some("Attested")),
super::ProcessPhase::Attested,
);
assert_eq!(
super::ProcessPhase::parse_released_from(None),
super::ProcessPhase::Attested,
);
for bad in [
"",
"failed",
"FAILED",
"attested",
"Running",
"Reaped",
"Some(Failed)",
] {
assert_eq!(
super::ProcessPhase::parse_released_from(Some(bad)),
super::ProcessPhase::Attested,
"non-canonical input {bad:?} must collapse to Attested",
);
}
}
#[test]
fn released_from_label_and_parse_are_inverse_on_terminal_gates() {
for gate in [super::ProcessPhase::Attested, super::ProcessPhase::Failed] {
let round_trip =
super::ProcessPhase::parse_released_from(Some(gate.released_from_label()));
assert_eq!(
round_trip, gate,
"{gate:?} must round-trip through label→parse",
);
}
}
#[test]
fn released_from_label_routes_through_as_str_not_a_hardcoded_literal() {
assert_eq!(
super::ProcessPhase::Failed.released_from_label(),
super::ProcessPhase::Failed.as_str(),
);
assert_eq!(
super::ProcessPhase::Attested.released_from_label(),
super::ProcessPhase::Attested.as_str(),
);
}
#[test]
fn released_from_label_output_is_always_a_terminal_gate() {
for p in super::ProcessPhase::ALL {
let label = p.released_from_label();
let decoded = super::ProcessPhase::parse_released_from(Some(label));
assert!(
matches!(decoded, super::ProcessPhase::Attested | super::ProcessPhase::Failed),
"{p:?}'s label {label:?} decoded to {decoded:?} — must land in the {{Attested,Failed}} gate set",
);
}
}
}