#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Transition {
Advance,
BackEdge,
Skip,
Abandon,
Noop,
FromTerminal,
SeamBreach,
}
pub(crate) fn is_transition_terminal(status: &str) -> bool {
matches!(status, "done" | "abandoned")
}
pub(crate) fn crosses_closure_seam(from: &str, to: &str) -> bool {
matches!((from, to), ("audit", "reconcile") | ("reconcile", "done"))
}
pub(crate) fn classify(from: &str, to: &str) -> Transition {
if from == to {
return Transition::Noop;
}
if is_transition_terminal(from) {
return Transition::FromTerminal;
}
if to == "reconcile" {
return if from == "audit" {
Transition::Advance
} else {
Transition::SeamBreach
};
}
if to == "done" {
return if from == "reconcile" {
Transition::Advance
} else {
Transition::SeamBreach
};
}
if to == "abandoned" {
return Transition::Abandon;
}
match (from, to) {
("proposed", "design")
| ("design", "plan")
| ("plan", "ready")
| ("ready", "started")
| ("started", "audit") => Transition::Advance,
("audit", "started" | "design") | ("reconcile", "audit" | "design") => Transition::BackEdge,
_ => Transition::Skip,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_forward_chain_is_advance() {
for (from, to) in [
("proposed", "design"),
("design", "plan"),
("plan", "ready"),
("ready", "started"),
("started", "audit"),
] {
assert_eq!(classify(from, to), Transition::Advance, "{from} → {to}");
}
}
#[test]
fn classify_legit_closure_seam_path_is_advance() {
assert_eq!(classify("audit", "reconcile"), Transition::Advance);
assert_eq!(classify("reconcile", "done"), Transition::Advance);
}
#[test]
fn classify_named_back_edges() {
for (from, to) in [
("audit", "started"),
("audit", "design"),
("reconcile", "audit"),
("reconcile", "design"),
] {
assert_eq!(classify(from, to), Transition::BackEdge, "{from} → {to}");
}
}
#[test]
fn classify_abandon_from_each_non_terminal() {
for from in [
"proposed",
"design",
"plan",
"ready",
"started",
"audit",
"reconcile",
] {
assert_eq!(
classify(from, "abandoned"),
Transition::Abandon,
"{from} → abandoned"
);
}
}
#[test]
fn classify_noop_when_unchanged() {
assert_eq!(classify("started", "started"), Transition::Noop);
assert_eq!(classify("done", "done"), Transition::Noop);
}
#[test]
fn classify_from_terminal_refused() {
for from in ["done", "abandoned"] {
assert_eq!(
classify(from, "design"),
Transition::FromTerminal,
"{from} → design"
);
}
}
#[test]
fn classify_seam_breach_to_reconcile_from_non_audit() {
for from in ["proposed", "design", "plan", "ready", "started"] {
assert_eq!(
classify(from, "reconcile"),
Transition::SeamBreach,
"{from} → reconcile"
);
}
}
#[test]
fn classify_seam_breach_to_done_from_non_reconcile() {
for from in ["proposed", "design", "plan", "ready", "started", "audit"] {
assert_eq!(
classify(from, "done"),
Transition::SeamBreach,
"{from} → done"
);
}
}
#[test]
fn classify_seam_binds_even_from_a_drifted_source() {
assert_eq!(classify("bogus", "reconcile"), Transition::SeamBreach);
assert_eq!(classify("bogus", "done"), Transition::SeamBreach);
}
#[test]
fn classify_move_out_of_drift_is_skip_not_refused() {
assert_eq!(classify("bogus", "started"), Transition::Skip);
}
#[test]
fn classify_non_chain_move_is_skip() {
assert_eq!(classify("proposed", "started"), Transition::Skip);
assert_eq!(classify("design", "started"), Transition::Skip);
}
}