#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DockerBuildError {
Empty,
InvalidArgKey,
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 {}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct BuildContext(String);
impl BuildContext {
pub fn new(value: impl AsRef<str>) -> Result<Self, DockerBuildError> {
Ok(Self(normalize_non_empty(value.as_ref())?))
}
#[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())
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct BuildArg {
key: String,
value: String,
}
impl BuildArg {
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(),
})
}
#[must_use]
pub fn key(&self) -> &str {
&self.key
}
#[must_use]
pub fn value(&self) -> &str {
&self.value
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct BuildTarget(String);
impl BuildTarget {
pub fn new(value: impl AsRef<str>) -> Result<Self, DockerBuildError> {
Ok(Self(normalize_non_empty(value.as_ref())?))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DockerPlatform {
os: String,
architecture: String,
variant: Option<String>,
}
impl DockerPlatform {
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,
})
}
pub fn with_variant(mut self, variant: impl AsRef<str>) -> Result<Self, DockerBuildError> {
self.variant = Some(normalize_platform_part(variant.as_ref())?);
Ok(self)
}
#[must_use]
pub fn os(&self) -> &str {
&self.os
}
#[must_use]
pub fn architecture(&self) -> &str {
&self.architecture
}
#[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),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum CacheMode {
Default,
NoCache,
Pull,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DockerBuildOptions {
context: BuildContext,
args: Vec<BuildArg>,
target: Option<BuildTarget>,
platform: Option<DockerPlatform>,
cache: CacheMode,
}
impl DockerBuildOptions {
#[must_use]
pub const fn new(context: BuildContext) -> Self {
Self {
context,
args: Vec::new(),
target: None,
platform: None,
cache: CacheMode::Default,
}
}
#[must_use]
pub fn with_arg(mut self, arg: BuildArg) -> Self {
self.args.push(arg);
self
}
#[must_use]
pub fn with_target(mut self, target: BuildTarget) -> Self {
self.target = Some(target);
self
}
#[must_use]
pub fn with_platform(mut self, platform: DockerPlatform) -> Self {
self.platform = Some(platform);
self
}
#[must_use]
pub const fn without_cache(mut self) -> Self {
self.cache = CacheMode::NoCache;
self
}
#[must_use]
pub const fn context(&self) -> &BuildContext {
&self.context
}
#[must_use]
pub fn args(&self) -> &[BuildArg] {
&self.args
}
#[must_use]
pub const fn target(&self) -> Option<&BuildTarget> {
self.target.as_ref()
}
#[must_use]
pub const fn platform(&self) -> Option<&DockerPlatform> {
self.platform.as_ref()
}
#[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(())
}
}