use-docker-tag 0.0.1

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

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

/// Error returned when Docker tag text is invalid.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DockerTagError {
    /// The tag was empty after trimming.
    Empty,
    /// Docker tags are limited to 128 characters.
    TooLong,
    /// The first character was not ASCII alphanumeric or `_`.
    InvalidStart,
    /// The tag contained a character outside `[A-Za-z0-9_.-]`.
    InvalidCharacter,
}

impl fmt::Display for DockerTagError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("Docker tag cannot be empty"),
            Self::TooLong => formatter.write_str("Docker tag cannot exceed 128 characters"),
            Self::InvalidStart => {
                formatter.write_str("Docker tag must start with an ASCII word character")
            },
            Self::InvalidCharacter => formatter.write_str("Docker tag contains invalid characters"),
        }
    }
}

impl Error for DockerTagError {}

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

impl DockerTag {
    /// Creates a validated Docker tag.
    pub fn new(value: impl AsRef<str>) -> Result<Self, DockerTagError> {
        let trimmed = value.as_ref().trim();
        validate_tag(trimmed)?;
        Ok(Self(trimmed.to_string()))
    }

    /// Returns the conventional `latest` tag.
    #[must_use]
    pub fn latest() -> Self {
        Self("latest".to_string())
    }

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

    /// Returns true when the tag is exactly `latest`.
    #[must_use]
    pub fn is_latest(&self) -> bool {
        self.as_str() == "latest"
    }

    /// Returns true for common semantic-version-shaped tags.
    #[must_use]
    pub fn is_semver_like(&self) -> bool {
        let value = self.as_str().strip_prefix('v').unwrap_or(self.as_str());
        let core = value.split_once('-').map_or(value, |(core, _)| core);
        let mut parts = core.split('.');
        matches!(
            (parts.next(), parts.next(), parts.next(), parts.next()),
            (Some(major), Some(minor), Some(patch), None)
                if is_digits(major) && is_digits(minor) && is_digits(patch)
        )
    }

    /// Returns true when the tag ends with a common platform or distro suffix.
    #[must_use]
    pub fn has_platform_suffix(&self) -> bool {
        let suffix = self
            .as_str()
            .rsplit_once(['-', '_'])
            .map_or(self.as_str(), |(_, suffix)| suffix);
        matches!(
            suffix,
            "amd64"
                | "arm64"
                | "armv7"
                | "armv6"
                | "386"
                | "alpine"
                | "bookworm"
                | "bullseye"
                | "slim"
        )
    }
}

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

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

impl FromStr for DockerTag {
    type Err = DockerTagError;

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

impl TryFrom<&str> for DockerTag {
    type Error = DockerTagError;

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

/// Returns true when `value` is valid Docker tag text.
#[must_use]
pub fn is_valid_docker_tag(value: impl AsRef<str>) -> bool {
    validate_tag(value.as_ref().trim()).is_ok()
}

fn validate_tag(value: &str) -> Result<(), DockerTagError> {
    if value.is_empty() {
        return Err(DockerTagError::Empty);
    }
    if value.len() > 128 {
        return Err(DockerTagError::TooLong);
    }
    let mut chars = value.chars();
    let Some(first) = chars.next() else {
        return Err(DockerTagError::Empty);
    };
    if !(first.is_ascii_alphanumeric() || first == '_') {
        return Err(DockerTagError::InvalidStart);
    }
    if chars.any(|character| {
        !(character.is_ascii_alphanumeric() || matches!(character, '_' | '.' | '-'))
    }) {
        return Err(DockerTagError::InvalidCharacter);
    }
    Ok(())
}

fn is_digits(value: &str) -> bool {
    !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())
}

#[cfg(test)]
mod tests {
    use super::{DockerTag, DockerTagError, is_valid_docker_tag};

    #[test]
    fn validates_and_classifies_tags() -> Result<(), Box<dyn std::error::Error>> {
        let tag: DockerTag = "v1.2.3-amd64".parse()?;

        assert!(tag.is_semver_like());
        assert!(tag.has_platform_suffix());
        assert!(DockerTag::latest().is_latest());
        assert!(is_valid_docker_tag("_dev"));
        assert_eq!(DockerTag::new("-bad"), Err(DockerTagError::InvalidStart));
        Ok(())
    }
}