#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HealthcheckError {
Empty,
InvalidDuration,
EmptyExecCommand,
}
impl fmt::Display for HealthcheckError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("Docker healthcheck value cannot be empty"),
Self::InvalidDuration => formatter.write_str("invalid Docker healthcheck duration"),
Self::EmptyExecCommand => {
formatter.write_str("Docker healthcheck exec command is empty")
},
}
}
}
impl Error for HealthcheckError {}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DurationSpec(String);
impl DurationSpec {
pub fn new(value: impl AsRef<str>) -> Result<Self, HealthcheckError> {
let trimmed = value.as_ref().trim();
validate_duration(trimmed)?;
Ok(Self(trimmed.to_string()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for DurationSpec {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for DurationSpec {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for DurationSpec {
type Err = HealthcheckError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum HealthcheckCommand {
None,
Shell(String),
Exec(Vec<String>),
}
impl HealthcheckCommand {
#[must_use]
pub fn shell(command: impl Into<String>) -> Self {
Self::Shell(command.into())
}
pub fn exec<I, S>(command: I) -> Result<Self, HealthcheckError>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let command = command.into_iter().map(Into::into).collect::<Vec<_>>();
if command.is_empty() {
Err(HealthcheckError::EmptyExecCommand)
} else {
Ok(Self::Exec(command))
}
}
}
impl fmt::Display for HealthcheckCommand {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::None => formatter.write_str("NONE"),
Self::Shell(command) => write!(formatter, "CMD-SHELL {command}"),
Self::Exec(command) => {
formatter.write_str("CMD [")?;
for (index, part) in command.iter().enumerate() {
if index > 0 {
formatter.write_str(", ")?;
}
write!(formatter, "\"{}\"", part.replace('"', "\\\""))?;
}
formatter.write_str("]")
},
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HealthcheckConfig {
command: HealthcheckCommand,
interval: Option<DurationSpec>,
timeout: Option<DurationSpec>,
start_period: Option<DurationSpec>,
retries: Option<u16>,
}
impl HealthcheckConfig {
#[must_use]
pub const fn new(command: HealthcheckCommand) -> Self {
Self {
command,
interval: None,
timeout: None,
start_period: None,
retries: None,
}
}
#[must_use]
pub fn with_interval(mut self, interval: DurationSpec) -> Self {
self.interval = Some(interval);
self
}
#[must_use]
pub fn with_timeout(mut self, timeout: DurationSpec) -> Self {
self.timeout = Some(timeout);
self
}
#[must_use]
pub fn with_start_period(mut self, start_period: DurationSpec) -> Self {
self.start_period = Some(start_period);
self
}
#[must_use]
pub const fn with_retries(mut self, retries: u16) -> Self {
self.retries = Some(retries);
self
}
#[must_use]
pub const fn command(&self) -> &HealthcheckCommand {
&self.command
}
}
impl fmt::Display for HealthcheckConfig {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut wrote_option = false;
for option in [
self.interval
.as_ref()
.map(|value| format!("--interval={value}")),
self.timeout
.as_ref()
.map(|value| format!("--timeout={value}")),
self.start_period
.as_ref()
.map(|value| format!("--start-period={value}")),
self.retries.map(|value| format!("--retries={value}")),
]
.into_iter()
.flatten()
{
if wrote_option {
formatter.write_str(" ")?;
}
formatter.write_str(&option)?;
wrote_option = true;
}
if wrote_option {
formatter.write_str(" ")?;
}
write!(formatter, "{}", self.command)
}
}
fn validate_duration(value: &str) -> Result<(), HealthcheckError> {
if value.is_empty() || value.chars().any(char::is_whitespace) {
return Err(HealthcheckError::InvalidDuration);
}
let split_at = value
.find(|character: char| !character.is_ascii_digit())
.ok_or(HealthcheckError::InvalidDuration)?;
let (number, unit) = value.split_at(split_at);
if number.is_empty() || !matches!(unit, "ns" | "us" | "ms" | "s" | "m" | "h") {
Err(HealthcheckError::InvalidDuration)
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{DurationSpec, HealthcheckCommand, HealthcheckConfig};
#[test]
fn renders_healthcheck_config() -> Result<(), Box<dyn std::error::Error>> {
let config = HealthcheckConfig::new(HealthcheckCommand::shell("curl -f http://localhost"))
.with_interval(DurationSpec::new("30s")?)
.with_retries(3);
assert_eq!(
config.to_string(),
"--interval=30s --retries=3 CMD-SHELL curl -f http://localhost"
);
Ok(())
}
}