Skip to main content

backbone_bucket/domain/state_machine/
conversion_job_state_machine.rs

1//! State machine for ConversionJob workflow
2//!
3//! Generated by metaphor-schema
4
5use serde::{Deserialize, Serialize};
6use std::str::FromStr;
7
8/// States for ConversionJob workflow
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum ConversionJobState {
12    /// Initial state
13    Pending,
14    Processing,
15    /// Final state
16    Completed,
17    /// Final state
18    Failed,
19    /// Final state
20    Cancelled,
21}
22
23impl Default for ConversionJobState {
24    fn default() -> Self {
25        Self::Pending
26    }
27}
28
29impl std::fmt::Display for ConversionJobState {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            Self::Pending => write!(f, "pending"),
33            Self::Processing => write!(f, "processing"),
34            Self::Completed => write!(f, "completed"),
35            Self::Failed => write!(f, "failed"),
36            Self::Cancelled => write!(f, "cancelled"),
37        }
38    }
39}
40
41impl FromStr for ConversionJobState {
42    type Err = StateMachineError;
43
44    fn from_str(s: &str) -> Result<Self, Self::Err> {
45        match s.to_lowercase().as_str() {
46            "pending" => Ok(Self::Pending),
47            "processing" => Ok(Self::Processing),
48            "completed" => Ok(Self::Completed),
49            "failed" => Ok(Self::Failed),
50            "cancelled" => Ok(Self::Cancelled),
51            _ => Err(StateMachineError::InvalidState(s.to_string())),
52        }
53    }
54}
55
56impl ConversionJobState {
57    /// Check if this is the initial state
58    pub fn is_initial(&self) -> bool {
59        matches!(self, Self::Pending)
60    }
61
62    /// Check if this is a final state
63    pub fn is_final(&self) -> bool {
64        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
65    }
66
67    /// Get all possible states
68    pub fn all() -> Vec<Self> {
69        vec![
70            Self::Pending,
71            Self::Processing,
72            Self::Completed,
73            Self::Failed,
74            Self::Cancelled,
75        ]
76    }
77}
78
79/// Transitions for ConversionJob workflow
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum ConversionJobTransition {
83    /// pending -> processing
84    Start,
85    /// processing -> processing
86    UpdateProgress,
87    /// processing -> completed
88    Complete,
89    /// processing -> failed
90    Fail,
91    /// pending -> cancelled
92    CancelPending,
93    /// processing -> cancelled
94    CancelRunning,
95    /// failed -> pending
96    Retry,
97    /// completed -> pending
98    Reconvert,
99}
100
101impl std::fmt::Display for ConversionJobTransition {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        match self {
104            Self::Start => write!(f, "start"),
105            Self::UpdateProgress => write!(f, "update_progress"),
106            Self::Complete => write!(f, "complete"),
107            Self::Fail => write!(f, "fail"),
108            Self::CancelPending => write!(f, "cancel_pending"),
109            Self::CancelRunning => write!(f, "cancel_running"),
110            Self::Retry => write!(f, "retry"),
111            Self::Reconvert => write!(f, "reconvert"),
112        }
113    }
114}
115
116impl FromStr for ConversionJobTransition {
117    type Err = StateMachineError;
118
119    fn from_str(s: &str) -> Result<Self, Self::Err> {
120        match s.to_lowercase().as_str() {
121            "start" => Ok(Self::Start),
122            "update_progress" => Ok(Self::UpdateProgress),
123            "complete" => Ok(Self::Complete),
124            "fail" => Ok(Self::Fail),
125            "cancel_pending" => Ok(Self::CancelPending),
126            "cancel_running" => Ok(Self::CancelRunning),
127            "retry" => Ok(Self::Retry),
128            "reconvert" => Ok(Self::Reconvert),
129            _ => Err(StateMachineError::InvalidTransition(s.to_string())),
130        }
131    }
132}
133
134impl ConversionJobTransition {
135    /// Get the target state of this transition
136    pub fn target_state(&self) -> ConversionJobState {
137        match self {
138            Self::Start => ConversionJobState::Processing,
139            Self::UpdateProgress => ConversionJobState::Processing,
140            Self::Complete => ConversionJobState::Completed,
141            Self::Fail => ConversionJobState::Failed,
142            Self::CancelPending => ConversionJobState::Cancelled,
143            Self::CancelRunning => ConversionJobState::Cancelled,
144            Self::Retry => ConversionJobState::Pending,
145            Self::Reconvert => ConversionJobState::Pending,
146        }
147    }
148
149    /// Get all transitions
150    pub fn all() -> Vec<Self> {
151        vec![
152            Self::Start,
153            Self::UpdateProgress,
154            Self::Complete,
155            Self::Fail,
156            Self::CancelPending,
157            Self::CancelRunning,
158            Self::Retry,
159            Self::Reconvert,
160        ]
161    }
162
163    /// Get allowed roles for this transition
164    pub fn allowed_roles(&self) -> &'static [&'static str] {
165        match self {
166            Self::Start => &["system"],
167            Self::UpdateProgress => &["system"],
168            Self::Complete => &["system"],
169            Self::Fail => &["system"],
170            Self::CancelPending => &["owner", "admin"],
171            Self::CancelRunning => &["owner", "admin"],
172            Self::Retry => &["system", "owner"],
173            Self::Reconvert => &["owner", "admin"],
174        }
175    }
176}
177
178use super::StateMachineError;
179
180/// State machine for ConversionJob workflow
181#[derive(Debug, Clone)]
182pub struct ConversionJobStateMachine {
183    current_state: ConversionJobState,
184}
185
186impl ConversionJobStateMachine {
187    /// Create a new state machine with initial state
188    pub fn new() -> Self {
189        Self {
190            current_state: ConversionJobState::default(),
191        }
192    }
193
194    /// Create from an existing state
195    pub fn from_state(state: ConversionJobState) -> Self {
196        Self { current_state: state }
197    }
198
199    /// Get the current state
200    pub fn current_state(&self) -> ConversionJobState {
201        self.current_state
202    }
203
204    /// Check if a transition is allowed from the current state
205    pub fn can_transition(&self, transition: ConversionJobTransition) -> bool {
206        if matches!(self.current_state, ConversionJobState::Cancelled) {
207            return false;
208        }
209
210        match (self.current_state, transition) {
211            (ConversionJobState::Pending, ConversionJobTransition::Start) => true,
212            (ConversionJobState::Processing, ConversionJobTransition::UpdateProgress) => true,
213            (ConversionJobState::Processing, ConversionJobTransition::Complete) => true,
214            (ConversionJobState::Processing, ConversionJobTransition::Fail) => true,
215            (ConversionJobState::Pending, ConversionJobTransition::CancelPending) => true,
216            (ConversionJobState::Processing, ConversionJobTransition::CancelRunning) => true,
217            (ConversionJobState::Failed, ConversionJobTransition::Retry) => true,
218            (ConversionJobState::Completed, ConversionJobTransition::Reconvert) => true,
219            _ => false,
220        }
221    }
222
223    /// Check if a transition is allowed for a given role
224    pub fn can_transition_with_role(&self, transition: ConversionJobTransition, role: &str) -> bool {
225        if !self.can_transition(transition) {
226            return false;
227        }
228
229        let allowed_roles = transition.allowed_roles();
230        if allowed_roles.is_empty() {
231            return true; // No role restriction
232        }
233
234        allowed_roles.iter().any(|r| *r == role || *r == "*")
235    }
236
237    /// Apply a transition, returning the new state
238    pub fn transition(&mut self, transition: ConversionJobTransition) -> Result<ConversionJobState, StateMachineError> {
239        if !self.can_transition(transition) {
240            return Err(StateMachineError::TransitionNotAllowed {
241                transition: transition.to_string(),
242                from: self.current_state.to_string(),
243            });
244        }
245
246        self.current_state = transition.target_state();
247        Ok(self.current_state)
248    }
249
250    /// Apply a transition with role check
251    pub fn transition_with_role(&mut self, transition: ConversionJobTransition, role: &str) -> Result<ConversionJobState, StateMachineError> {
252        // Check basic transition validity first
253        if !self.can_transition(transition) {
254            return Err(StateMachineError::TransitionNotAllowed {
255                transition: transition.to_string(),
256                from: self.current_state.to_string(),
257            });
258        }
259
260        // Check role authorization
261        if !self.can_transition_with_role(transition, role) {
262            return Err(StateMachineError::RoleNotAuthorized {
263                role: role.to_string(),
264                transition: transition.to_string(),
265            });
266        }
267
268        self.current_state = transition.target_state();
269        Ok(self.current_state)
270    }
271
272    /// Get all available transitions from the current state
273    pub fn available_transitions(&self) -> Vec<ConversionJobTransition> {
274        ConversionJobTransition::all()
275            .into_iter()
276            .filter(|t| self.can_transition(*t))
277            .collect()
278    }
279
280    /// Get all available transitions for a given role
281    pub fn available_transitions_for_role(&self, role: &str) -> Vec<ConversionJobTransition> {
282        ConversionJobTransition::all()
283            .into_iter()
284            .filter(|t| self.can_transition_with_role(*t, role))
285            .collect()
286    }
287
288    /// Attempt to transition directly to a target state.
289    ///
290    /// Finds any transition that leads to the target state and applies it.
291    /// Returns Err if no valid transition leads from current state to target.
292    pub fn transition_to_state(&mut self, target: ConversionJobState) -> Result<ConversionJobState, StateMachineError> {
293        let valid = ConversionJobTransition::all().into_iter()
294            .filter(|t| self.can_transition(*t))
295            .find(|t| t.target_state() == target);
296        match valid {
297            Some(t) => self.transition(t),
298            None => Err(StateMachineError::TransitionNotAllowed {
299                transition: target.to_string(),
300                from: self.current_state.to_string(),
301            }),
302        }
303    }
304}
305
306impl Default for ConversionJobStateMachine {
307    fn default() -> Self {
308        Self::new()
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn test_initial_state() {
318        let sm = ConversionJobStateMachine::new();
319        assert_eq!(sm.current_state(), ConversionJobState::Pending);
320        assert!(sm.current_state().is_initial());
321    }
322
323    #[test]
324    fn test_valid_transition() {
325        let mut sm = ConversionJobStateMachine::from_state(ConversionJobState::Pending);
326        assert!(sm.can_transition(ConversionJobTransition::Start));
327        let result = sm.transition(ConversionJobTransition::Start);
328        assert!(result.is_ok());
329        assert_eq!(sm.current_state(), ConversionJobState::Processing);
330    }
331
332    #[test]
333    fn test_invalid_transition() {
334        let mut sm = ConversionJobStateMachine::from_state(ConversionJobState::Pending);
335        // UpdateProgress is not valid from Pending state
336        let result = sm.transition(ConversionJobTransition::UpdateProgress);
337        assert!(result.is_err());
338    }
339
340    #[test]
341    fn test_state_parsing() {
342        let state: ConversionJobState = "pending".parse().unwrap();
343        assert_eq!(state, ConversionJobState::Pending);
344    }
345
346    #[test]
347    fn test_available_transitions() {
348        let sm = ConversionJobStateMachine::new();
349        let available = sm.available_transitions();
350        // Should have at least some transitions available from initial state
351        assert!(!available.is_empty() || sm.current_state().is_final());
352    }
353}