Skip to main content

solti_model/domain/
phase.rs

1//! # Task phase
2//!
3//! [`TaskPhase`] is the current lifecycle state recorded in [`TaskStatus`](crate::TaskStatus).
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 task.
13///
14/// Phases describe the state visible on [`TaskStatus`](crate::TaskStatus).
15///
16/// ## Example
17///
18/// ```
19/// use solti_model::TaskPhase;
20///
21/// let phase: TaskPhase = "running".parse().unwrap();
22/// assert!(phase.is_active());
23/// assert_eq!(phase.to_string(), "running");
24/// ```
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27#[serde(rename_all = "camelCase")]
28#[non_exhaustive]
29pub enum TaskPhase {
30    /// Desired state is waiting for execution.
31    Pending,
32    /// An attempt is executing.
33    Running,
34    /// An attempt completed successfully.
35    Succeeded,
36    /// Attempt failed with an error.
37    Failed,
38    /// An attempt exceeded its timeout.
39    Timeout,
40    /// Execution was canceled.
41    Canceled,
42    /// Failure retry budget was exhausted.
43    Exhausted,
44}
45
46impl fmt::Display for TaskPhase {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        match self {
49            TaskPhase::Pending => f.write_str("pending"),
50            TaskPhase::Running => f.write_str("running"),
51            TaskPhase::Succeeded => f.write_str("succeeded"),
52            TaskPhase::Failed => f.write_str("failed"),
53            TaskPhase::Timeout => f.write_str("timeout"),
54            TaskPhase::Canceled => f.write_str("canceled"),
55            TaskPhase::Exhausted => f.write_str("exhausted"),
56        }
57    }
58}
59
60impl FromStr for TaskPhase {
61    type Err = ModelError;
62
63    /// Parses a phase name.
64    ///
65    /// Parsing trims whitespace and ignores ASCII case.
66    ///
67    /// # Errors
68    ///
69    /// Returns [`ModelError::UnknownTaskPhase`] for an unknown value.
70    fn from_str(s: &str) -> ModelResult<Self> {
71        let trimmed = s.trim();
72        match trimmed.to_ascii_lowercase().as_str() {
73            "pending" => Ok(TaskPhase::Pending),
74            "running" => Ok(TaskPhase::Running),
75            "succeeded" => Ok(TaskPhase::Succeeded),
76            "failed" => Ok(TaskPhase::Failed),
77            "timeout" => Ok(TaskPhase::Timeout),
78            "canceled" => Ok(TaskPhase::Canceled),
79            "exhausted" => Ok(TaskPhase::Exhausted),
80            _ => Err(ModelError::UnknownTaskPhase(trimmed.to_string())),
81        }
82    }
83}
84
85impl TaskPhase {
86    /// Returns whether the phase is terminal.
87    ///
88    /// A later attempt may still start under [`RestartPolicy`](crate::RestartPolicy).
89    /// Resource reconciliation may also refine or replace a terminal outcome.
90    ///
91    /// ## Example
92    ///
93    /// ```
94    /// use solti_model::TaskPhase;
95    ///
96    /// assert!(TaskPhase::Succeeded.is_terminal());
97    /// assert!(!TaskPhase::Running.is_terminal());
98    /// ```
99    #[inline]
100    pub fn is_terminal(&self) -> bool {
101        matches!(
102            self,
103            TaskPhase::Succeeded
104                | TaskPhase::Failed
105                | TaskPhase::Timeout
106                | TaskPhase::Canceled
107                | TaskPhase::Exhausted
108        )
109    }
110
111    /// Returns whether the phase is pending or running.
112    ///
113    /// ## Example
114    ///
115    /// ```
116    /// use solti_model::TaskPhase;
117    ///
118    /// assert!(TaskPhase::Pending.is_active());
119    /// assert!(!TaskPhase::Failed.is_active());
120    /// ```
121    #[inline]
122    pub fn is_active(&self) -> bool {
123        matches!(self, TaskPhase::Pending | TaskPhase::Running)
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn active_and_terminal_classification_covers_every_phase() {
133        for phase in [TaskPhase::Pending, TaskPhase::Running] {
134            assert!(phase.is_active());
135            assert!(!phase.is_terminal());
136        }
137        for phase in [
138            TaskPhase::Succeeded,
139            TaskPhase::Failed,
140            TaskPhase::Timeout,
141            TaskPhase::Canceled,
142            TaskPhase::Exhausted,
143        ] {
144            assert!(!phase.is_active());
145            assert!(phase.is_terminal());
146        }
147    }
148
149    #[test]
150    fn display_parse_and_serde_roundtrip_every_phase() {
151        let cases = [
152            ("pending", TaskPhase::Pending),
153            ("running", TaskPhase::Running),
154            ("succeeded", TaskPhase::Succeeded),
155            ("failed", TaskPhase::Failed),
156            ("timeout", TaskPhase::Timeout),
157            ("canceled", TaskPhase::Canceled),
158            ("exhausted", TaskPhase::Exhausted),
159        ];
160        for (wire, phase) in cases {
161            assert_eq!(phase.to_string(), wire);
162            assert_eq!(wire.parse::<TaskPhase>().unwrap(), phase);
163            let json = serde_json::to_string(&phase).unwrap();
164            assert_eq!(serde_json::from_str::<TaskPhase>(&json).unwrap(), phase);
165        }
166    }
167
168    #[test]
169    fn parsing_normalizes_case_and_whitespace_and_rejects_unknown_values() {
170        assert_eq!("RUNNING".parse::<TaskPhase>().unwrap(), TaskPhase::Running);
171        assert_eq!(
172            "  Succeeded  ".parse::<TaskPhase>().unwrap(),
173            TaskPhase::Succeeded
174        );
175        let err = "bogus".parse::<TaskPhase>().unwrap_err();
176        assert!(matches!(err, ModelError::UnknownTaskPhase(_)));
177    }
178}