1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum DockerBuildError {
10 Empty,
12 InvalidArgKey,
14 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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
32pub struct BuildContext(String);
33
34impl BuildContext {
35 pub fn new(value: impl AsRef<str>) -> Result<Self, DockerBuildError> {
37 Ok(Self(normalize_non_empty(value.as_ref())?))
38 }
39
40 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
61pub struct BuildArg {
62 key: String,
63 value: String,
64}
65
66impl BuildArg {
67 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 #[must_use]
78 pub fn key(&self) -> &str {
79 &self.key
80 }
81
82 #[must_use]
84 pub fn value(&self) -> &str {
85 &self.value
86 }
87}
88
89#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
91pub struct BuildTarget(String);
92
93impl BuildTarget {
94 pub fn new(value: impl AsRef<str>) -> Result<Self, DockerBuildError> {
96 Ok(Self(normalize_non_empty(value.as_ref())?))
97 }
98
99 #[must_use]
101 pub fn as_str(&self) -> &str {
102 &self.0
103 }
104}
105
106#[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 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 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 #[must_use]
137 pub fn os(&self) -> &str {
138 &self.os
139 }
140
141 #[must_use]
143 pub fn architecture(&self) -> &str {
144 &self.architecture
145 }
146
147 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
179pub enum CacheMode {
180 Default,
182 NoCache,
184 Pull,
186}
187
188#[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 #[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 #[must_use]
213 pub fn with_arg(mut self, arg: BuildArg) -> Self {
214 self.args.push(arg);
215 self
216 }
217
218 #[must_use]
220 pub fn with_target(mut self, target: BuildTarget) -> Self {
221 self.target = Some(target);
222 self
223 }
224
225 #[must_use]
227 pub fn with_platform(mut self, platform: DockerPlatform) -> Self {
228 self.platform = Some(platform);
229 self
230 }
231
232 #[must_use]
234 pub const fn without_cache(mut self) -> Self {
235 self.cache = CacheMode::NoCache;
236 self
237 }
238
239 #[must_use]
241 pub const fn context(&self) -> &BuildContext {
242 &self.context
243 }
244
245 #[must_use]
247 pub fn args(&self) -> &[BuildArg] {
248 &self.args
249 }
250
251 #[must_use]
253 pub const fn target(&self) -> Option<&BuildTarget> {
254 self.target.as_ref()
255 }
256
257 #[must_use]
259 pub const fn platform(&self) -> Option<&DockerPlatform> {
260 self.platform.as_ref()
261 }
262
263 #[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}