use serde::{Deserialize, Serialize};
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConversionJobState {
Pending,
Processing,
Completed,
Failed,
Cancelled,
}
impl Default for ConversionJobState {
fn default() -> Self {
Self::Pending
}
}
impl std::fmt::Display for ConversionJobState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Pending => write!(f, "pending"),
Self::Processing => write!(f, "processing"),
Self::Completed => write!(f, "completed"),
Self::Failed => write!(f, "failed"),
Self::Cancelled => write!(f, "cancelled"),
}
}
}
impl FromStr for ConversionJobState {
type Err = StateMachineError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"pending" => Ok(Self::Pending),
"processing" => Ok(Self::Processing),
"completed" => Ok(Self::Completed),
"failed" => Ok(Self::Failed),
"cancelled" => Ok(Self::Cancelled),
_ => Err(StateMachineError::InvalidState(s.to_string())),
}
}
}
impl ConversionJobState {
pub fn is_initial(&self) -> bool {
matches!(self, Self::Pending)
}
pub fn is_final(&self) -> bool {
matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
}
pub fn all() -> Vec<Self> {
vec![
Self::Pending,
Self::Processing,
Self::Completed,
Self::Failed,
Self::Cancelled,
]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConversionJobTransition {
Start,
UpdateProgress,
Complete,
Fail,
CancelPending,
CancelRunning,
Retry,
Reconvert,
}
impl std::fmt::Display for ConversionJobTransition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Start => write!(f, "start"),
Self::UpdateProgress => write!(f, "update_progress"),
Self::Complete => write!(f, "complete"),
Self::Fail => write!(f, "fail"),
Self::CancelPending => write!(f, "cancel_pending"),
Self::CancelRunning => write!(f, "cancel_running"),
Self::Retry => write!(f, "retry"),
Self::Reconvert => write!(f, "reconvert"),
}
}
}
impl FromStr for ConversionJobTransition {
type Err = StateMachineError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"start" => Ok(Self::Start),
"update_progress" => Ok(Self::UpdateProgress),
"complete" => Ok(Self::Complete),
"fail" => Ok(Self::Fail),
"cancel_pending" => Ok(Self::CancelPending),
"cancel_running" => Ok(Self::CancelRunning),
"retry" => Ok(Self::Retry),
"reconvert" => Ok(Self::Reconvert),
_ => Err(StateMachineError::InvalidTransition(s.to_string())),
}
}
}
impl ConversionJobTransition {
pub fn target_state(&self) -> ConversionJobState {
match self {
Self::Start => ConversionJobState::Processing,
Self::UpdateProgress => ConversionJobState::Processing,
Self::Complete => ConversionJobState::Completed,
Self::Fail => ConversionJobState::Failed,
Self::CancelPending => ConversionJobState::Cancelled,
Self::CancelRunning => ConversionJobState::Cancelled,
Self::Retry => ConversionJobState::Pending,
Self::Reconvert => ConversionJobState::Pending,
}
}
pub fn all() -> Vec<Self> {
vec![
Self::Start,
Self::UpdateProgress,
Self::Complete,
Self::Fail,
Self::CancelPending,
Self::CancelRunning,
Self::Retry,
Self::Reconvert,
]
}
pub fn allowed_roles(&self) -> &'static [&'static str] {
match self {
Self::Start => &["system"],
Self::UpdateProgress => &["system"],
Self::Complete => &["system"],
Self::Fail => &["system"],
Self::CancelPending => &["owner", "admin"],
Self::CancelRunning => &["owner", "admin"],
Self::Retry => &["system", "owner"],
Self::Reconvert => &["owner", "admin"],
}
}
}
use super::StateMachineError;
#[derive(Debug, Clone)]
pub struct ConversionJobStateMachine {
current_state: ConversionJobState,
}
impl ConversionJobStateMachine {
pub fn new() -> Self {
Self {
current_state: ConversionJobState::default(),
}
}
pub fn from_state(state: ConversionJobState) -> Self {
Self { current_state: state }
}
pub fn current_state(&self) -> ConversionJobState {
self.current_state
}
pub fn can_transition(&self, transition: ConversionJobTransition) -> bool {
if matches!(self.current_state, ConversionJobState::Cancelled) {
return false;
}
match (self.current_state, transition) {
(ConversionJobState::Pending, ConversionJobTransition::Start) => true,
(ConversionJobState::Processing, ConversionJobTransition::UpdateProgress) => true,
(ConversionJobState::Processing, ConversionJobTransition::Complete) => true,
(ConversionJobState::Processing, ConversionJobTransition::Fail) => true,
(ConversionJobState::Pending, ConversionJobTransition::CancelPending) => true,
(ConversionJobState::Processing, ConversionJobTransition::CancelRunning) => true,
(ConversionJobState::Failed, ConversionJobTransition::Retry) => true,
(ConversionJobState::Completed, ConversionJobTransition::Reconvert) => true,
_ => false,
}
}
pub fn can_transition_with_role(&self, transition: ConversionJobTransition, role: &str) -> bool {
if !self.can_transition(transition) {
return false;
}
let allowed_roles = transition.allowed_roles();
if allowed_roles.is_empty() {
return true; }
allowed_roles.iter().any(|r| *r == role || *r == "*")
}
pub fn transition(&mut self, transition: ConversionJobTransition) -> Result<ConversionJobState, StateMachineError> {
if !self.can_transition(transition) {
return Err(StateMachineError::TransitionNotAllowed {
transition: transition.to_string(),
from: self.current_state.to_string(),
});
}
self.current_state = transition.target_state();
Ok(self.current_state)
}
pub fn transition_with_role(&mut self, transition: ConversionJobTransition, role: &str) -> Result<ConversionJobState, StateMachineError> {
if !self.can_transition(transition) {
return Err(StateMachineError::TransitionNotAllowed {
transition: transition.to_string(),
from: self.current_state.to_string(),
});
}
if !self.can_transition_with_role(transition, role) {
return Err(StateMachineError::RoleNotAuthorized {
role: role.to_string(),
transition: transition.to_string(),
});
}
self.current_state = transition.target_state();
Ok(self.current_state)
}
pub fn available_transitions(&self) -> Vec<ConversionJobTransition> {
ConversionJobTransition::all()
.into_iter()
.filter(|t| self.can_transition(*t))
.collect()
}
pub fn available_transitions_for_role(&self, role: &str) -> Vec<ConversionJobTransition> {
ConversionJobTransition::all()
.into_iter()
.filter(|t| self.can_transition_with_role(*t, role))
.collect()
}
pub fn transition_to_state(&mut self, target: ConversionJobState) -> Result<ConversionJobState, StateMachineError> {
let valid = ConversionJobTransition::all().into_iter()
.filter(|t| self.can_transition(*t))
.find(|t| t.target_state() == target);
match valid {
Some(t) => self.transition(t),
None => Err(StateMachineError::TransitionNotAllowed {
transition: target.to_string(),
from: self.current_state.to_string(),
}),
}
}
}
impl Default for ConversionJobStateMachine {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_initial_state() {
let sm = ConversionJobStateMachine::new();
assert_eq!(sm.current_state(), ConversionJobState::Pending);
assert!(sm.current_state().is_initial());
}
#[test]
fn test_valid_transition() {
let mut sm = ConversionJobStateMachine::from_state(ConversionJobState::Pending);
assert!(sm.can_transition(ConversionJobTransition::Start));
let result = sm.transition(ConversionJobTransition::Start);
assert!(result.is_ok());
assert_eq!(sm.current_state(), ConversionJobState::Processing);
}
#[test]
fn test_invalid_transition() {
let mut sm = ConversionJobStateMachine::from_state(ConversionJobState::Pending);
let result = sm.transition(ConversionJobTransition::UpdateProgress);
assert!(result.is_err());
}
#[test]
fn test_state_parsing() {
let state: ConversionJobState = "pending".parse().unwrap();
assert_eq!(state, ConversionJobState::Pending);
}
#[test]
fn test_available_transitions() {
let sm = ConversionJobStateMachine::new();
let available = sm.available_transitions();
assert!(!available.is_empty() || sm.current_state().is_final());
}
}