use_docker_healthcheck/
lib.rs1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum HealthcheckError {
10 Empty,
12 InvalidDuration,
14 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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
34pub struct DurationSpec(String);
35
36impl DurationSpec {
37 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 #[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#[derive(Clone, Debug, Eq, PartialEq)]
73pub enum HealthcheckCommand {
74 None,
76 Shell(String),
78 Exec(Vec<String>),
80}
81
82impl HealthcheckCommand {
83 #[must_use]
85 pub fn shell(command: impl Into<String>) -> Self {
86 Self::Shell(command.into())
87 }
88
89 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#[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 #[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 #[must_use]
148 pub fn with_interval(mut self, interval: DurationSpec) -> Self {
149 self.interval = Some(interval);
150 self
151 }
152
153 #[must_use]
155 pub fn with_timeout(mut self, timeout: DurationSpec) -> Self {
156 self.timeout = Some(timeout);
157 self
158 }
159
160 #[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 #[must_use]
169 pub const fn with_retries(mut self, retries: u16) -> Self {
170 self.retries = Some(retries);
171 self
172 }
173
174 #[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}