use chrono::Utc;
use crate::input_state::{
InputAbandonReason, InputLifecycleState, InputState, InputStateHistoryEntry,
InputTerminalOutcome,
};
use meerkat_core::lifecycle::InputId;
#[derive(Debug, Clone, thiserror::Error)]
#[non_exhaustive]
pub enum InputStateMachineError {
#[error("Invalid transition: {from:?} -> {to:?}")]
InvalidTransition {
from: InputLifecycleState,
to: InputLifecycleState,
},
#[error("Input {input_id} is in terminal state {state:?}")]
TerminalState {
input_id: InputId,
state: InputLifecycleState,
},
}
pub struct InputStateMachine;
impl InputStateMachine {
pub fn is_valid_transition(from: InputLifecycleState, to: InputLifecycleState) -> bool {
use InputLifecycleState::{
Abandoned, Accepted, Applied, AppliedPendingConsumption, Coalesced, Consumed, Queued,
Staged, Superseded,
};
if from.is_terminal() {
return false;
}
matches!(
(from, to),
(Accepted, Queued | Consumed | Superseded | Coalesced | Abandoned)
| (Queued, Staged | Superseded | Coalesced | Abandoned)
| (Staged, Queued | Applied | Superseded | Abandoned)
| (Applied, AppliedPendingConsumption | Abandoned)
| (AppliedPendingConsumption, Consumed | Abandoned)
)
}
pub fn transition(
state: &mut InputState,
to: InputLifecycleState,
reason: Option<String>,
) -> Result<(), InputStateMachineError> {
let from = state.current_state;
if from.is_terminal() {
return Err(InputStateMachineError::TerminalState {
input_id: state.input_id.clone(),
state: from,
});
}
if !Self::is_valid_transition(from, to) {
return Err(InputStateMachineError::InvalidTransition { from, to });
}
let now = Utc::now();
state.history.push(InputStateHistoryEntry {
timestamp: now,
from,
to,
reason,
});
state.current_state = to;
state.updated_at = now;
if to.is_terminal() && state.terminal_outcome.is_none() {
state.terminal_outcome = Some(match to {
InputLifecycleState::Consumed => InputTerminalOutcome::Consumed,
InputLifecycleState::Abandoned => InputTerminalOutcome::Abandoned {
reason: InputAbandonReason::Cancelled,
},
_ => return Ok(()),
});
}
Ok(())
}
pub fn set_terminal_outcome(state: &mut InputState, outcome: InputTerminalOutcome) {
state.terminal_outcome = Some(outcome);
}
pub fn abandon(
state: &mut InputState,
reason: InputAbandonReason,
) -> Result<(), InputStateMachineError> {
Self::transition(
state,
InputLifecycleState::Abandoned,
Some(format!("abandoned: {reason:?}")),
)?;
state.terminal_outcome = Some(InputTerminalOutcome::Abandoned { reason });
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn new_state() -> InputState {
InputState::new_accepted(InputId::new())
}
#[test]
fn accepted_to_queued() {
let mut state = new_state();
assert!(
InputStateMachine::transition(
&mut state,
InputLifecycleState::Queued,
Some("policy resolved".into()),
)
.is_ok()
);
assert_eq!(state.current_state, InputLifecycleState::Queued);
assert_eq!(state.history.len(), 1);
assert_eq!(state.history[0].from, InputLifecycleState::Accepted);
assert_eq!(state.history[0].to, InputLifecycleState::Queued);
}
#[test]
fn queued_to_staged() {
let mut state = new_state();
InputStateMachine::transition(&mut state, InputLifecycleState::Queued, None).unwrap();
assert!(
InputStateMachine::transition(&mut state, InputLifecycleState::Staged, None,).is_ok()
);
assert_eq!(state.current_state, InputLifecycleState::Staged);
}
#[test]
fn staged_to_applied() {
let mut state = new_state();
InputStateMachine::transition(&mut state, InputLifecycleState::Queued, None).unwrap();
InputStateMachine::transition(&mut state, InputLifecycleState::Staged, None).unwrap();
assert!(
InputStateMachine::transition(&mut state, InputLifecycleState::Applied, None,).is_ok()
);
assert_eq!(state.current_state, InputLifecycleState::Applied);
}
#[test]
fn applied_to_applied_pending_consumption() {
let mut state = new_state();
InputStateMachine::transition(&mut state, InputLifecycleState::Queued, None).unwrap();
InputStateMachine::transition(&mut state, InputLifecycleState::Staged, None).unwrap();
InputStateMachine::transition(&mut state, InputLifecycleState::Applied, None).unwrap();
assert!(
InputStateMachine::transition(
&mut state,
InputLifecycleState::AppliedPendingConsumption,
None,
)
.is_ok()
);
assert_eq!(
state.current_state,
InputLifecycleState::AppliedPendingConsumption
);
}
#[test]
fn applied_pending_to_consumed() {
let mut state = new_state();
InputStateMachine::transition(&mut state, InputLifecycleState::Queued, None).unwrap();
InputStateMachine::transition(&mut state, InputLifecycleState::Staged, None).unwrap();
InputStateMachine::transition(&mut state, InputLifecycleState::Applied, None).unwrap();
InputStateMachine::transition(
&mut state,
InputLifecycleState::AppliedPendingConsumption,
None,
)
.unwrap();
assert!(
InputStateMachine::transition(&mut state, InputLifecycleState::Consumed, None,).is_ok()
);
assert!(state.is_terminal());
assert!(matches!(
state.terminal_outcome,
Some(InputTerminalOutcome::Consumed)
));
}
#[test]
fn full_happy_path_history() {
let mut state = new_state();
InputStateMachine::transition(&mut state, InputLifecycleState::Queued, None).unwrap();
InputStateMachine::transition(&mut state, InputLifecycleState::Staged, None).unwrap();
InputStateMachine::transition(&mut state, InputLifecycleState::Applied, None).unwrap();
InputStateMachine::transition(
&mut state,
InputLifecycleState::AppliedPendingConsumption,
None,
)
.unwrap();
InputStateMachine::transition(&mut state, InputLifecycleState::Consumed, None).unwrap();
assert_eq!(state.history.len(), 5);
}
#[test]
fn staged_to_queued_rollback() {
let mut state = new_state();
InputStateMachine::transition(&mut state, InputLifecycleState::Queued, None).unwrap();
InputStateMachine::transition(&mut state, InputLifecycleState::Staged, None).unwrap();
assert!(
InputStateMachine::transition(
&mut state,
InputLifecycleState::Queued,
Some("run failed, rollback".into()),
)
.is_ok()
);
assert_eq!(state.current_state, InputLifecycleState::Queued);
}
#[test]
fn applied_pending_to_queued_rejected() {
let mut state = new_state();
InputStateMachine::transition(&mut state, InputLifecycleState::Queued, None).unwrap();
InputStateMachine::transition(&mut state, InputLifecycleState::Staged, None).unwrap();
InputStateMachine::transition(&mut state, InputLifecycleState::Applied, None).unwrap();
InputStateMachine::transition(
&mut state,
InputLifecycleState::AppliedPendingConsumption,
None,
)
.unwrap();
let result = InputStateMachine::transition(&mut state, InputLifecycleState::Queued, None);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
InputStateMachineError::InvalidTransition { .. }
));
assert_eq!(
state.current_state,
InputLifecycleState::AppliedPendingConsumption
);
}
#[test]
fn consumed_rejects_all() {
let mut state = new_state();
InputStateMachine::transition(&mut state, InputLifecycleState::Queued, None).unwrap();
InputStateMachine::transition(&mut state, InputLifecycleState::Staged, None).unwrap();
InputStateMachine::transition(&mut state, InputLifecycleState::Applied, None).unwrap();
InputStateMachine::transition(
&mut state,
InputLifecycleState::AppliedPendingConsumption,
None,
)
.unwrap();
InputStateMachine::transition(&mut state, InputLifecycleState::Consumed, None).unwrap();
for target in [
InputLifecycleState::Accepted,
InputLifecycleState::Queued,
InputLifecycleState::Staged,
InputLifecycleState::Applied,
InputLifecycleState::Consumed,
] {
let result = InputStateMachine::transition(&mut state, target, None);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
InputStateMachineError::TerminalState { .. }
));
}
}
#[test]
fn superseded_rejects_all() {
let mut state = new_state();
InputStateMachine::transition(&mut state, InputLifecycleState::Superseded, None).unwrap();
assert!(state.is_terminal());
let result = InputStateMachine::transition(&mut state, InputLifecycleState::Queued, None);
assert!(result.is_err());
}
#[test]
fn coalesced_rejects_all() {
let mut state = new_state();
InputStateMachine::transition(&mut state, InputLifecycleState::Coalesced, None).unwrap();
assert!(state.is_terminal());
let result = InputStateMachine::transition(&mut state, InputLifecycleState::Queued, None);
assert!(result.is_err());
}
#[test]
fn abandoned_rejects_all() {
let mut state = new_state();
InputStateMachine::abandon(&mut state, InputAbandonReason::Retired).unwrap();
assert!(state.is_terminal());
let result = InputStateMachine::transition(&mut state, InputLifecycleState::Queued, None);
assert!(result.is_err());
}
#[test]
fn abandon_from_accepted() {
let mut state = new_state();
assert!(InputStateMachine::abandon(&mut state, InputAbandonReason::Retired).is_ok());
assert!(matches!(
state.terminal_outcome,
Some(InputTerminalOutcome::Abandoned {
reason: InputAbandonReason::Retired,
})
));
}
#[test]
fn abandon_from_queued() {
let mut state = new_state();
InputStateMachine::transition(&mut state, InputLifecycleState::Queued, None).unwrap();
assert!(InputStateMachine::abandon(&mut state, InputAbandonReason::Reset).is_ok());
}
#[test]
fn abandon_from_staged() {
let mut state = new_state();
InputStateMachine::transition(&mut state, InputLifecycleState::Queued, None).unwrap();
InputStateMachine::transition(&mut state, InputLifecycleState::Staged, None).unwrap();
assert!(InputStateMachine::abandon(&mut state, InputAbandonReason::Destroyed).is_ok());
}
#[test]
fn abandon_from_applied() {
let mut state = new_state();
InputStateMachine::transition(&mut state, InputLifecycleState::Queued, None).unwrap();
InputStateMachine::transition(&mut state, InputLifecycleState::Staged, None).unwrap();
InputStateMachine::transition(&mut state, InputLifecycleState::Applied, None).unwrap();
assert!(InputStateMachine::abandon(&mut state, InputAbandonReason::Cancelled).is_ok());
}
#[test]
fn abandon_from_applied_pending() {
let mut state = new_state();
InputStateMachine::transition(&mut state, InputLifecycleState::Queued, None).unwrap();
InputStateMachine::transition(&mut state, InputLifecycleState::Staged, None).unwrap();
InputStateMachine::transition(&mut state, InputLifecycleState::Applied, None).unwrap();
InputStateMachine::transition(
&mut state,
InputLifecycleState::AppliedPendingConsumption,
None,
)
.unwrap();
assert!(InputStateMachine::abandon(&mut state, InputAbandonReason::Retired).is_ok());
}
#[test]
fn accepted_to_consumed_ignore_on_accept() {
let mut state = new_state();
assert!(
InputStateMachine::transition(
&mut state,
InputLifecycleState::Consumed,
Some("Ignore + OnAccept".into()),
)
.is_ok()
);
assert!(state.is_terminal());
}
#[test]
fn accepted_to_staged_invalid() {
let mut state = new_state();
let result = InputStateMachine::transition(&mut state, InputLifecycleState::Staged, None);
assert!(result.is_err());
}
#[test]
fn accepted_to_applied_invalid() {
let mut state = new_state();
let result = InputStateMachine::transition(&mut state, InputLifecycleState::Applied, None);
assert!(result.is_err());
}
#[test]
fn queued_to_applied_invalid() {
let mut state = new_state();
InputStateMachine::transition(&mut state, InputLifecycleState::Queued, None).unwrap();
let result = InputStateMachine::transition(&mut state, InputLifecycleState::Applied, None);
assert!(result.is_err());
}
#[test]
fn queued_to_consumed_invalid() {
let mut state = new_state();
InputStateMachine::transition(&mut state, InputLifecycleState::Queued, None).unwrap();
let result = InputStateMachine::transition(&mut state, InputLifecycleState::Consumed, None);
assert!(result.is_err());
}
#[test]
fn set_terminal_outcome_superseded() {
let mut state = new_state();
let superseder = InputId::new();
InputStateMachine::transition(&mut state, InputLifecycleState::Superseded, None).unwrap();
InputStateMachine::set_terminal_outcome(
&mut state,
InputTerminalOutcome::Superseded {
superseded_by: superseder,
},
);
assert!(matches!(
state.terminal_outcome,
Some(InputTerminalOutcome::Superseded { .. })
));
}
#[test]
fn history_records_reason() {
let mut state = new_state();
InputStateMachine::transition(
&mut state,
InputLifecycleState::Queued,
Some("test reason".into()),
)
.unwrap();
assert_eq!(state.history[0].reason.as_deref(), Some("test reason"));
}
#[test]
fn history_records_timestamps() {
let mut state = new_state();
let before = Utc::now();
InputStateMachine::transition(&mut state, InputLifecycleState::Queued, None).unwrap();
let after = Utc::now();
assert!(state.history[0].timestamp >= before);
assert!(state.history[0].timestamp <= after);
}
}