Skip to main content

use_docker_build/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Error returned when Docker build metadata is invalid.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum DockerBuildError {
10    /// The value was empty after trimming.
11    Empty,
12    /// A build arg key was invalid.
13    InvalidArgKey,
14    /// A platform value was invalid.
15    InvalidPlatform,
16}
17
18impl fmt::Display for DockerBuildError {
19    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            Self::Empty => formatter.write_str("Docker build value cannot be empty"),
22            Self::InvalidArgKey => formatter.write_str("invalid Docker build arg key"),
23            Self::InvalidPlatform => formatter.write_str("invalid Docker platform"),
24        }
25    }
26}
27
28impl Error for DockerBuildError {}
29
30/// A build context path label.
31#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
32pub struct BuildContext(String);
33
34impl BuildContext {
35    /// Creates a build context label.
36    pub fn new(value: impl AsRef<str>) -> Result<Self, DockerBuildError> {
37        Ok(Self(normalize_non_empty(value.as_ref())?))
38    }
39
40    /// Returns the context path text.
41    #[must_use]
42    pub fn as_str(&self) -> &str {
43        &self.0
44    }
45}
46
47impl AsRef<str> for BuildContext {
48    fn as_ref(&self) -> &str {
49        self.as_str()
50    }
51}
52
53impl fmt::Display for BuildContext {
54    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
55        formatter.write_str(self.as_str())
56    }
57}
58
59/// A Docker build argument.
60#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
61pub struct BuildArg {
62    key: String,
63    value: String,
64}
65
66impl BuildArg {
67    /// Creates a build argument.
68    pub fn new(key: impl AsRef<str>, value: impl Into<String>) -> Result<Self, DockerBuildError> {
69        let key = normalize_arg_key(key.as_ref())?;
70        Ok(Self {
71            key,
72            value: value.into(),
73        })
74    }
75
76    /// Returns the argument key.
77    #[must_use]
78    pub fn key(&self) -> &str {
79        &self.key
80    }
81
82    /// Returns the argument value.
83    #[must_use]
84    pub fn value(&self) -> &str {
85        &self.value
86    }
87}
88
89/// A Docker target stage name.
90#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
91pub struct BuildTarget(String);
92
93impl BuildTarget {
94    /// Creates a target stage label.
95    pub fn new(value: impl AsRef<str>) -> Result<Self, DockerBuildError> {
96        Ok(Self(normalize_non_empty(value.as_ref())?))
97    }
98
99    /// Returns the target stage text.
100    #[must_use]
101    pub fn as_str(&self) -> &str {
102        &self.0
103    }
104}
105
106/// A Docker platform triple-like value.
107#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
108pub struct DockerPlatform {
109    os: String,
110    architecture: String,
111    variant: Option<String>,
112}
113
114impl DockerPlatform {
115    /// Creates a platform from OS and architecture labels.
116    pub fn new(
117        os: impl AsRef<str>,
118        architecture: impl AsRef<str>,
119    ) -> Result<Self, DockerBuildError> {
120        let os = normalize_platform_part(os.as_ref())?;
121        let architecture = normalize_platform_part(architecture.as_ref())?;
122        Ok(Self {
123            os,
124            architecture,
125            variant: None,
126        })
127    }
128
129    /// Adds a variant label.
130    pub fn with_variant(mut self, variant: impl AsRef<str>) -> Result<Self, DockerBuildError> {
131        self.variant = Some(normalize_platform_part(variant.as_ref())?);
132        Ok(self)
133    }
134
135    /// Returns the OS label.
136    #[must_use]
137    pub fn os(&self) -> &str {
138        &self.os
139    }
140
141    /// Returns the architecture label.
142    #[must_use]
143    pub fn architecture(&self) -> &str {
144        &self.architecture
145    }
146
147    /// Returns the optional variant label.
148    #[must_use]
149    pub fn variant(&self) -> Option<&str> {
150        self.variant.as_deref()
151    }
152}
153
154impl fmt::Display for DockerPlatform {
155    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
156        write!(formatter, "{}/{}", self.os, self.architecture)?;
157        if let Some(variant) = &self.variant {
158            write!(formatter, "/{variant}")?;
159        }
160        Ok(())
161    }
162}
163
164impl FromStr for DockerPlatform {
165    type Err = DockerBuildError;
166
167    fn from_str(value: &str) -> Result<Self, Self::Err> {
168        let parts = value.trim().split('/').collect::<Vec<_>>();
169        match parts.as_slice() {
170            [os, architecture] => Self::new(os, architecture),
171            [os, architecture, variant] => Self::new(os, architecture)?.with_variant(variant),
172            _ => Err(DockerBuildError::InvalidPlatform),
173        }
174    }
175}
176
177/// Docker build cache behavior.
178#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
179pub enum CacheMode {
180    /// Use normal cache behavior.
181    Default,
182    /// Disable build cache.
183    NoCache,
184    /// Pull newer base images where supported.
185    Pull,
186}
187
188/// Docker build option primitives.
189#[derive(Clone, Debug, Eq, PartialEq)]
190pub struct DockerBuildOptions {
191    context: BuildContext,
192    args: Vec<BuildArg>,
193    target: Option<BuildTarget>,
194    platform: Option<DockerPlatform>,
195    cache: CacheMode,
196}
197
198impl DockerBuildOptions {
199    /// Creates build options for a context.
200    #[must_use]
201    pub const fn new(context: BuildContext) -> Self {
202        Self {
203            context,
204            args: Vec::new(),
205            target: None,
206            platform: None,
207            cache: CacheMode::Default,
208        }
209    }
210
211    /// Adds a build argument.
212    #[must_use]
213    pub fn with_arg(mut self, arg: BuildArg) -> Self {
214        self.args.push(arg);
215        self
216    }
217
218    /// Adds a target stage.
219    #[must_use]
220    pub fn with_target(mut self, target: BuildTarget) -> Self {
221        self.target = Some(target);
222        self
223    }
224
225    /// Adds a platform.
226    #[must_use]
227    pub fn with_platform(mut self, platform: DockerPlatform) -> Self {
228        self.platform = Some(platform);
229        self
230    }
231
232    /// Disables build cache.
233    #[must_use]
234    pub const fn without_cache(mut self) -> Self {
235        self.cache = CacheMode::NoCache;
236        self
237    }
238
239    /// Returns the context.
240    #[must_use]
241    pub const fn context(&self) -> &BuildContext {
242        &self.context
243    }
244
245    /// Returns build arguments.
246    #[must_use]
247    pub fn args(&self) -> &[BuildArg] {
248        &self.args
249    }
250
251    /// Returns the optional target.
252    #[must_use]
253    pub const fn target(&self) -> Option<&BuildTarget> {
254        self.target.as_ref()
255    }
256
257    /// Returns the optional platform.
258    #[must_use]
259    pub const fn platform(&self) -> Option<&DockerPlatform> {
260        self.platform.as_ref()
261    }
262
263    /// Returns the cache mode.
264    #[must_use]
265    pub const fn cache(&self) -> CacheMode {
266        self.cache
267    }
268}
269
270fn normalize_non_empty(value: &str) -> Result<String, DockerBuildError> {
271    let trimmed = value.trim();
272    if trimmed.is_empty() || trimmed.contains(['\n', '\r']) {
273        Err(DockerBuildError::Empty)
274    } else {
275        Ok(trimmed.to_string())
276    }
277}
278
279fn normalize_arg_key(value: &str) -> Result<String, DockerBuildError> {
280    let trimmed = value.trim();
281    let mut chars = trimmed.chars();
282    let Some(first) = chars.next() else {
283        return Err(DockerBuildError::InvalidArgKey);
284    };
285    if !(first == '_' || first.is_ascii_alphabetic()) {
286        return Err(DockerBuildError::InvalidArgKey);
287    }
288    if chars.any(|character| !(character == '_' || character.is_ascii_alphanumeric())) {
289        return Err(DockerBuildError::InvalidArgKey);
290    }
291    Ok(trimmed.to_string())
292}
293
294fn normalize_platform_part(value: &str) -> Result<String, DockerBuildError> {
295    let trimmed = value.trim();
296    if trimmed.is_empty()
297        || !trimmed
298            .bytes()
299            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
300    {
301        Err(DockerBuildError::InvalidPlatform)
302    } else {
303        Ok(trimmed.to_ascii_lowercase())
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::{BuildArg, BuildContext, DockerBuildOptions, DockerPlatform};
310
311    #[test]
312    fn models_build_options() -> Result<(), Box<dyn std::error::Error>> {
313        let options = DockerBuildOptions::new(BuildContext::new(".")?)
314            .with_platform(DockerPlatform::new("linux", "amd64")?)
315            .with_arg(BuildArg::new("RUST_LOG", "info")?);
316
317        assert_eq!(options.context().as_str(), ".");
318        assert_eq!(options.platform().unwrap().to_string(), "linux/amd64");
319        assert_eq!(options.args()[0].key(), "RUST_LOG");
320        Ok(())
321    }
322}