use-docker-build 0.0.1

Primitive Docker build option 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 build metadata is invalid.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DockerBuildError {
    /// The value was empty after trimming.
    Empty,
    /// A build arg key was invalid.
    InvalidArgKey,
    /// A platform value was invalid.
    InvalidPlatform,
}

impl fmt::Display for DockerBuildError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("Docker build value cannot be empty"),
            Self::InvalidArgKey => formatter.write_str("invalid Docker build arg key"),
            Self::InvalidPlatform => formatter.write_str("invalid Docker platform"),
        }
    }
}

impl Error for DockerBuildError {}

/// A build context path label.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct BuildContext(String);

impl BuildContext {
    /// Creates a build context label.
    pub fn new(value: impl AsRef<str>) -> Result<Self, DockerBuildError> {
        Ok(Self(normalize_non_empty(value.as_ref())?))
    }

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

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

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

/// A Docker build argument.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct BuildArg {
    key: String,
    value: String,
}

impl BuildArg {
    /// Creates a build argument.
    pub fn new(key: impl AsRef<str>, value: impl Into<String>) -> Result<Self, DockerBuildError> {
        let key = normalize_arg_key(key.as_ref())?;
        Ok(Self {
            key,
            value: value.into(),
        })
    }

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

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

/// A Docker target stage name.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct BuildTarget(String);

impl BuildTarget {
    /// Creates a target stage label.
    pub fn new(value: impl AsRef<str>) -> Result<Self, DockerBuildError> {
        Ok(Self(normalize_non_empty(value.as_ref())?))
    }

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

/// A Docker platform triple-like value.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DockerPlatform {
    os: String,
    architecture: String,
    variant: Option<String>,
}

impl DockerPlatform {
    /// Creates a platform from OS and architecture labels.
    pub fn new(
        os: impl AsRef<str>,
        architecture: impl AsRef<str>,
    ) -> Result<Self, DockerBuildError> {
        let os = normalize_platform_part(os.as_ref())?;
        let architecture = normalize_platform_part(architecture.as_ref())?;
        Ok(Self {
            os,
            architecture,
            variant: None,
        })
    }

    /// Adds a variant label.
    pub fn with_variant(mut self, variant: impl AsRef<str>) -> Result<Self, DockerBuildError> {
        self.variant = Some(normalize_platform_part(variant.as_ref())?);
        Ok(self)
    }

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

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

    /// Returns the optional variant label.
    #[must_use]
    pub fn variant(&self) -> Option<&str> {
        self.variant.as_deref()
    }
}

impl fmt::Display for DockerPlatform {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "{}/{}", self.os, self.architecture)?;
        if let Some(variant) = &self.variant {
            write!(formatter, "/{variant}")?;
        }
        Ok(())
    }
}

impl FromStr for DockerPlatform {
    type Err = DockerBuildError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        let parts = value.trim().split('/').collect::<Vec<_>>();
        match parts.as_slice() {
            [os, architecture] => Self::new(os, architecture),
            [os, architecture, variant] => Self::new(os, architecture)?.with_variant(variant),
            _ => Err(DockerBuildError::InvalidPlatform),
        }
    }
}

/// Docker build cache behavior.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum CacheMode {
    /// Use normal cache behavior.
    Default,
    /// Disable build cache.
    NoCache,
    /// Pull newer base images where supported.
    Pull,
}

/// Docker build option primitives.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DockerBuildOptions {
    context: BuildContext,
    args: Vec<BuildArg>,
    target: Option<BuildTarget>,
    platform: Option<DockerPlatform>,
    cache: CacheMode,
}

impl DockerBuildOptions {
    /// Creates build options for a context.
    #[must_use]
    pub const fn new(context: BuildContext) -> Self {
        Self {
            context,
            args: Vec::new(),
            target: None,
            platform: None,
            cache: CacheMode::Default,
        }
    }

    /// Adds a build argument.
    #[must_use]
    pub fn with_arg(mut self, arg: BuildArg) -> Self {
        self.args.push(arg);
        self
    }

    /// Adds a target stage.
    #[must_use]
    pub fn with_target(mut self, target: BuildTarget) -> Self {
        self.target = Some(target);
        self
    }

    /// Adds a platform.
    #[must_use]
    pub fn with_platform(mut self, platform: DockerPlatform) -> Self {
        self.platform = Some(platform);
        self
    }

    /// Disables build cache.
    #[must_use]
    pub const fn without_cache(mut self) -> Self {
        self.cache = CacheMode::NoCache;
        self
    }

    /// Returns the context.
    #[must_use]
    pub const fn context(&self) -> &BuildContext {
        &self.context
    }

    /// Returns build arguments.
    #[must_use]
    pub fn args(&self) -> &[BuildArg] {
        &self.args
    }

    /// Returns the optional target.
    #[must_use]
    pub const fn target(&self) -> Option<&BuildTarget> {
        self.target.as_ref()
    }

    /// Returns the optional platform.
    #[must_use]
    pub const fn platform(&self) -> Option<&DockerPlatform> {
        self.platform.as_ref()
    }

    /// Returns the cache mode.
    #[must_use]
    pub const fn cache(&self) -> CacheMode {
        self.cache
    }
}

fn normalize_non_empty(value: &str) -> Result<String, DockerBuildError> {
    let trimmed = value.trim();
    if trimmed.is_empty() || trimmed.contains(['\n', '\r']) {
        Err(DockerBuildError::Empty)
    } else {
        Ok(trimmed.to_string())
    }
}

fn normalize_arg_key(value: &str) -> Result<String, DockerBuildError> {
    let trimmed = value.trim();
    let mut chars = trimmed.chars();
    let Some(first) = chars.next() else {
        return Err(DockerBuildError::InvalidArgKey);
    };
    if !(first == '_' || first.is_ascii_alphabetic()) {
        return Err(DockerBuildError::InvalidArgKey);
    }
    if chars.any(|character| !(character == '_' || character.is_ascii_alphanumeric())) {
        return Err(DockerBuildError::InvalidArgKey);
    }
    Ok(trimmed.to_string())
}

fn normalize_platform_part(value: &str) -> Result<String, DockerBuildError> {
    let trimmed = value.trim();
    if trimmed.is_empty()
        || !trimmed
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
    {
        Err(DockerBuildError::InvalidPlatform)
    } else {
        Ok(trimmed.to_ascii_lowercase())
    }
}

#[cfg(test)]
mod tests {
    use super::{BuildArg, BuildContext, DockerBuildOptions, DockerPlatform};

    #[test]
    fn models_build_options() -> Result<(), Box<dyn std::error::Error>> {
        let options = DockerBuildOptions::new(BuildContext::new(".")?)
            .with_platform(DockerPlatform::new("linux", "amd64")?)
            .with_arg(BuildArg::new("RUST_LOG", "info")?);

        assert_eq!(options.context().as_str(), ".");
        assert_eq!(options.platform().unwrap().to_string(), "linux/amd64");
        assert_eq!(options.args()[0].key(), "RUST_LOG");
        Ok(())
    }
}