use-docker-registry 0.0.1

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

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

/// Error returned when registry or repository reference text is invalid.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DockerRegistryError {
    /// The value was empty after trimming.
    Empty,
    /// Registry host text used unsupported syntax.
    InvalidRegistry,
    /// Repository path text used unsupported syntax.
    InvalidRepository,
}

impl fmt::Display for DockerRegistryError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("Docker registry reference cannot be empty"),
            Self::InvalidRegistry => formatter.write_str("invalid Docker registry host"),
            Self::InvalidRepository => formatter.write_str("invalid Docker repository path"),
        }
    }
}

impl Error for DockerRegistryError {}

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

impl DockerRegistry {
    /// Creates a registry host label.
    pub fn new(value: impl AsRef<str>) -> Result<Self, DockerRegistryError> {
        let trimmed = value.as_ref().trim();
        validate_registry(trimmed)?;
        Ok(Self(trimmed.to_string()))
    }

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

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

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

impl FromStr for DockerRegistry {
    type Err = DockerRegistryError;

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

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

impl DockerRepositoryPath {
    /// Creates a repository path.
    pub fn new(value: impl AsRef<str>) -> Result<Self, DockerRegistryError> {
        let trimmed = value.as_ref().trim();
        validate_repository(trimmed)?;
        Ok(Self(trimmed.to_string()))
    }

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

    /// Returns the repository name after the final slash.
    #[must_use]
    pub fn repository(&self) -> &str {
        self.as_str()
            .rsplit_once('/')
            .map_or(self.as_str(), |(_, repository)| repository)
    }
}

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

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

impl FromStr for DockerRepositoryPath {
    type Err = DockerRegistryError;

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

/// A registry-qualified or local repository path.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct RegistryImagePath {
    registry: Option<DockerRegistry>,
    repository: DockerRepositoryPath,
}

impl RegistryImagePath {
    /// Creates a registry image path from validated parts.
    #[must_use]
    pub fn new(registry: Option<DockerRegistry>, repository: DockerRepositoryPath) -> Self {
        Self {
            registry,
            repository,
        }
    }

    /// Returns the optional registry host.
    #[must_use]
    pub fn registry(&self) -> Option<&DockerRegistry> {
        self.registry.as_ref()
    }

    /// Returns the repository path.
    #[must_use]
    pub fn repository_path(&self) -> &DockerRepositoryPath {
        &self.repository
    }
}

impl fmt::Display for RegistryImagePath {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        if let Some(registry) = &self.registry {
            write!(formatter, "{registry}/{}", self.repository)
        } else {
            fmt::Display::fmt(&self.repository, formatter)
        }
    }
}

fn validate_registry(value: &str) -> Result<(), DockerRegistryError> {
    if value.is_empty() {
        return Err(DockerRegistryError::Empty);
    }
    if value.contains("//")
        || value.contains('/')
        || value.chars().any(char::is_whitespace)
        || !value
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b':'))
        || value.starts_with(['.', '-', ':'])
        || value.ends_with(['.', '-', ':'])
    {
        Err(DockerRegistryError::InvalidRegistry)
    } else {
        Ok(())
    }
}

fn validate_repository(value: &str) -> Result<(), DockerRegistryError> {
    if value.is_empty() {
        return Err(DockerRegistryError::Empty);
    }
    if value
        .split('/')
        .any(|component| !is_valid_component(component))
    {
        Err(DockerRegistryError::InvalidRepository)
    } else {
        Ok(())
    }
}

fn is_valid_component(value: &str) -> bool {
    !value.is_empty()
        && value.bytes().all(|byte| {
            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
        })
        && value
            .bytes()
            .next()
            .is_some_and(|byte| byte.is_ascii_alphanumeric())
        && value
            .bytes()
            .last()
            .is_some_and(|byte| byte.is_ascii_alphanumeric())
}

#[cfg(test)]
mod tests {
    use super::{DockerRegistry, DockerRepositoryPath, RegistryImagePath};

    #[test]
    fn renders_registry_image_paths() -> Result<(), Box<dyn std::error::Error>> {
        let registry = DockerRegistry::new("ghcr.io")?;
        let repository = DockerRepositoryPath::new("rustuse/app")?;
        let path = RegistryImagePath::new(Some(registry), repository);

        assert_eq!(path.to_string(), "ghcr.io/rustuse/app");
        assert_eq!(path.repository_path().repository(), "app");
        Ok(())
    }
}