Skip to main content

solti_model/domain/
phase.rs

1//! # Task lifecycle phases.
2//!
3//! [`TaskPhase`] represents the current state of a task in the supervision lifecycle.
4
5use std::fmt;
6use std::str::FromStr;
7
8use serde::{Deserialize, Serialize};
9
10use crate::error::{ModelError, ModelResult};
11
12/// Current execution phase of a single task attempt.
13///
14/// Phases describe the state of the **current attempt**.
15///
16/// ## Also
17///
18/// - [`RestartPolicy`](crate::RestartPolicy) governs what happens after a terminal phase.
19/// - [`TaskStatus`](crate::TaskStatus) carries the current phase.
20/// - [`TaskPhase::is_terminal`] checks for final states.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23#[non_exhaustive]
24pub enum TaskPhase {
25    /// Task is queued or waiting to start.
26    Pending,
27    /// Task is currently executing.
28    Running,
29    /// Task completed successfully.
30    Succeeded,
31    /// Attempt failed with an error.
32    Failed,
33    /// Task exceeded its timeout limit.
34    Timeout,
35    /// Task was explicitly canceled.
36    Canceled,
37    /// Task exhausted its restart budget and will not retry.
38    Exhausted,
39}
40
41impl fmt::Display for TaskPhase {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        match self {
44            TaskPhase::Pending => f.write_str("pending"),
45            TaskPhase::Running => f.write_str("running"),
46            TaskPhase::Succeeded => f.write_str("succeeded"),
47            TaskPhase::Failed => f.write_str("failed"),
48            TaskPhase::Timeout => f.write_str("timeout"),
49            TaskPhase::Canceled => f.write_str("canceled"),
50            TaskPhase::Exhausted => f.write_str("exhausted"),
51        }
52    }
53}
54
55impl FromStr for TaskPhase {
56    type Err = ModelError;
57
58    /// Parse a phase name (case-insensitive, trimmed). Accepts the same camelCase form produced by [`fmt::Display`] / serde.
59    fn from_str(s: &str) -> ModelResult<Self> {
60        let trimmed = s.trim();
61        match trimmed.to_ascii_lowercase().as_str() {
62            "pending" => Ok(TaskPhase::Pending),
63            "running" => Ok(TaskPhase::Running),
64            "succeeded" => Ok(TaskPhase::Succeeded),
65            "failed" => Ok(TaskPhase::Failed),
66            "timeout" => Ok(TaskPhase::Timeout),
67            "canceled" => Ok(TaskPhase::Canceled),
68            "exhausted" => Ok(TaskPhase::Exhausted),
69            _ => Err(ModelError::UnknownTaskPhase(trimmed.to_string())),
70        }
71    }
72}
73
74impl TaskPhase {
75    /// Returns `true` if the current attempt has reached a final state.
76    ///
77    /// A terminal phase means this attempt will not transition further.
78    /// The supervisor may still start a **new** attempt based on the [`RestartPolicy`](crate::RestartPolicy).
79    #[inline]
80    pub fn is_terminal(&self) -> bool {
81        matches!(
82            self,
83            TaskPhase::Succeeded
84                | TaskPhase::Failed
85                | TaskPhase::Timeout
86                | TaskPhase::Canceled
87                | TaskPhase::Exhausted
88        )
89    }
90
91    /// Returns `true` if the task is still active (pending or running).
92    #[inline]
93    pub fn is_active(&self) -> bool {
94        matches!(self, TaskPhase::Pending | TaskPhase::Running)
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn terminal_states() {
104        assert!(TaskPhase::Succeeded.is_terminal());
105        assert!(TaskPhase::Failed.is_terminal());
106        assert!(TaskPhase::Timeout.is_terminal());
107        assert!(TaskPhase::Canceled.is_terminal());
108        assert!(TaskPhase::Exhausted.is_terminal());
109
110        assert!(!TaskPhase::Pending.is_terminal());
111        assert!(!TaskPhase::Running.is_terminal());
112    }
113
114    #[test]
115    fn active_states() {
116        assert!(TaskPhase::Pending.is_active());
117        assert!(TaskPhase::Running.is_active());
118
119        assert!(!TaskPhase::Succeeded.is_active());
120        assert!(!TaskPhase::Failed.is_active());
121    }
122
123    #[test]
124    fn serde_roundtrip() {
125        let status = TaskPhase::Running;
126        let json = serde_json::to_string(&status).unwrap();
127        assert_eq!(json, r#""running""#);
128
129        let back: TaskPhase = serde_json::from_str(&json).unwrap();
130        assert_eq!(back, status);
131    }
132
133    #[test]
134    fn from_str_all_variants() {
135        let cases = [
136            ("pending", TaskPhase::Pending),
137            ("running", TaskPhase::Running),
138            ("succeeded", TaskPhase::Succeeded),
139            ("failed", TaskPhase::Failed),
140            ("timeout", TaskPhase::Timeout),
141            ("canceled", TaskPhase::Canceled),
142            ("exhausted", TaskPhase::Exhausted),
143        ];
144        for (s, expected) in cases {
145            assert_eq!(s.parse::<TaskPhase>().unwrap(), expected);
146        }
147    }
148
149    #[test]
150    fn from_str_is_case_insensitive_and_trims() {
151        assert_eq!("RUNNING".parse::<TaskPhase>().unwrap(), TaskPhase::Running);
152        assert_eq!(
153            "  Succeeded  ".parse::<TaskPhase>().unwrap(),
154            TaskPhase::Succeeded
155        );
156    }
157
158    #[test]
159    fn from_str_roundtrips_display() {
160        for phase in [
161            TaskPhase::Pending,
162            TaskPhase::Running,
163            TaskPhase::Succeeded,
164            TaskPhase::Failed,
165            TaskPhase::Timeout,
166            TaskPhase::Canceled,
167            TaskPhase::Exhausted,
168        ] {
169            let rendered = phase.to_string();
170            assert_eq!(rendered.parse::<TaskPhase>().unwrap(), phase);
171        }
172    }
173
174    #[test]
175    fn from_str_unknown_errors() {
176        let err = "bogus".parse::<TaskPhase>().unwrap_err();
177        assert!(matches!(err, ModelError::UnknownTaskPhase(_)));
178    }
179}