Skip to main content

solti_model/domain/policy/
restart.rs

1//! # Restart policy
2//!
3//! [`RestartPolicy`] describes whether another attempt may be started.
4
5use serde::{Deserialize, Serialize};
6use std::str::FromStr;
7
8use crate::error::{ModelError, ModelResult};
9
10/// Policy for starting another attempt.
11///
12/// | Variant     | Meaning                              |
13/// |-------------|--------------------------------------|
14/// | `Never`     | Do not start another attempt         |
15/// | `OnFailure` | Restart after a retryable failure    |
16/// | `Always`    | Restart after any completed attempt  |
17///
18/// `Always { interval_ms: None }` requests an immediate restart.
19/// `Always { interval_ms: Some(n) }` requests a delay of `n` milliseconds.
20///
21/// ## Example
22///
23/// ```
24/// use solti_model::RestartPolicy;
25///
26/// let retry_errors = RestartPolicy::OnFailure;
27/// let service = RestartPolicy::always();
28/// let periodic = RestartPolicy::periodic(60_000);
29///
30/// let _ = (retry_errors, service, periodic);
31/// ```
32#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
33#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
34#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
35#[non_exhaustive]
36pub enum RestartPolicy {
37    /// Never restart the task.
38    #[default]
39    Never,
40    /// Restart after a retryable failure.
41    OnFailure,
42    /// Restart after every completed attempt.
43    #[serde(rename_all = "camelCase")]
44    Always {
45        /// Delay between attempts in milliseconds.
46        ///
47        /// `None` and `Some(0)` request an immediate restart.
48        #[serde(skip_serializing_if = "Option::is_none")]
49        interval_ms: Option<u64>,
50    },
51}
52
53impl RestartPolicy {
54    /// Creates an immediate `Always` policy.
55    ///
56    /// ## Example
57    ///
58    /// ```
59    /// use solti_model::RestartPolicy;
60    ///
61    /// assert_eq!(
62    ///     RestartPolicy::always(),
63    ///     RestartPolicy::Always { interval_ms: None },
64    /// );
65    /// ```
66    pub const fn always() -> Self {
67        RestartPolicy::Always { interval_ms: None }
68    }
69
70    /// Creates a periodic `Always` policy.
71    ///
72    /// ## Example
73    ///
74    /// ```
75    /// use solti_model::RestartPolicy;
76    ///
77    /// assert_eq!(
78    ///     RestartPolicy::periodic(5_000),
79    ///     RestartPolicy::Always { interval_ms: Some(5_000) },
80    /// );
81    /// ```
82    pub const fn periodic(interval_ms: u64) -> Self {
83        RestartPolicy::Always {
84            interval_ms: Some(interval_ms),
85        }
86    }
87}
88
89impl FromStr for RestartPolicy {
90    type Err = ModelError;
91
92    fn from_str(s: &str) -> ModelResult<Self> {
93        let original = s.trim();
94        if original.is_empty() {
95            return Ok(RestartPolicy::Never);
96        }
97
98        let (head, rest) = match original.find(':') {
99            Some(pos) => (&original[..pos], Some(original[pos + 1..].trim())),
100            None => (original, None),
101        };
102
103        if head.eq_ignore_ascii_case("never") {
104            Ok(RestartPolicy::Never)
105        } else if head.eq_ignore_ascii_case("on-failure") || head.eq_ignore_ascii_case("failure") {
106            Ok(RestartPolicy::OnFailure)
107        } else if head.eq_ignore_ascii_case("always") {
108            let interval_ms = match rest {
109                None | Some("") => None,
110                Some(v) => {
111                    let v = v.parse::<u64>().map_err(|_| {
112                        ModelError::UnknownRestart(format!(
113                            "invalid interval in '{}': must be u64",
114                            original
115                        ))
116                    })?;
117                    Some(v)
118                }
119            };
120            Ok(RestartPolicy::Always { interval_ms })
121        } else {
122            Err(ModelError::UnknownRestart(original.to_string()))
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::RestartPolicy;
130    use crate::error::ModelError;
131    use std::str::FromStr;
132
133    #[test]
134    fn parsing_accepts_policies_aliases_case_and_intervals() {
135        let cases = [
136            ("", RestartPolicy::Never),
137            ("never", RestartPolicy::Never),
138            ("  NeVeR  ", RestartPolicy::Never),
139            ("on-failure", RestartPolicy::OnFailure),
140            ("failure", RestartPolicy::OnFailure),
141            ("  Failure ", RestartPolicy::OnFailure),
142            ("always", RestartPolicy::Always { interval_ms: None }),
143            ("  ALWAYS  ", RestartPolicy::Always { interval_ms: None }),
144            ("always:", RestartPolicy::Always { interval_ms: None }),
145            ("always:   ", RestartPolicy::Always { interval_ms: None }),
146            (
147                "always:1000",
148                RestartPolicy::Always {
149                    interval_ms: Some(1_000),
150                },
151            ),
152            (
153                " Always:  60000 ",
154                RestartPolicy::Always {
155                    interval_ms: Some(60_000),
156                },
157            ),
158        ];
159        for (value, expected) in cases {
160            assert_eq!(RestartPolicy::from_str(value).unwrap(), expected);
161        }
162    }
163
164    #[test]
165    fn parsing_rejects_unknown_policy_and_invalid_interval() {
166        for value in ["always:not-a-number", "random"] {
167            assert!(matches!(
168                RestartPolicy::from_str(value),
169                Err(ModelError::UnknownRestart(_))
170            ));
171        }
172    }
173}