Skip to main content

oximedia_workflow/
state_machine.rs

1// Copyright 2025 OxiMedia Contributors
2// Licensed under the Apache License, Version 2.0
3
4//! Finite state machine for workflow execution state tracking.
5//!
6//! Provides a simple but complete FSM implementation with named states,
7//! guarded transitions, and terminal-state detection.
8
9/// A single state in the FSM.
10#[derive(Debug, Clone)]
11pub struct WorkflowState {
12    /// Unique identifier for this state.
13    pub id: String,
14    /// Human-readable label.
15    pub name: String,
16    /// If `true` the FSM stops accepting transitions once in this state.
17    pub is_terminal: bool,
18    /// Optional action identifier to invoke on entry.
19    pub on_enter: Option<String>,
20    /// Optional action identifier to invoke on exit.
21    pub on_exit: Option<String>,
22}
23
24impl WorkflowState {
25    /// Create a new state.
26    #[must_use]
27    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
28        Self {
29            id: id.into(),
30            name: name.into(),
31            is_terminal: false,
32            on_enter: None,
33            on_exit: None,
34        }
35    }
36
37    /// Mark this state as terminal (no outgoing transitions accepted).
38    #[must_use]
39    pub fn terminal(mut self) -> Self {
40        self.is_terminal = true;
41        self
42    }
43
44    /// Set the on-enter action identifier.
45    #[must_use]
46    pub fn on_enter(mut self, action: impl Into<String>) -> Self {
47        self.on_enter = Some(action.into());
48        self
49    }
50
51    /// Set the on-exit action identifier.
52    #[must_use]
53    pub fn on_exit(mut self, action: impl Into<String>) -> Self {
54        self.on_exit = Some(action.into());
55        self
56    }
57
58    /// Returns `true` when the state is a terminal state.
59    #[must_use]
60    pub fn is_final(&self) -> bool {
61        self.is_terminal
62    }
63}
64
65/// A directed transition between two states.
66#[derive(Debug, Clone)]
67pub struct WorkflowTransition {
68    /// The source state ID.
69    pub from: String,
70    /// The destination state ID.
71    pub to: String,
72    /// Event name that triggers this transition.
73    pub trigger: String,
74    /// Optional guard expression (informational only in this implementation).
75    pub guard: Option<String>,
76}
77
78impl WorkflowTransition {
79    /// Create a new transition.
80    #[must_use]
81    pub fn new(from: impl Into<String>, to: impl Into<String>, trigger: impl Into<String>) -> Self {
82        Self {
83            from: from.into(),
84            to: to.into(),
85            trigger: trigger.into(),
86            guard: None,
87        }
88    }
89
90    /// Attach a guard expression to this transition.
91    #[must_use]
92    pub fn with_guard(mut self, guard: impl Into<String>) -> Self {
93        self.guard = Some(guard.into());
94        self
95    }
96
97    /// Returns `true` when `t` matches this transition's trigger.
98    #[must_use]
99    pub fn matches_trigger(&self, t: &str) -> bool {
100        self.trigger == t
101    }
102}
103
104/// A finite state machine composed of [`WorkflowState`]s and [`WorkflowTransition`]s.
105#[derive(Debug, Clone)]
106pub struct StateMachine {
107    /// All registered states.
108    pub states: Vec<WorkflowState>,
109    /// All registered transitions.
110    pub transitions: Vec<WorkflowTransition>,
111    /// ID of the currently active state.
112    pub current_state: String,
113}
114
115impl StateMachine {
116    /// Create a new FSM with `initial_state_id` as the starting state.
117    ///
118    /// The initial state is NOT required to exist in `states` at construction
119    /// time; it is added implicitly when the first state is added.
120    #[must_use]
121    pub fn new(initial_state_id: impl Into<String>) -> Self {
122        Self {
123            states: Vec::new(),
124            transitions: Vec::new(),
125            current_state: initial_state_id.into(),
126        }
127    }
128
129    /// Register a state.
130    pub fn add_state(&mut self, state: WorkflowState) {
131        self.states.push(state);
132    }
133
134    /// Register a transition.
135    pub fn add_transition(&mut self, t: WorkflowTransition) {
136        self.transitions.push(t);
137    }
138
139    /// Fire `event`.  Returns `true` if a transition was found and applied.
140    ///
141    /// A transition is eligible when:
142    /// 1. Its `from` field matches `current_state`.
143    /// 2. Its `trigger` matches `event`.
144    /// 3. The current state is not terminal.
145    pub fn trigger(&mut self, event: &str) -> bool {
146        if self.is_terminal() {
147            return false;
148        }
149
150        let transition = self
151            .transitions
152            .iter()
153            .find(|t| t.from == self.current_state && t.matches_trigger(event));
154
155        if let Some(t) = transition {
156            let next = t.to.clone();
157            self.current_state = next;
158            true
159        } else {
160            false
161        }
162    }
163
164    /// Return the ID of the current state.
165    #[must_use]
166    pub fn current(&self) -> &str {
167        &self.current_state
168    }
169
170    /// Returns `true` when the current state is marked terminal.
171    #[must_use]
172    pub fn is_terminal(&self) -> bool {
173        self.states
174            .iter()
175            .find(|s| s.id == self.current_state)
176            .is_some_and(|s| s.is_terminal)
177    }
178
179    /// Return the event names of all transitions originating from the current state.
180    #[must_use]
181    pub fn valid_triggers(&self) -> Vec<&str> {
182        self.transitions
183            .iter()
184            .filter(|t| t.from == self.current_state)
185            .map(|t| t.trigger.as_str())
186            .collect()
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    fn build_simple_fsm() -> StateMachine {
195        let mut fsm = StateMachine::new("idle");
196        fsm.add_state(WorkflowState::new("idle", "Idle"));
197        fsm.add_state(WorkflowState::new("running", "Running"));
198        fsm.add_state(WorkflowState::new("done", "Done").terminal());
199        fsm.add_state(WorkflowState::new("failed", "Failed").terminal());
200
201        fsm.add_transition(WorkflowTransition::new("idle", "running", "start"));
202        fsm.add_transition(WorkflowTransition::new("running", "done", "complete"));
203        fsm.add_transition(WorkflowTransition::new("running", "failed", "error"));
204        fsm
205    }
206
207    #[test]
208    fn test_initial_state() {
209        let fsm = build_simple_fsm();
210        assert_eq!(fsm.current(), "idle");
211    }
212
213    #[test]
214    fn test_trigger_valid_event() {
215        let mut fsm = build_simple_fsm();
216        assert!(fsm.trigger("start"));
217        assert_eq!(fsm.current(), "running");
218    }
219
220    #[test]
221    fn test_trigger_unknown_event_returns_false() {
222        let mut fsm = build_simple_fsm();
223        assert!(!fsm.trigger("unknown"));
224        assert_eq!(fsm.current(), "idle");
225    }
226
227    #[test]
228    fn test_multi_step_transition() {
229        let mut fsm = build_simple_fsm();
230        fsm.trigger("start");
231        fsm.trigger("complete");
232        assert_eq!(fsm.current(), "done");
233    }
234
235    #[test]
236    fn test_terminal_state_blocks_trigger() {
237        let mut fsm = build_simple_fsm();
238        fsm.trigger("start");
239        fsm.trigger("complete");
240        assert!(fsm.is_terminal());
241        // further triggers should be ignored
242        assert!(!fsm.trigger("start"));
243        assert_eq!(fsm.current(), "done");
244    }
245
246    #[test]
247    fn test_error_path() {
248        let mut fsm = build_simple_fsm();
249        fsm.trigger("start");
250        assert!(fsm.trigger("error"));
251        assert_eq!(fsm.current(), "failed");
252        assert!(fsm.is_terminal());
253    }
254
255    #[test]
256    fn test_is_terminal_false_on_nonterminal() {
257        let fsm = build_simple_fsm();
258        assert!(!fsm.is_terminal());
259    }
260
261    #[test]
262    fn test_valid_triggers_from_idle() {
263        let fsm = build_simple_fsm();
264        let triggers = fsm.valid_triggers();
265        assert_eq!(triggers, vec!["start"]);
266    }
267
268    #[test]
269    fn test_valid_triggers_from_running() {
270        let mut fsm = build_simple_fsm();
271        fsm.trigger("start");
272        let mut triggers = fsm.valid_triggers();
273        triggers.sort_unstable();
274        assert_eq!(triggers, vec!["complete", "error"]);
275    }
276
277    #[test]
278    fn test_workflow_state_is_final() {
279        let s = WorkflowState::new("end", "End").terminal();
280        assert!(s.is_final());
281        let s2 = WorkflowState::new("mid", "Mid");
282        assert!(!s2.is_final());
283    }
284
285    #[test]
286    fn test_workflow_state_on_enter_exit() {
287        let s = WorkflowState::new("s", "S")
288            .on_enter("log_entry")
289            .on_exit("log_exit");
290        assert_eq!(s.on_enter.as_deref(), Some("log_entry"));
291        assert_eq!(s.on_exit.as_deref(), Some("log_exit"));
292    }
293
294    #[test]
295    fn test_transition_matches_trigger() {
296        let t = WorkflowTransition::new("a", "b", "go");
297        assert!(t.matches_trigger("go"));
298        assert!(!t.matches_trigger("stop"));
299    }
300
301    #[test]
302    fn test_transition_with_guard() {
303        let t = WorkflowTransition::new("a", "b", "go").with_guard("x > 0");
304        assert_eq!(t.guard.as_deref(), Some("x > 0"));
305    }
306
307    #[test]
308    fn test_valid_triggers_empty_when_terminal() {
309        let mut fsm = build_simple_fsm();
310        fsm.trigger("start");
311        fsm.trigger("complete");
312        // terminal state — no transitions defined from "done"
313        assert!(fsm.valid_triggers().is_empty());
314    }
315}