Skip to main content

ironflow_engine/fsm/
mod.rs

1//! Finite State Machines for workflow run and step lifecycles.
2//!
3//! Instead of ad-hoc `can_transition_to()` checks scattered across the codebase,
4//! these FSMs provide typed events, explicit transition tables, and a history
5//! of transitions for observability.
6//!
7//! # Architecture
8//!
9//! - [`RunFsm`] — Manages the lifecycle of a [`Run`](ironflow_store::entities::Run).
10//! - [`StepFsm`] — Manages the lifecycle of a [`Step`](ironflow_store::entities::Step).
11//! - [`RunEvent`] / [`StepEvent`] -- Typed events that trigger transitions.
12//! - [`Transition`] — A recorded state change with event and timestamp.
13
14mod run_fsm;
15mod step_fsm;
16
17pub use run_fsm::{RunEvent, RunFsm};
18pub use step_fsm::{StepEvent, StepFsm};
19
20use std::fmt;
21
22use chrono::{DateTime, Utc};
23use serde::{Deserialize, Serialize};
24
25/// A recorded state transition.
26///
27/// Captures the from/to states, the event that triggered the transition,
28/// and when it occurred.
29///
30/// # Examples
31///
32/// ```
33/// use ironflow_engine::fsm::Transition;
34///
35/// let t: Transition<String, String> = Transition {
36///     from: "Pending".to_string(),
37///     to: "Running".to_string(),
38///     event: "picked_up".to_string(),
39///     at: chrono::Utc::now(),
40/// };
41/// ```
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43pub struct Transition<S, E> {
44    /// State before the transition.
45    pub from: S,
46    /// State after the transition.
47    pub to: S,
48    /// Event that triggered the transition.
49    pub event: E,
50    /// When the transition occurred.
51    pub at: DateTime<Utc>,
52}
53
54/// Error returned when a transition is not allowed.
55///
56/// # Examples
57///
58/// ```
59/// use ironflow_engine::fsm::TransitionError;
60///
61/// let err: TransitionError<String, String> = TransitionError {
62///     from: "Completed".to_string(),
63///     event: "picked_up".to_string(),
64/// };
65/// assert!(err.to_string().contains("Completed"));
66/// ```
67#[derive(Debug, Clone)]
68pub struct TransitionError<S: fmt::Display, E: fmt::Display> {
69    /// Current state when the invalid transition was attempted.
70    pub from: S,
71    /// Event that was rejected.
72    pub event: E,
73}
74
75impl<S: fmt::Display, E: fmt::Display> fmt::Display for TransitionError<S, E> {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        write!(
78            f,
79            "invalid transition: event '{}' not allowed in state '{}'",
80            self.event, self.from
81        )
82    }
83}
84
85impl<S: fmt::Debug + fmt::Display, E: fmt::Debug + fmt::Display> std::error::Error
86    for TransitionError<S, E>
87{
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use chrono::Utc;
94
95    #[test]
96    fn transition_captures_state_change() {
97        let now = Utc::now();
98        let t: Transition<String, String> = Transition {
99            from: "Pending".to_string(),
100            to: "Running".to_string(),
101            event: "picked_up".to_string(),
102            at: now,
103        };
104
105        assert_eq!(t.from, "Pending");
106        assert_eq!(t.to, "Running");
107        assert_eq!(t.event, "picked_up");
108        assert_eq!(t.at, now);
109    }
110
111    #[test]
112    fn transition_with_different_types() {
113        let now = Utc::now();
114        #[derive(Debug, Clone, PartialEq)]
115        enum State {
116            Pending,
117            Running,
118            Completed,
119        }
120
121        #[derive(Debug, Clone)]
122        enum Event {
123            Started,
124            Finished,
125        }
126
127        let t = Transition {
128            from: State::Pending,
129            to: State::Running,
130            event: Event::Started,
131            at: now,
132        };
133
134        assert_eq!(t.from, State::Pending);
135        assert_eq!(t.to, State::Running);
136
137        let t2 = Transition {
138            from: State::Running,
139            to: State::Completed,
140            event: Event::Finished,
141            at: now,
142        };
143
144        assert_eq!(t2.from, State::Running);
145        assert_eq!(t2.to, State::Completed);
146    }
147
148    #[test]
149    fn transition_serializes_to_json() {
150        let now = Utc::now();
151        let t: Transition<String, String> = Transition {
152            from: "A".to_string(),
153            to: "B".to_string(),
154            event: "go".to_string(),
155            at: now,
156        };
157
158        let json = serde_json::to_value(&t).expect("serialize");
159        assert_eq!(json["from"], "A");
160        assert_eq!(json["to"], "B");
161        assert_eq!(json["event"], "go");
162    }
163
164    #[test]
165    fn transition_deserializes_from_json() {
166        let json_str =
167            r#"{"from":"pending","to":"running","event":"picked_up","at":"2025-01-01T00:00:00Z"}"#;
168        let t: Transition<String, String> = serde_json::from_str(json_str).expect("deserialize");
169
170        assert_eq!(t.from, "pending");
171        assert_eq!(t.to, "running");
172        assert_eq!(t.event, "picked_up");
173    }
174
175    #[test]
176    fn transition_error_formats_message() {
177        let err: TransitionError<String, String> = TransitionError {
178            from: "Completed".to_string(),
179            event: "picked_up".to_string(),
180        };
181
182        let msg = err.to_string();
183        assert!(msg.contains("Completed"));
184        assert!(msg.contains("picked_up"));
185        assert!(msg.contains("invalid transition"));
186    }
187
188    #[test]
189    fn transition_error_message_is_informative() {
190        let err: TransitionError<&str, &str> = TransitionError {
191            from: "Failed",
192            event: "retry",
193        };
194
195        let msg = err.to_string();
196        assert!(msg.contains("Failed"));
197        assert!(msg.contains("retry"));
198    }
199
200    #[test]
201    fn transition_error_implements_error_trait() {
202        use std::error::Error;
203
204        let err: TransitionError<String, String> = TransitionError {
205            from: "Running".to_string(),
206            event: "start".to_string(),
207        };
208
209        let error_ref: &dyn Error = &err;
210        assert!(!error_ref.to_string().is_empty());
211    }
212
213    #[test]
214    fn transition_error_clone() {
215        let err = TransitionError {
216            from: "Pending".to_string(),
217            event: "resume".to_string(),
218        };
219
220        let cloned = err.clone();
221        assert_eq!(cloned.from, err.from);
222        assert_eq!(cloned.event, err.event);
223    }
224
225    #[test]
226    fn transition_clone_preserves_all_fields() {
227        let now = Utc::now();
228        let t1 = Transition {
229            from: "A".to_string(),
230            to: "B".to_string(),
231            event: "E".to_string(),
232            at: now,
233        };
234
235        let t2 = t1.clone();
236        assert_eq!(t1, t2);
237        assert_eq!(t1.at, t2.at);
238    }
239
240    #[test]
241    fn transition_debug_output_contains_fields() {
242        let t: Transition<String, String> = Transition {
243            from: "x".to_string(),
244            to: "y".to_string(),
245            event: "z".to_string(),
246            at: Utc::now(),
247        };
248
249        let debug = format!("{:?}", t);
250        assert!(debug.contains("x"));
251        assert!(debug.contains("y"));
252        assert!(debug.contains("z"));
253    }
254}