use-docker-healthcheck 0.0.1

Primitive Docker healthcheck helpers for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

use core::{fmt, str::FromStr};
use std::error::Error;

/// Error returned when healthcheck text is invalid.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HealthcheckError {
    /// The value was empty after trimming.
    Empty,
    /// A duration used unsupported syntax.
    InvalidDuration,
    /// An exec command had no arguments.
    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 {}

/// A Docker duration string such as `30s`, `1m`, or `500ms`.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DurationSpec(String);

impl DurationSpec {
    /// Creates a duration spec from text.
    pub fn new(value: impl AsRef<str>) -> Result<Self, HealthcheckError> {
        let trimmed = value.as_ref().trim();
        validate_duration(trimmed)?;
        Ok(Self(trimmed.to_string()))
    }

    /// Returns the duration text.
    #[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)
    }
}

/// A Docker healthcheck command.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum HealthcheckCommand {
    /// Disable healthchecks.
    None,
    /// Shell-form command rendered with `CMD-SHELL`.
    Shell(String),
    /// Exec-form command rendered with `CMD`.
    Exec(Vec<String>),
}

impl HealthcheckCommand {
    /// Creates a shell-form command.
    #[must_use]
    pub fn shell(command: impl Into<String>) -> Self {
        Self::Shell(command.into())
    }

    /// Creates an exec-form command.
    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("]")
            },
        }
    }
}

/// Docker healthcheck options and command.
#[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 {
    /// Creates healthcheck config for a command.
    #[must_use]
    pub const fn new(command: HealthcheckCommand) -> Self {
        Self {
            command,
            interval: None,
            timeout: None,
            start_period: None,
            retries: None,
        }
    }

    /// Adds an interval option.
    #[must_use]
    pub fn with_interval(mut self, interval: DurationSpec) -> Self {
        self.interval = Some(interval);
        self
    }

    /// Adds a timeout option.
    #[must_use]
    pub fn with_timeout(mut self, timeout: DurationSpec) -> Self {
        self.timeout = Some(timeout);
        self
    }

    /// Adds a start-period option.
    #[must_use]
    pub fn with_start_period(mut self, start_period: DurationSpec) -> Self {
        self.start_period = Some(start_period);
        self
    }

    /// Adds a retry count.
    #[must_use]
    pub const fn with_retries(mut self, retries: u16) -> Self {
        self.retries = Some(retries);
        self
    }

    /// Returns the command.
    #[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(())
    }
}