backbone_bucket/domain/state_machine/
conversion_job_state_machine.rs1use serde::{Deserialize, Serialize};
6use std::str::FromStr;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum ConversionJobState {
12 Pending,
14 Processing,
15 Completed,
17 Failed,
19 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 pub fn is_initial(&self) -> bool {
59 matches!(self, Self::Pending)
60 }
61
62 pub fn is_final(&self) -> bool {
64 matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
65 }
66
67 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum ConversionJobTransition {
83 Start,
85 UpdateProgress,
87 Complete,
89 Fail,
91 CancelPending,
93 CancelRunning,
95 Retry,
97 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 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 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 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#[derive(Debug, Clone)]
182pub struct ConversionJobStateMachine {
183 current_state: ConversionJobState,
184}
185
186impl ConversionJobStateMachine {
187 pub fn new() -> Self {
189 Self {
190 current_state: ConversionJobState::default(),
191 }
192 }
193
194 pub fn from_state(state: ConversionJobState) -> Self {
196 Self { current_state: state }
197 }
198
199 pub fn current_state(&self) -> ConversionJobState {
201 self.current_state
202 }
203
204 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 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; }
233
234 allowed_roles.iter().any(|r| *r == role || *r == "*")
235 }
236
237 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 pub fn transition_with_role(&mut self, transition: ConversionJobTransition, role: &str) -> Result<ConversionJobState, StateMachineError> {
252 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 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 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 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 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 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 assert!(!available.is_empty() || sm.current_state().is_final());
352 }
353}