Skip to main content

use_docker_healthcheck/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Error returned when healthcheck text is invalid.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum HealthcheckError {
10    /// The value was empty after trimming.
11    Empty,
12    /// A duration used unsupported syntax.
13    InvalidDuration,
14    /// An exec command had no arguments.
15    EmptyExecCommand,
16}
17
18impl fmt::Display for HealthcheckError {
19    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            Self::Empty => formatter.write_str("Docker healthcheck value cannot be empty"),
22            Self::InvalidDuration => formatter.write_str("invalid Docker healthcheck duration"),
23            Self::EmptyExecCommand => {
24                formatter.write_str("Docker healthcheck exec command is empty")
25            },
26        }
27    }
28}
29
30impl Error for HealthcheckError {}
31
32/// A Docker duration string such as `30s`, `1m`, or `500ms`.
33#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
34pub struct DurationSpec(String);
35
36impl DurationSpec {
37    /// Creates a duration spec from text.
38    pub fn new(value: impl AsRef<str>) -> Result<Self, HealthcheckError> {
39        let trimmed = value.as_ref().trim();
40        validate_duration(trimmed)?;
41        Ok(Self(trimmed.to_string()))
42    }
43
44    /// Returns the duration text.
45    #[must_use]
46    pub fn as_str(&self) -> &str {
47        &self.0
48    }
49}
50
51impl AsRef<str> for DurationSpec {
52    fn as_ref(&self) -> &str {
53        self.as_str()
54    }
55}
56
57impl fmt::Display for DurationSpec {
58    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
59        formatter.write_str(self.as_str())
60    }
61}
62
63impl FromStr for DurationSpec {
64    type Err = HealthcheckError;
65
66    fn from_str(value: &str) -> Result<Self, Self::Err> {
67        Self::new(value)
68    }
69}
70
71/// A Docker healthcheck command.
72#[derive(Clone, Debug, Eq, PartialEq)]
73pub enum HealthcheckCommand {
74    /// Disable healthchecks.
75    None,
76    /// Shell-form command rendered with `CMD-SHELL`.
77    Shell(String),
78    /// Exec-form command rendered with `CMD`.
79    Exec(Vec<String>),
80}
81
82impl HealthcheckCommand {
83    /// Creates a shell-form command.
84    #[must_use]
85    pub fn shell(command: impl Into<String>) -> Self {
86        Self::Shell(command.into())
87    }
88
89    /// Creates an exec-form command.
90    pub fn exec<I, S>(command: I) -> Result<Self, HealthcheckError>
91    where
92        I: IntoIterator<Item = S>,
93        S: Into<String>,
94    {
95        let command = command.into_iter().map(Into::into).collect::<Vec<_>>();
96        if command.is_empty() {
97            Err(HealthcheckError::EmptyExecCommand)
98        } else {
99            Ok(Self::Exec(command))
100        }
101    }
102}
103
104impl fmt::Display for HealthcheckCommand {
105    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
106        match self {
107            Self::None => formatter.write_str("NONE"),
108            Self::Shell(command) => write!(formatter, "CMD-SHELL {command}"),
109            Self::Exec(command) => {
110                formatter.write_str("CMD [")?;
111                for (index, part) in command.iter().enumerate() {
112                    if index > 0 {
113                        formatter.write_str(", ")?;
114                    }
115                    write!(formatter, "\"{}\"", part.replace('"', "\\\""))?;
116                }
117                formatter.write_str("]")
118            },
119        }
120    }
121}
122
123/// Docker healthcheck options and command.
124#[derive(Clone, Debug, Eq, PartialEq)]
125pub struct HealthcheckConfig {
126    command: HealthcheckCommand,
127    interval: Option<DurationSpec>,
128    timeout: Option<DurationSpec>,
129    start_period: Option<DurationSpec>,
130    retries: Option<u16>,
131}
132
133impl HealthcheckConfig {
134    /// Creates healthcheck config for a command.
135    #[must_use]
136    pub const fn new(command: HealthcheckCommand) -> Self {
137        Self {
138            command,
139            interval: None,
140            timeout: None,
141            start_period: None,
142            retries: None,
143        }
144    }
145
146    /// Adds an interval option.
147    #[must_use]
148    pub fn with_interval(mut self, interval: DurationSpec) -> Self {
149        self.interval = Some(interval);
150        self
151    }
152
153    /// Adds a timeout option.
154    #[must_use]
155    pub fn with_timeout(mut self, timeout: DurationSpec) -> Self {
156        self.timeout = Some(timeout);
157        self
158    }
159
160    /// Adds a start-period option.
161    #[must_use]
162    pub fn with_start_period(mut self, start_period: DurationSpec) -> Self {
163        self.start_period = Some(start_period);
164        self
165    }
166
167    /// Adds a retry count.
168    #[must_use]
169    pub const fn with_retries(mut self, retries: u16) -> Self {
170        self.retries = Some(retries);
171        self
172    }
173
174    /// Returns the command.
175    #[must_use]
176    pub const fn command(&self) -> &HealthcheckCommand {
177        &self.command
178    }
179}
180
181impl fmt::Display for HealthcheckConfig {
182    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
183        let mut wrote_option = false;
184        for option in [
185            self.interval
186                .as_ref()
187                .map(|value| format!("--interval={value}")),
188            self.timeout
189                .as_ref()
190                .map(|value| format!("--timeout={value}")),
191            self.start_period
192                .as_ref()
193                .map(|value| format!("--start-period={value}")),
194            self.retries.map(|value| format!("--retries={value}")),
195        ]
196        .into_iter()
197        .flatten()
198        {
199            if wrote_option {
200                formatter.write_str(" ")?;
201            }
202            formatter.write_str(&option)?;
203            wrote_option = true;
204        }
205        if wrote_option {
206            formatter.write_str(" ")?;
207        }
208        write!(formatter, "{}", self.command)
209    }
210}
211
212fn validate_duration(value: &str) -> Result<(), HealthcheckError> {
213    if value.is_empty() || value.chars().any(char::is_whitespace) {
214        return Err(HealthcheckError::InvalidDuration);
215    }
216    let split_at = value
217        .find(|character: char| !character.is_ascii_digit())
218        .ok_or(HealthcheckError::InvalidDuration)?;
219    let (number, unit) = value.split_at(split_at);
220    if number.is_empty() || !matches!(unit, "ns" | "us" | "ms" | "s" | "m" | "h") {
221        Err(HealthcheckError::InvalidDuration)
222    } else {
223        Ok(())
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::{DurationSpec, HealthcheckCommand, HealthcheckConfig};
230
231    #[test]
232    fn renders_healthcheck_config() -> Result<(), Box<dyn std::error::Error>> {
233        let config = HealthcheckConfig::new(HealthcheckCommand::shell("curl -f http://localhost"))
234            .with_interval(DurationSpec::new("30s")?)
235            .with_retries(3);
236
237        assert_eq!(
238            config.to_string(),
239            "--interval=30s --retries=3 CMD-SHELL curl -f http://localhost"
240        );
241        Ok(())
242    }
243}