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/// - [`TaskStatus`](crate::TaskStatus) carries the current phase.
19/// - [`TaskPhase::is_terminal`] checks for final states.
20/// - [`RestartPolicy`](crate::RestartPolicy) governs what happens after a terminal phase.
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
59    /// camelCase form produced by [`fmt::Display`] / serde.
60    fn from_str(s: &str) -> ModelResult<Self> {
61        let trimmed = s.trim();
62        match trimmed.to_ascii_lowercase().as_str() {
63            "pending" => Ok(TaskPhase::Pending),
64            "running" => Ok(TaskPhase::Running),
65            "succeeded" => Ok(TaskPhase::Succeeded),
66            "failed" => Ok(TaskPhase::Failed),
67            "timeout" => Ok(TaskPhase::Timeout),
68            "canceled" => Ok(TaskPhase::Canceled),
69            "exhausted" => Ok(TaskPhase::Exhausted),
70            _ => Err(ModelError::UnknownTaskPhase(trimmed.to_string())),
71        }
72    }
73}
74
75impl TaskPhase {
76    /// Returns `true` if the current attempt has reached a final state.
77    ///
78    /// A terminal phase means this attempt will not transition further.
79    /// The supervisor may still start a **new** attempt based on the [`RestartPolicy`](crate::RestartPolicy).
80    #[inline]
81    pub fn is_terminal(&self) -> bool {
82        matches!(
83            self,
84            TaskPhase::Succeeded
85                | TaskPhase::Failed
86                | TaskPhase::Timeout
87                | TaskPhase::Canceled
88                | TaskPhase::Exhausted
89        )
90    }
91
92    /// Returns `true` if the task is still active (pending or running).
93    #[inline]
94    pub fn is_active(&self) -> bool {
95        matches!(self, TaskPhase::Pending | TaskPhase::Running)
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn terminal_states() {
105        assert!(TaskPhase::Succeeded.is_terminal());
106        assert!(TaskPhase::Failed.is_terminal());
107        assert!(TaskPhase::Timeout.is_terminal());
108        assert!(TaskPhase::Canceled.is_terminal());
109        assert!(TaskPhase::Exhausted.is_terminal());
110
111        assert!(!TaskPhase::Pending.is_terminal());
112        assert!(!TaskPhase::Running.is_terminal());
113    }
114
115    #[test]
116    fn active_states() {
117        assert!(TaskPhase::Pending.is_active());
118        assert!(TaskPhase::Running.is_active());
119
120        assert!(!TaskPhase::Succeeded.is_active());
121        assert!(!TaskPhase::Failed.is_active());
122    }
123
124    #[test]
125    fn serde_roundtrip() {
126        let status = TaskPhase::Running;
127        let json = serde_json::to_string(&status).unwrap();
128        assert_eq!(json, r#""running""#);
129
130        let back: TaskPhase = serde_json::from_str(&json).unwrap();
131        assert_eq!(back, status);
132    }
133
134    #[test]
135    fn from_str_all_variants() {
136        let cases = [
137            ("pending", TaskPhase::Pending),
138            ("running", TaskPhase::Running),
139            ("succeeded", TaskPhase::Succeeded),
140            ("failed", TaskPhase::Failed),
141            ("timeout", TaskPhase::Timeout),
142            ("canceled", TaskPhase::Canceled),
143            ("exhausted", TaskPhase::Exhausted),
144        ];
145        for (s, expected) in cases {
146            assert_eq!(s.parse::<TaskPhase>().unwrap(), expected);
147        }
148    }
149
150    #[test]
151    fn from_str_is_case_insensitive_and_trims() {
152        assert_eq!("RUNNING".parse::<TaskPhase>().unwrap(), TaskPhase::Running);
153        assert_eq!(
154            "  Succeeded  ".parse::<TaskPhase>().unwrap(),
155            TaskPhase::Succeeded
156        );
157    }
158
159    #[test]
160    fn from_str_roundtrips_display() {
161        for phase in [
162            TaskPhase::Pending,
163            TaskPhase::Running,
164            TaskPhase::Succeeded,
165            TaskPhase::Failed,
166            TaskPhase::Timeout,
167            TaskPhase::Canceled,
168            TaskPhase::Exhausted,
169        ] {
170            let rendered = phase.to_string();
171            assert_eq!(rendered.parse::<TaskPhase>().unwrap(), phase);
172        }
173    }
174
175    #[test]
176    fn from_str_unknown_errors() {
177        let err = "bogus".parse::<TaskPhase>().unwrap_err();
178        assert!(matches!(err, ModelError::UnknownTaskPhase(_)));
179    }
180}