use parking_lot::Mutex;
use rskit_errors::{AppError, AppResult, ErrorCode};
use std::collections::HashMap;
use std::hash::Hash;
use std::sync::Arc;
use std::time::SystemTime;
pub trait State: Clone + Send + Sync + 'static {}
impl<T> State for T where T: Clone + Send + Sync + 'static {}
type Guard<S, C> = Arc<dyn Fn(&S, &C) -> AppResult<()> + Send + Sync>;
type Action<S, C> = Arc<dyn Fn(&S, &S, &C) -> AppResult<()> + Send + Sync>;
pub struct Transition<S, C> {
name: String,
from: Option<S>,
to: S,
guard: Option<Guard<S, C>>,
action: Option<Action<S, C>>,
}
impl<S, C> Transition<S, C>
where
S: State,
{
#[must_use]
pub fn new(name: impl Into<String>, to: S) -> Self {
Self {
name: name.into(),
from: None,
to,
guard: None,
action: None,
}
}
#[must_use]
pub fn from(mut self, state: S) -> Self {
self.from = Some(state);
self
}
#[must_use]
pub fn with_guard(
mut self,
guard: impl Fn(&S, &C) -> AppResult<()> + Send + Sync + 'static,
) -> Self {
self.guard = Some(Arc::new(guard));
self
}
#[must_use]
pub fn with_action(
mut self,
action: impl Fn(&S, &S, &C) -> AppResult<()> + Send + Sync + 'static,
) -> Self {
self.action = Some(Arc::new(action));
self
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
}
#[derive(Debug, Clone)]
pub struct StateSnapshot<S> {
pub state: S,
pub version: u64,
}
#[derive(Debug, Clone)]
pub struct AuditEntry<S, C> {
pub transition: String,
pub from: S,
pub to: S,
pub context: C,
pub version: u64,
pub recorded_at: SystemTime,
}
pub trait StatePersistence<S, C>: Send + Sync
where
S: State,
{
fn persist(&self, snapshot: &StateSnapshot<S>, audit: &AuditEntry<S, C>) -> AppResult<()>;
}
struct MachineState<S, C> {
current: S,
version: u64,
audit_log: Vec<AuditEntry<S, C>>,
}
pub struct StateMachine<S, C> {
state: Mutex<MachineState<S, C>>,
transitions: HashMap<String, Transition<S, C>>,
persistence: Vec<Arc<dyn StatePersistence<S, C>>>,
}
impl<S, C> StateMachine<S, C>
where
S: State + Eq + Hash,
C: Clone + Send + Sync + 'static,
{
#[must_use]
pub fn new(initial: S) -> Self {
Self {
state: Mutex::new(MachineState {
current: initial,
version: 0,
audit_log: Vec::new(),
}),
transitions: HashMap::new(),
persistence: Vec::new(),
}
}
#[must_use]
pub fn with_transition(mut self, transition: Transition<S, C>) -> Self {
self.transitions.insert(transition.name.clone(), transition);
self
}
#[must_use]
pub fn with_persistence(mut self, persistence: Arc<dyn StatePersistence<S, C>>) -> Self {
self.persistence.push(persistence);
self
}
#[must_use]
pub fn state(&self) -> S {
self.state.lock().current.clone()
}
#[must_use]
pub fn snapshot(&self) -> StateSnapshot<S> {
let state = self.state.lock();
StateSnapshot {
state: state.current.clone(),
version: state.version,
}
}
#[must_use]
pub fn audit_log(&self) -> Vec<AuditEntry<S, C>> {
self.state.lock().audit_log.clone()
}
pub fn apply(&self, name: &str, context: C) -> AppResult<StateSnapshot<S>> {
let transition = self.transitions.get(name).ok_or_else(|| {
AppError::new(
ErrorCode::InvalidInput,
format!("state transition '{name}' is not registered"),
)
})?;
let mut state = self.state.lock();
let from = state.current.clone();
if let Some(required) = &transition.from
&& required != &from
{
return Err(AppError::new(
ErrorCode::InvalidInput,
format!("transition '{name}' cannot be applied from current state"),
));
}
if let Some(guard) = &transition.guard {
guard(&from, &context)?;
}
let to = transition.to.clone();
let next_version = state.version + 1;
let snapshot = StateSnapshot {
state: to.clone(),
version: next_version,
};
if let Some(action) = &transition.action {
action(&from, &to, &context)?;
}
let audit = AuditEntry {
transition: transition.name.clone(),
from,
to: to.clone(),
context,
version: snapshot.version,
recorded_at: SystemTime::now(),
};
for persistence in &self.persistence {
persistence.persist(&snapshot, &audit)?;
}
state.current = to;
state.version = next_version;
state.audit_log.push(audit);
Ok(snapshot)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum OrderState {
Draft,
Submitted,
}
struct CountingPersistence(AtomicUsize);
struct FailingPersistence;
impl StatePersistence<OrderState, bool> for CountingPersistence {
fn persist(
&self,
_snapshot: &StateSnapshot<OrderState>,
_audit: &AuditEntry<OrderState, bool>,
) -> AppResult<()> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
impl StatePersistence<OrderState, bool> for FailingPersistence {
fn persist(
&self,
_snapshot: &StateSnapshot<OrderState>,
_audit: &AuditEntry<OrderState, bool>,
) -> AppResult<()> {
Err(AppError::new(ErrorCode::Internal, "persist failed"))
}
}
#[test]
fn guarded_transition_updates_state_and_audit() {
let persistence = Arc::new(CountingPersistence(AtomicUsize::new(0)));
let machine = StateMachine::new(OrderState::Draft)
.with_transition(
Transition::new("submit", OrderState::Submitted)
.from(OrderState::Draft)
.with_guard(|_, allowed| {
if *allowed {
Ok(())
} else {
Err(AppError::new(ErrorCode::InvalidInput, "not allowed"))
}
}),
)
.with_persistence(persistence.clone());
let snapshot = machine.apply("submit", true).unwrap();
assert_eq!(snapshot.state, OrderState::Submitted);
assert_eq!(snapshot.version, 1);
assert_eq!(machine.audit_log().len(), 1);
assert_eq!(persistence.0.load(Ordering::SeqCst), 1);
}
#[test]
fn action_failure_does_not_change_state_or_audit() {
let machine = StateMachine::new(OrderState::Draft).with_transition(
Transition::new("submit", OrderState::Submitted)
.from(OrderState::Draft)
.with_action(|_, _, _| Err(AppError::new(ErrorCode::Internal, "action failed"))),
);
let error = machine.apply("submit", true).unwrap_err();
assert_eq!(error.code(), ErrorCode::Internal);
assert_eq!(machine.state(), OrderState::Draft);
assert_eq!(machine.snapshot().version, 0);
assert!(machine.audit_log().is_empty());
}
#[test]
fn persistence_failure_does_not_change_state_or_audit() {
let machine = StateMachine::new(OrderState::Draft)
.with_transition(
Transition::new("submit", OrderState::Submitted).from(OrderState::Draft),
)
.with_persistence(Arc::new(FailingPersistence));
let error = machine.apply("submit", true).unwrap_err();
assert_eq!(error.code(), ErrorCode::Internal);
assert_eq!(machine.state(), OrderState::Draft);
assert_eq!(machine.snapshot().version, 0);
assert!(machine.audit_log().is_empty());
}
}