#[derive(Debug, Clone)]
pub struct WorkflowState {
pub id: String,
pub name: String,
pub is_terminal: bool,
pub on_enter: Option<String>,
pub on_exit: Option<String>,
}
impl WorkflowState {
#[must_use]
pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
Self {
id: id.into(),
name: name.into(),
is_terminal: false,
on_enter: None,
on_exit: None,
}
}
#[must_use]
pub fn terminal(mut self) -> Self {
self.is_terminal = true;
self
}
#[must_use]
pub fn on_enter(mut self, action: impl Into<String>) -> Self {
self.on_enter = Some(action.into());
self
}
#[must_use]
pub fn on_exit(mut self, action: impl Into<String>) -> Self {
self.on_exit = Some(action.into());
self
}
#[must_use]
pub fn is_final(&self) -> bool {
self.is_terminal
}
}
#[derive(Debug, Clone)]
pub struct WorkflowTransition {
pub from: String,
pub to: String,
pub trigger: String,
pub guard: Option<String>,
}
impl WorkflowTransition {
#[must_use]
pub fn new(from: impl Into<String>, to: impl Into<String>, trigger: impl Into<String>) -> Self {
Self {
from: from.into(),
to: to.into(),
trigger: trigger.into(),
guard: None,
}
}
#[must_use]
pub fn with_guard(mut self, guard: impl Into<String>) -> Self {
self.guard = Some(guard.into());
self
}
#[must_use]
pub fn matches_trigger(&self, t: &str) -> bool {
self.trigger == t
}
}
#[derive(Debug, Clone)]
pub struct StateMachine {
pub states: Vec<WorkflowState>,
pub transitions: Vec<WorkflowTransition>,
pub current_state: String,
}
impl StateMachine {
#[must_use]
pub fn new(initial_state_id: impl Into<String>) -> Self {
Self {
states: Vec::new(),
transitions: Vec::new(),
current_state: initial_state_id.into(),
}
}
pub fn add_state(&mut self, state: WorkflowState) {
self.states.push(state);
}
pub fn add_transition(&mut self, t: WorkflowTransition) {
self.transitions.push(t);
}
pub fn trigger(&mut self, event: &str) -> bool {
if self.is_terminal() {
return false;
}
let transition = self
.transitions
.iter()
.find(|t| t.from == self.current_state && t.matches_trigger(event));
if let Some(t) = transition {
let next = t.to.clone();
self.current_state = next;
true
} else {
false
}
}
#[must_use]
pub fn current(&self) -> &str {
&self.current_state
}
#[must_use]
pub fn is_terminal(&self) -> bool {
self.states
.iter()
.find(|s| s.id == self.current_state)
.is_some_and(|s| s.is_terminal)
}
#[must_use]
pub fn valid_triggers(&self) -> Vec<&str> {
self.transitions
.iter()
.filter(|t| t.from == self.current_state)
.map(|t| t.trigger.as_str())
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn build_simple_fsm() -> StateMachine {
let mut fsm = StateMachine::new("idle");
fsm.add_state(WorkflowState::new("idle", "Idle"));
fsm.add_state(WorkflowState::new("running", "Running"));
fsm.add_state(WorkflowState::new("done", "Done").terminal());
fsm.add_state(WorkflowState::new("failed", "Failed").terminal());
fsm.add_transition(WorkflowTransition::new("idle", "running", "start"));
fsm.add_transition(WorkflowTransition::new("running", "done", "complete"));
fsm.add_transition(WorkflowTransition::new("running", "failed", "error"));
fsm
}
#[test]
fn test_initial_state() {
let fsm = build_simple_fsm();
assert_eq!(fsm.current(), "idle");
}
#[test]
fn test_trigger_valid_event() {
let mut fsm = build_simple_fsm();
assert!(fsm.trigger("start"));
assert_eq!(fsm.current(), "running");
}
#[test]
fn test_trigger_unknown_event_returns_false() {
let mut fsm = build_simple_fsm();
assert!(!fsm.trigger("unknown"));
assert_eq!(fsm.current(), "idle");
}
#[test]
fn test_multi_step_transition() {
let mut fsm = build_simple_fsm();
fsm.trigger("start");
fsm.trigger("complete");
assert_eq!(fsm.current(), "done");
}
#[test]
fn test_terminal_state_blocks_trigger() {
let mut fsm = build_simple_fsm();
fsm.trigger("start");
fsm.trigger("complete");
assert!(fsm.is_terminal());
assert!(!fsm.trigger("start"));
assert_eq!(fsm.current(), "done");
}
#[test]
fn test_error_path() {
let mut fsm = build_simple_fsm();
fsm.trigger("start");
assert!(fsm.trigger("error"));
assert_eq!(fsm.current(), "failed");
assert!(fsm.is_terminal());
}
#[test]
fn test_is_terminal_false_on_nonterminal() {
let fsm = build_simple_fsm();
assert!(!fsm.is_terminal());
}
#[test]
fn test_valid_triggers_from_idle() {
let fsm = build_simple_fsm();
let triggers = fsm.valid_triggers();
assert_eq!(triggers, vec!["start"]);
}
#[test]
fn test_valid_triggers_from_running() {
let mut fsm = build_simple_fsm();
fsm.trigger("start");
let mut triggers = fsm.valid_triggers();
triggers.sort_unstable();
assert_eq!(triggers, vec!["complete", "error"]);
}
#[test]
fn test_workflow_state_is_final() {
let s = WorkflowState::new("end", "End").terminal();
assert!(s.is_final());
let s2 = WorkflowState::new("mid", "Mid");
assert!(!s2.is_final());
}
#[test]
fn test_workflow_state_on_enter_exit() {
let s = WorkflowState::new("s", "S")
.on_enter("log_entry")
.on_exit("log_exit");
assert_eq!(s.on_enter.as_deref(), Some("log_entry"));
assert_eq!(s.on_exit.as_deref(), Some("log_exit"));
}
#[test]
fn test_transition_matches_trigger() {
let t = WorkflowTransition::new("a", "b", "go");
assert!(t.matches_trigger("go"));
assert!(!t.matches_trigger("stop"));
}
#[test]
fn test_transition_with_guard() {
let t = WorkflowTransition::new("a", "b", "go").with_guard("x > 0");
assert_eq!(t.guard.as_deref(), Some("x > 0"));
}
#[test]
fn test_valid_triggers_empty_when_terminal() {
let mut fsm = build_simple_fsm();
fsm.trigger("start");
fsm.trigger("complete");
assert!(fsm.valid_triggers().is_empty());
}
}