use-docker-label 0.0.1

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

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

/// Common `OCI` annotation label for an image title.
pub const OCI_TITLE: &str = "org.opencontainers.image.title";
/// Common `OCI` annotation label for a description.
pub const OCI_DESCRIPTION: &str = "org.opencontainers.image.description";
/// Common `OCI` annotation label for a version.
pub const OCI_VERSION: &str = "org.opencontainers.image.version";
/// Common `OCI` annotation label for a source URL.
pub const OCI_SOURCE: &str = "org.opencontainers.image.source";
/// Common `OCI` annotation label for a revision.
pub const OCI_REVISION: &str = "org.opencontainers.image.revision";
/// Common `OCI` annotation label for license metadata.
pub const OCI_LICENSES: &str = "org.opencontainers.image.licenses";

/// Error returned when Docker label text is invalid.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DockerLabelError {
    /// The label key was empty.
    EmptyKey,
    /// The label key contained unsupported syntax.
    InvalidKey,
    /// The label value contained a NUL byte.
    InvalidValue,
}

impl fmt::Display for DockerLabelError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::EmptyKey => formatter.write_str("Docker label key cannot be empty"),
            Self::InvalidKey => formatter.write_str("invalid Docker label key"),
            Self::InvalidValue => formatter.write_str("invalid Docker label value"),
        }
    }
}

impl Error for DockerLabelError {}

/// A validated Docker label key.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DockerLabelKey(String);

impl DockerLabelKey {
    /// Creates a label key.
    pub fn new(value: impl AsRef<str>) -> Result<Self, DockerLabelError> {
        let trimmed = value.as_ref().trim();
        validate_key(trimmed)?;
        Ok(Self(trimmed.to_string()))
    }

    /// Returns the key text.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl AsRef<str> for DockerLabelKey {
    fn as_ref(&self) -> &str {
        self.as_str()
    }
}

impl fmt::Display for DockerLabelKey {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for DockerLabelKey {
    type Err = DockerLabelError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        Self::new(value)
    }
}

/// A Docker label key/value pair.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DockerLabel {
    key: DockerLabelKey,
    value: String,
}

impl DockerLabel {
    /// Creates a Docker label.
    pub fn new(key: DockerLabelKey, value: impl AsRef<str>) -> Result<Self, DockerLabelError> {
        let value = value.as_ref();
        if value.contains('\0') {
            return Err(DockerLabelError::InvalidValue);
        }
        Ok(Self {
            key,
            value: value.to_string(),
        })
    }

    /// Creates a common `OCI` title label.
    pub fn oci_title(value: impl AsRef<str>) -> Result<Self, DockerLabelError> {
        Self::new(DockerLabelKey::new(OCI_TITLE)?, value)
    }

    /// Returns the label key.
    #[must_use]
    pub const fn key(&self) -> &DockerLabelKey {
        &self.key
    }

    /// Returns the label value.
    #[must_use]
    pub fn value(&self) -> &str {
        &self.value
    }
}

impl fmt::Display for DockerLabel {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "{}={}", self.key, self.value)
    }
}

fn validate_key(value: &str) -> Result<(), DockerLabelError> {
    if value.is_empty() {
        return Err(DockerLabelError::EmptyKey);
    }
    if value.starts_with(['.', '/', '-'])
        || value.ends_with(['.', '/', '-'])
        || value.chars().any(char::is_whitespace)
        || !value
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b'/'))
    {
        Err(DockerLabelError::InvalidKey)
    } else {
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::{DockerLabel, DockerLabelError, DockerLabelKey, OCI_TITLE};

    #[test]
    fn validates_and_renders_labels() -> Result<(), Box<dyn std::error::Error>> {
        let label = DockerLabel::new(DockerLabelKey::new(OCI_TITLE)?, "RustUse app")?;

        assert_eq!(
            label.to_string(),
            "org.opencontainers.image.title=RustUse app"
        );
        assert_eq!(
            DockerLabelKey::new("bad key"),
            Err(DockerLabelError::InvalidKey)
        );
        assert_eq!(DockerLabel::oci_title("RustUse")?.key().as_str(), OCI_TITLE);
        Ok(())
    }
}