Skip to main content

sysknife_daemon/
jobs.rs

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    // Test-table aliases — clippy flags the inline tuple type as too complex.
82    type ValidTransitionCase = (&'static str, fn() -> JobStateMachine, JobState);
83    type InvalidTransitionCase = (&'static str, fn() -> JobStateMachine, JobState, JobState);
84
85    // -------------------------------------------------------------------------
86    // 1. new_starts_in_queued
87    // -------------------------------------------------------------------------
88    #[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    // -------------------------------------------------------------------------
96    // 2. valid_transitions
97    // -------------------------------------------------------------------------
98    #[test]
99    fn valid_transitions() {
100        // (label, setup_fn, from, to)
101        // Each closure receives a fresh machine and puts it into `from` state.
102        let cases: &[ValidTransitionCase] = &[
103            // Queued → Running
104            (
105                "Queued->Running",
106                || JobStateMachine::new("j"),
107                JobState::Running,
108            ),
109            // Queued → Canceled
110            (
111                "Queued->Canceled",
112                || JobStateMachine::new("j"),
113                JobState::Canceled,
114            ),
115            // Running → Succeeded  (must go Queued→Running first)
116            (
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            // Running → Failed
126            (
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            // Running → Canceled
136            (
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            // Running → RolledBack
146            (
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            // Running → NeedsReboot
156            (
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    // -------------------------------------------------------------------------
176    // 3. invalid_transitions_return_error
177    // -------------------------------------------------------------------------
178    #[test]
179    fn invalid_transitions_return_error() {
180        let cases: &[InvalidTransitionCase] = &[
181            // Queued → Succeeded (skip Running)
182            (
183                "Queued->Succeeded",
184                || JobStateMachine::new("j"),
185                JobState::Queued,
186                JobState::Succeeded,
187            ),
188            // Queued → Failed (skip Running)
189            (
190                "Queued->Failed",
191                || JobStateMachine::new("j"),
192                JobState::Queued,
193                JobState::Failed,
194            ),
195            // Running → Queued (no going back)
196            (
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            // Succeeded → Running (terminal)
207            (
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            // Failed → Running (terminal)
219            (
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            // Canceled → Running (terminal)
231            (
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            // RolledBack → Running (terminal)
242            (
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            // NeedsReboot → Succeeded (terminal)
254            (
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    // -------------------------------------------------------------------------
281    // 4. is_terminal_for_all_states
282    // -------------------------------------------------------------------------
283    #[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            // Build a machine whose internal state equals `state` by driving
297            // valid transitions from Queued.
298            let m = machine_at(*state);
299            assert_eq!(m.is_terminal(), *expected, "is_terminal() for {state:?}");
300        }
301    }
302
303    // -------------------------------------------------------------------------
304    // 5. cancel_from_queued
305    // -------------------------------------------------------------------------
306    #[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    // -------------------------------------------------------------------------
314    // 6. cancel_from_running
315    // -------------------------------------------------------------------------
316    #[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    // -------------------------------------------------------------------------
325    // 7. needs_reboot_from_running
326    // -------------------------------------------------------------------------
327    #[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    // -------------------------------------------------------------------------
336    // 8. cancel_from_terminal_fails
337    // -------------------------------------------------------------------------
338    #[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    // -------------------------------------------------------------------------
350    // 9. error_message_includes_states
351    // -------------------------------------------------------------------------
352    #[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    // -------------------------------------------------------------------------
370    // 10. allowed_transition_free_fn_matches_machine
371    // -------------------------------------------------------------------------
372    #[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    // -------------------------------------------------------------------------
385    // Helper: drive a fresh machine to any reachable state.
386    // -------------------------------------------------------------------------
387    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}