oximedia_workflow/
state_machine.rs1#[derive(Debug, Clone)]
11pub struct WorkflowState {
12 pub id: String,
14 pub name: String,
16 pub is_terminal: bool,
18 pub on_enter: Option<String>,
20 pub on_exit: Option<String>,
22}
23
24impl WorkflowState {
25 #[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 #[must_use]
39 pub fn terminal(mut self) -> Self {
40 self.is_terminal = true;
41 self
42 }
43
44 #[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 #[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 #[must_use]
60 pub fn is_final(&self) -> bool {
61 self.is_terminal
62 }
63}
64
65#[derive(Debug, Clone)]
67pub struct WorkflowTransition {
68 pub from: String,
70 pub to: String,
72 pub trigger: String,
74 pub guard: Option<String>,
76}
77
78impl WorkflowTransition {
79 #[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 #[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 #[must_use]
99 pub fn matches_trigger(&self, t: &str) -> bool {
100 self.trigger == t
101 }
102}
103
104#[derive(Debug, Clone)]
106pub struct StateMachine {
107 pub states: Vec<WorkflowState>,
109 pub transitions: Vec<WorkflowTransition>,
111 pub current_state: String,
113}
114
115impl StateMachine {
116 #[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 pub fn add_state(&mut self, state: WorkflowState) {
131 self.states.push(state);
132 }
133
134 pub fn add_transition(&mut self, t: WorkflowTransition) {
136 self.transitions.push(t);
137 }
138
139 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 #[must_use]
166 pub fn current(&self) -> &str {
167 &self.current_state
168 }
169
170 #[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 #[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 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 assert!(fsm.valid_triggers().is_empty());
314 }
315}