1use sysknife_types::JobState;
2
3#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
4pub enum JobError {
5 #[error("invalid transition from {from:?} to {to:?}")]
6 InvalidTransition { from: JobState, to: JobState },
7}
8
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct JobStateMachine {
11 job_id: String,
12 state: JobState,
13}
14
15impl JobStateMachine {
16 pub fn new(job_id: impl Into<String>) -> Self {
17 Self {
18 job_id: job_id.into(),
19 state: JobState::Queued,
20 }
21 }
22
23 pub fn job_id(&self) -> &str {
24 &self.job_id
25 }
26
27 pub fn state(&self) -> JobState {
28 self.state
29 }
30
31 pub fn transition_to(&mut self, next: JobState) -> Result<(), JobError> {
32 if allowed_transition(&self.state, &next) {
33 self.state = next;
34 Ok(())
35 } else {
36 Err(JobError::InvalidTransition {
37 from: self.state,
38 to: next,
39 })
40 }
41 }
42
43 pub fn cancel(&mut self) -> Result<(), JobError> {
44 self.transition_to(JobState::Canceled)
45 }
46
47 pub fn needs_reboot(&mut self) -> Result<(), JobError> {
48 self.transition_to(JobState::NeedsReboot)
49 }
50
51 pub fn is_terminal(&self) -> bool {
52 matches!(
53 self.state,
54 JobState::Succeeded
55 | JobState::Failed
56 | JobState::Canceled
57 | JobState::RolledBack
58 | JobState::NeedsReboot
59 )
60 }
61}
62
63pub fn allowed_transition(current: &JobState, next: &JobState) -> bool {
64 matches!(
65 (current, next),
66 (JobState::Queued, JobState::Running)
67 | (JobState::Queued, JobState::Canceled)
68 | (JobState::Running, JobState::Succeeded)
69 | (JobState::Running, JobState::Failed)
70 | (JobState::Running, JobState::Canceled)
71 | (JobState::Running, JobState::RolledBack)
72 | (JobState::Running, JobState::NeedsReboot)
73 )
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79 use sysknife_types::JobState;
80
81 type ValidTransitionCase = (&'static str, fn() -> JobStateMachine, JobState);
83 type InvalidTransitionCase = (&'static str, fn() -> JobStateMachine, JobState, JobState);
84
85 #[test]
89 fn new_starts_in_queued() {
90 let m = JobStateMachine::new("j1");
91 assert_eq!(m.state(), JobState::Queued);
92 assert_eq!(m.job_id(), "j1");
93 }
94
95 #[test]
99 fn valid_transitions() {
100 let cases: &[ValidTransitionCase] = &[
103 (
105 "Queued->Running",
106 || JobStateMachine::new("j"),
107 JobState::Running,
108 ),
109 (
111 "Queued->Canceled",
112 || JobStateMachine::new("j"),
113 JobState::Canceled,
114 ),
115 (
117 "Running->Succeeded",
118 || {
119 let mut m = JobStateMachine::new("j");
120 m.transition_to(JobState::Running).unwrap();
121 m
122 },
123 JobState::Succeeded,
124 ),
125 (
127 "Running->Failed",
128 || {
129 let mut m = JobStateMachine::new("j");
130 m.transition_to(JobState::Running).unwrap();
131 m
132 },
133 JobState::Failed,
134 ),
135 (
137 "Running->Canceled",
138 || {
139 let mut m = JobStateMachine::new("j");
140 m.transition_to(JobState::Running).unwrap();
141 m
142 },
143 JobState::Canceled,
144 ),
145 (
147 "Running->RolledBack",
148 || {
149 let mut m = JobStateMachine::new("j");
150 m.transition_to(JobState::Running).unwrap();
151 m
152 },
153 JobState::RolledBack,
154 ),
155 (
157 "Running->NeedsReboot",
158 || {
159 let mut m = JobStateMachine::new("j");
160 m.transition_to(JobState::Running).unwrap();
161 m
162 },
163 JobState::NeedsReboot,
164 ),
165 ];
166
167 for (label, setup, to) in cases {
168 let mut m = setup();
169 let result = m.transition_to(*to);
170 assert!(result.is_ok(), "{label}: expected Ok(()), got {result:?}");
171 assert_eq!(m.state(), *to, "{label}: state after transition");
172 }
173 }
174
175 #[test]
179 fn invalid_transitions_return_error() {
180 let cases: &[InvalidTransitionCase] = &[
181 (
183 "Queued->Succeeded",
184 || JobStateMachine::new("j"),
185 JobState::Queued,
186 JobState::Succeeded,
187 ),
188 (
190 "Queued->Failed",
191 || JobStateMachine::new("j"),
192 JobState::Queued,
193 JobState::Failed,
194 ),
195 (
197 "Running->Queued",
198 || {
199 let mut m = JobStateMachine::new("j");
200 m.transition_to(JobState::Running).unwrap();
201 m
202 },
203 JobState::Running,
204 JobState::Queued,
205 ),
206 (
208 "Succeeded->Running",
209 || {
210 let mut m = JobStateMachine::new("j");
211 m.transition_to(JobState::Running).unwrap();
212 m.transition_to(JobState::Succeeded).unwrap();
213 m
214 },
215 JobState::Succeeded,
216 JobState::Running,
217 ),
218 (
220 "Failed->Running",
221 || {
222 let mut m = JobStateMachine::new("j");
223 m.transition_to(JobState::Running).unwrap();
224 m.transition_to(JobState::Failed).unwrap();
225 m
226 },
227 JobState::Failed,
228 JobState::Running,
229 ),
230 (
232 "Canceled->Running",
233 || {
234 let mut m = JobStateMachine::new("j");
235 m.transition_to(JobState::Canceled).unwrap();
236 m
237 },
238 JobState::Canceled,
239 JobState::Running,
240 ),
241 (
243 "RolledBack->Running",
244 || {
245 let mut m = JobStateMachine::new("j");
246 m.transition_to(JobState::Running).unwrap();
247 m.transition_to(JobState::RolledBack).unwrap();
248 m
249 },
250 JobState::RolledBack,
251 JobState::Running,
252 ),
253 (
255 "NeedsReboot->Succeeded",
256 || {
257 let mut m = JobStateMachine::new("j");
258 m.transition_to(JobState::Running).unwrap();
259 m.transition_to(JobState::NeedsReboot).unwrap();
260 m
261 },
262 JobState::NeedsReboot,
263 JobState::Succeeded,
264 ),
265 ];
266
267 for (label, setup, expected_from, to) in cases {
268 let mut m = setup();
269 let result = m.transition_to(*to);
270 match result {
271 Err(JobError::InvalidTransition { from, to: err_to }) => {
272 assert_eq!(from, *expected_from, "{label}: wrong `from` in error");
273 assert_eq!(err_to, *to, "{label}: wrong `to` in error");
274 }
275 other => panic!("{label}: expected Err(InvalidTransition), got {other:?}"),
276 }
277 }
278 }
279
280 #[test]
284 fn is_terminal_for_all_states() {
285 let cases: &[(JobState, bool)] = &[
286 (JobState::Queued, false),
287 (JobState::Running, false),
288 (JobState::Succeeded, true),
289 (JobState::Failed, true),
290 (JobState::Canceled, true),
291 (JobState::RolledBack, true),
292 (JobState::NeedsReboot, true),
293 ];
294
295 for (state, expected) in cases {
296 let m = machine_at(*state);
299 assert_eq!(m.is_terminal(), *expected, "is_terminal() for {state:?}");
300 }
301 }
302
303 #[test]
307 fn cancel_from_queued() {
308 let mut m = JobStateMachine::new("j");
309 assert_eq!(m.cancel(), Ok(()));
310 assert_eq!(m.state(), JobState::Canceled);
311 }
312
313 #[test]
317 fn cancel_from_running() {
318 let mut m = JobStateMachine::new("j");
319 m.transition_to(JobState::Running).unwrap();
320 assert_eq!(m.cancel(), Ok(()));
321 assert_eq!(m.state(), JobState::Canceled);
322 }
323
324 #[test]
328 fn needs_reboot_from_running() {
329 let mut m = JobStateMachine::new("j");
330 m.transition_to(JobState::Running).unwrap();
331 assert_eq!(m.needs_reboot(), Ok(()));
332 assert_eq!(m.state(), JobState::NeedsReboot);
333 }
334
335 #[test]
339 fn cancel_from_terminal_fails() {
340 let mut m = JobStateMachine::new("j");
341 m.transition_to(JobState::Canceled).unwrap();
342 let result = m.cancel();
343 assert!(
344 matches!(result, Err(JobError::InvalidTransition { .. })),
345 "expected Err(InvalidTransition), got {result:?}"
346 );
347 }
348
349 #[test]
353 fn error_message_includes_states() {
354 let err = JobError::InvalidTransition {
355 from: JobState::Queued,
356 to: JobState::Succeeded,
357 };
358 let msg = err.to_string();
359 assert!(
360 msg.contains("Queued"),
361 "error message missing 'Queued': {msg:?}"
362 );
363 assert!(
364 msg.contains("Succeeded"),
365 "error message missing 'Succeeded': {msg:?}"
366 );
367 }
368
369 #[test]
373 fn allowed_transition_free_fn_matches_machine() {
374 assert!(
375 allowed_transition(&JobState::Queued, &JobState::Running),
376 "Queued->Running should be allowed"
377 );
378 assert!(
379 !allowed_transition(&JobState::Succeeded, &JobState::Running),
380 "Succeeded->Running should not be allowed"
381 );
382 }
383
384 fn machine_at(target: JobState) -> JobStateMachine {
388 let mut m = JobStateMachine::new("j");
389 match target {
390 JobState::Queued => {}
391 JobState::Running => {
392 m.transition_to(JobState::Running).unwrap();
393 }
394 JobState::Succeeded => {
395 m.transition_to(JobState::Running).unwrap();
396 m.transition_to(JobState::Succeeded).unwrap();
397 }
398 JobState::Failed => {
399 m.transition_to(JobState::Running).unwrap();
400 m.transition_to(JobState::Failed).unwrap();
401 }
402 JobState::Canceled => {
403 m.transition_to(JobState::Canceled).unwrap();
404 }
405 JobState::RolledBack => {
406 m.transition_to(JobState::Running).unwrap();
407 m.transition_to(JobState::RolledBack).unwrap();
408 }
409 JobState::NeedsReboot => {
410 m.transition_to(JobState::Running).unwrap();
411 m.transition_to(JobState::NeedsReboot).unwrap();
412 }
413 }
414 m
415 }
416}