Skip to main content

rskit_stateful/
machine.rs

1use parking_lot::Mutex;
2use rskit_errors::{AppError, AppResult, ErrorCode};
3use std::collections::HashMap;
4use std::hash::Hash;
5use std::sync::Arc;
6use std::time::SystemTime;
7
8/// Marker trait for states managed by [`StateMachine`].
9pub trait State: Clone + Send + Sync + 'static {}
10
11impl<T> State for T where T: Clone + Send + Sync + 'static {}
12
13type Guard<S, C> = Arc<dyn Fn(&S, &C) -> AppResult<()> + Send + Sync>;
14type Action<S, C> = Arc<dyn Fn(&S, &S, &C) -> AppResult<()> + Send + Sync>;
15
16/// A named transition from one state to another.
17pub struct Transition<S, C> {
18    name: String,
19    from: Option<S>,
20    to: S,
21    guard: Option<Guard<S, C>>,
22    action: Option<Action<S, C>>,
23}
24
25impl<S, C> Transition<S, C>
26where
27    S: State,
28{
29    /// Create a transition that may be applied from any current state.
30    #[must_use]
31    pub fn new(name: impl Into<String>, to: S) -> Self {
32        Self {
33            name: name.into(),
34            from: None,
35            to,
36            guard: None,
37            action: None,
38        }
39    }
40
41    /// Restrict this transition to a specific source state.
42    #[must_use]
43    pub fn from(mut self, state: S) -> Self {
44        self.from = Some(state);
45        self
46    }
47
48    /// Add a guard that must allow the transition.
49    #[must_use]
50    pub fn with_guard(
51        mut self,
52        guard: impl Fn(&S, &C) -> AppResult<()> + Send + Sync + 'static,
53    ) -> Self {
54        self.guard = Some(Arc::new(guard));
55        self
56    }
57
58    /// Add an action that runs after guard validation and before the transition commits.
59    ///
60    /// Returning an error aborts the transition without changing state or recording audit.
61    #[must_use]
62    pub fn with_action(
63        mut self,
64        action: impl Fn(&S, &S, &C) -> AppResult<()> + Send + Sync + 'static,
65    ) -> Self {
66        self.action = Some(Arc::new(action));
67        self
68    }
69
70    /// Transition name.
71    #[must_use]
72    pub fn name(&self) -> &str {
73        &self.name
74    }
75}
76
77/// Point-in-time state snapshot.
78#[derive(Debug, Clone)]
79pub struct StateSnapshot<S> {
80    /// Current state.
81    pub state: S,
82    /// Monotonic state version.
83    pub version: u64,
84}
85
86/// Audit record emitted for each successful transition.
87#[derive(Debug, Clone)]
88pub struct AuditEntry<S, C> {
89    /// Transition name.
90    pub transition: String,
91    /// Previous state.
92    pub from: S,
93    /// New state.
94    pub to: S,
95    /// Caller-supplied transition context.
96    pub context: C,
97    /// Version after the transition.
98    pub version: u64,
99    /// Wall-clock time when the transition was applied.
100    pub recorded_at: SystemTime,
101}
102
103/// Persistence hook for snapshots and audit entries.
104pub trait StatePersistence<S, C>: Send + Sync
105where
106    S: State,
107{
108    /// Persist a successful state transition.
109    fn persist(&self, snapshot: &StateSnapshot<S>, audit: &AuditEntry<S, C>) -> AppResult<()>;
110}
111
112struct MachineState<S, C> {
113    current: S,
114    version: u64,
115    audit_log: Vec<AuditEntry<S, C>>,
116}
117
118/// Typed state machine with guarded transitions, audit, and persistence hooks.
119pub struct StateMachine<S, C> {
120    state: Mutex<MachineState<S, C>>,
121    transitions: HashMap<String, Transition<S, C>>,
122    persistence: Vec<Arc<dyn StatePersistence<S, C>>>,
123}
124
125impl<S, C> StateMachine<S, C>
126where
127    S: State + Eq + Hash,
128    C: Clone + Send + Sync + 'static,
129{
130    /// Create a state machine with an initial state.
131    #[must_use]
132    pub fn new(initial: S) -> Self {
133        Self {
134            state: Mutex::new(MachineState {
135                current: initial,
136                version: 0,
137                audit_log: Vec::new(),
138            }),
139            transitions: HashMap::new(),
140            persistence: Vec::new(),
141        }
142    }
143
144    /// Register a transition.
145    #[must_use]
146    pub fn with_transition(mut self, transition: Transition<S, C>) -> Self {
147        self.transitions.insert(transition.name.clone(), transition);
148        self
149    }
150
151    /// Register a persistence hook.
152    #[must_use]
153    pub fn with_persistence(mut self, persistence: Arc<dyn StatePersistence<S, C>>) -> Self {
154        self.persistence.push(persistence);
155        self
156    }
157
158    /// Return the current state.
159    #[must_use]
160    pub fn state(&self) -> S {
161        self.state.lock().current.clone()
162    }
163
164    /// Return the current snapshot.
165    #[must_use]
166    pub fn snapshot(&self) -> StateSnapshot<S> {
167        let state = self.state.lock();
168        StateSnapshot {
169            state: state.current.clone(),
170            version: state.version,
171        }
172    }
173
174    /// Return a copy of the audit log.
175    #[must_use]
176    pub fn audit_log(&self) -> Vec<AuditEntry<S, C>> {
177        self.state.lock().audit_log.clone()
178    }
179
180    /// Apply a named transition.
181    pub fn apply(&self, name: &str, context: C) -> AppResult<StateSnapshot<S>> {
182        let transition = self.transitions.get(name).ok_or_else(|| {
183            AppError::new(
184                ErrorCode::InvalidInput,
185                format!("state transition '{name}' is not registered"),
186            )
187        })?;
188
189        let mut state = self.state.lock();
190        let from = state.current.clone();
191        if let Some(required) = &transition.from
192            && required != &from
193        {
194            return Err(AppError::new(
195                ErrorCode::InvalidInput,
196                format!("transition '{name}' cannot be applied from current state"),
197            ));
198        }
199        if let Some(guard) = &transition.guard {
200            guard(&from, &context)?;
201        }
202
203        let to = transition.to.clone();
204        let next_version = state.version + 1;
205        let snapshot = StateSnapshot {
206            state: to.clone(),
207            version: next_version,
208        };
209
210        if let Some(action) = &transition.action {
211            action(&from, &to, &context)?;
212        }
213
214        let audit = AuditEntry {
215            transition: transition.name.clone(),
216            from,
217            to: to.clone(),
218            context,
219            version: snapshot.version,
220            recorded_at: SystemTime::now(),
221        };
222
223        for persistence in &self.persistence {
224            persistence.persist(&snapshot, &audit)?;
225        }
226        state.current = to;
227        state.version = next_version;
228        state.audit_log.push(audit);
229
230        Ok(snapshot)
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use std::sync::atomic::{AtomicUsize, Ordering};
238
239    #[derive(Debug, Clone, PartialEq, Eq, Hash)]
240    enum OrderState {
241        Draft,
242        Submitted,
243    }
244
245    struct CountingPersistence(AtomicUsize);
246    struct FailingPersistence;
247
248    impl StatePersistence<OrderState, bool> for CountingPersistence {
249        fn persist(
250            &self,
251            _snapshot: &StateSnapshot<OrderState>,
252            _audit: &AuditEntry<OrderState, bool>,
253        ) -> AppResult<()> {
254            self.0.fetch_add(1, Ordering::SeqCst);
255            Ok(())
256        }
257    }
258
259    impl StatePersistence<OrderState, bool> for FailingPersistence {
260        fn persist(
261            &self,
262            _snapshot: &StateSnapshot<OrderState>,
263            _audit: &AuditEntry<OrderState, bool>,
264        ) -> AppResult<()> {
265            Err(AppError::new(ErrorCode::Internal, "persist failed"))
266        }
267    }
268
269    #[test]
270    fn guarded_transition_updates_state_and_audit() {
271        let persistence = Arc::new(CountingPersistence(AtomicUsize::new(0)));
272        let machine = StateMachine::new(OrderState::Draft)
273            .with_transition(
274                Transition::new("submit", OrderState::Submitted)
275                    .from(OrderState::Draft)
276                    .with_guard(|_, allowed| {
277                        if *allowed {
278                            Ok(())
279                        } else {
280                            Err(AppError::new(ErrorCode::InvalidInput, "not allowed"))
281                        }
282                    }),
283            )
284            .with_persistence(persistence.clone());
285
286        let snapshot = machine.apply("submit", true).unwrap();
287        assert_eq!(snapshot.state, OrderState::Submitted);
288        assert_eq!(snapshot.version, 1);
289        assert_eq!(machine.audit_log().len(), 1);
290        assert_eq!(persistence.0.load(Ordering::SeqCst), 1);
291    }
292
293    #[test]
294    fn action_failure_does_not_change_state_or_audit() {
295        let machine = StateMachine::new(OrderState::Draft).with_transition(
296            Transition::new("submit", OrderState::Submitted)
297                .from(OrderState::Draft)
298                .with_action(|_, _, _| Err(AppError::new(ErrorCode::Internal, "action failed"))),
299        );
300
301        let error = machine.apply("submit", true).unwrap_err();
302
303        assert_eq!(error.code(), ErrorCode::Internal);
304        assert_eq!(machine.state(), OrderState::Draft);
305        assert_eq!(machine.snapshot().version, 0);
306        assert!(machine.audit_log().is_empty());
307    }
308
309    #[test]
310    fn persistence_failure_does_not_change_state_or_audit() {
311        let machine = StateMachine::new(OrderState::Draft)
312            .with_transition(
313                Transition::new("submit", OrderState::Submitted).from(OrderState::Draft),
314            )
315            .with_persistence(Arc::new(FailingPersistence));
316
317        let error = machine.apply("submit", true).unwrap_err();
318
319        assert_eq!(error.code(), ErrorCode::Internal);
320        assert_eq!(machine.state(), OrderState::Draft);
321        assert_eq!(machine.snapshot().version, 0);
322        assert!(machine.audit_log().is_empty());
323    }
324}