1use std::collections::BTreeMap;
4use std::fmt;
5
6use strum_macros::Display;
7use strum_macros::EnumString;
8
9#[derive(Debug, Display, EnumString)]
10#[strum(serialize_all = "lowercase")]
11pub enum Protocol {
13 Tcp,
14 Udp,
15}
16
17#[derive(Debug)]
18pub enum Instruction {
20 Add {
21 checksum: Option<String>,
22 chown: Option<String>,
23 chmod: Option<String>,
24 link: Option<String>,
25 sources: Vec<String>,
26 destination: String,
27 },
28 Arg(BTreeMap<String, Option<String>>),
29 Cmd(Vec<String>),
30 Copy {
31 from: Option<String>,
32 chown: Option<String>,
33 chmod: Option<String>,
34 link: Option<String>,
35 sources: Vec<String>,
36 destination: String,
37 },
38 Entrypoint(Vec<String>),
39 Env(BTreeMap<String, String>),
40 Expose {
41 port: String,
42 protocol: Option<Protocol>,
43 },
44 From {
45 platform: Option<String>,
46 image: String,
47 alias: Option<String>,
48 },
49 Label(BTreeMap<String, String>),
50 Run {
51 mount: Option<String>,
52 network: Option<String>,
53 security: Option<String>,
54 command: Vec<String>,
55 },
56 Shell(Vec<String>),
57 User {
58 user: String,
59 group: Option<String>,
60 },
61 Volume {
62 mounts: Vec<String>,
63 },
64 Workdir {
65 path: String,
66 },
67 Comment(String),
71 Empty,
72}
73
74impl fmt::Display for Instruction {
75 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76 match self {
77 Instruction::Add {
78 checksum,
79 chown,
80 chmod,
81 link,
82 sources,
83 destination,
84 } => {
85 let options = vec![
86 helpers::format_instruction_option("checksum", checksum),
87 helpers::format_instruction_option("chown", chown),
88 helpers::format_instruction_option("chmod", chmod),
89 helpers::format_instruction_option("link", link),
90 ];
91 let options_string = helpers::format_options_string(options);
92 let prefix = if options_string.is_empty() {
93 String::new()
94 } else {
95 format!("{} ", options_string)
96 };
97 write!(f, "ADD {}{} {}", prefix, sources.join(" "), destination)
98 }
99 Instruction::Arg(args) => {
100 let arg_string = args
101 .iter()
102 .map(|(key, value)| {
103 if let Some(default) = value {
104 format!("{}={}", key, default)
105 } else {
106 key.to_owned()
107 }
108 })
109 .collect::<Vec<String>>()
110 .join(" ");
111 write!(f, "ARG {}", arg_string)
112 }
113 Instruction::Cmd(cmd) => write!(f, "CMD {:?}", cmd),
114 Instruction::Copy {
115 from,
116 chown,
117 chmod,
118 link,
119 sources,
120 destination,
121 } => {
122 let options = vec![
123 helpers::format_instruction_option("from", from),
124 helpers::format_instruction_option("chown", chown),
125 helpers::format_instruction_option("chmod", chmod),
126 helpers::format_instruction_option("link", link),
127 ];
128 let options_string = helpers::format_options_string(options);
129 let prefix = if options_string.is_empty() {
130 String::new()
131 } else {
132 format!("{} ", options_string)
133 };
134 write!(f, "COPY {}{} {}", prefix, sources.join(" "), destination)
135 }
136 Instruction::Entrypoint(entrypoint) => write!(f, "ENTRYPOINT {:?}", entrypoint),
137 Instruction::Env(env) => {
138 write!(f, "ENV {}", helpers::format_btree_map(env))
139 }
140 Instruction::Expose { port, protocol } => {
141 if let Some(protocol) = protocol {
142 write!(f, "EXPOSE {}/{}", port, protocol)
143 } else {
144 write!(f, "EXPOSE {}", port)
145 }
146 }
147 Instruction::From {
148 platform,
149 image,
150 alias,
151 } => {
152 let options = vec![helpers::format_instruction_option("platform", platform)];
153 let options_string = helpers::format_options_string(options);
154 let prefix = if options_string.is_empty() {
155 String::new()
156 } else {
157 format!("{} ", options_string)
158 };
159 let mut line = format!("FROM {}{}", prefix, image);
160
161 if let Some(alias) = alias {
162 line.push_str(&format!(" AS {}", alias));
163 }
164 write!(f, "{}", line)
165 }
166 Instruction::Label(labels) => {
167 write!(f, "LABEL {}", helpers::format_btree_map(labels))
168 }
169 Instruction::Run {
170 mount,
171 network,
172 security,
173 command,
174 } => {
175 let options = vec![
176 helpers::format_instruction_option("mount", mount),
177 helpers::format_instruction_option("network", network),
178 helpers::format_instruction_option("security", security),
179 ];
180 let options_string = helpers::format_options_string(options);
181 let prefix = if options_string.is_empty() {
182 String::new()
183 } else {
184 format!("{} ", options_string)
185 };
186 write!(f, "RUN {}{:?}", prefix, command)
187 }
188 Instruction::Shell(shell) => write!(f, "SHELL {:?}", shell),
189 Instruction::User { user, group } => {
190 if let Some(group) = group {
191 write!(f, "USER {}:{}", user, group)
192 } else {
193 write!(f, "USER {}", user)
194 }
195 }
196 Instruction::Volume { mounts } => write!(f, "VOLUME {:?}", mounts),
197 Instruction::Workdir { path } => write!(f, "WORKDIR {}", path),
198 Instruction::Comment(comment) => write!(f, "{}", comment),
202 Instruction::Empty => write!(f, ""),
203 }
204 }
205}
206
207mod helpers {
208 use super::*;
209
210 pub fn format_instruction_option(key: &str, value: &Option<String>) -> String {
211 value
212 .as_ref()
213 .map(|v| format!("--{}={}", key, v))
214 .unwrap_or_default()
215 }
216
217 pub fn format_options_string(options: Vec<String>) -> String {
218 options
219 .into_iter()
220 .filter(|s| !s.is_empty())
221 .collect::<Vec<String>>()
222 .join(" ")
223 }
224
225 pub fn format_btree_map(pairs: &BTreeMap<String, String>) -> String {
226 pairs
227 .iter()
228 .map(|(key, value)| format!("{}=\"{}\"", key, value))
229 .collect::<Vec<String>>()
230 .join(" ")
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn test_display_instruction_add() {
240 let instruction = Instruction::Add {
241 checksum: None,
242 chown: None,
243 chmod: None,
244 link: None,
245 sources: vec![String::from("source1"), String::from("source2")],
246 destination: String::from("/destination"),
247 };
248
249 let expected = "ADD source1 source2 /destination";
250 assert_eq!(format!("{}", instruction), expected);
251 }
252
253 #[test]
254 fn test_display_instruction_arg() {
255 let instruction = Instruction::Arg(BTreeMap::from([
256 (String::from("ARG2"), None),
257 (String::from("ARG1"), Some(String::from("value1"))),
258 ]));
259
260 let expected = "ARG ARG1=value1 ARG2";
262 assert_eq!(format!("{}", instruction), expected);
263 }
264
265 #[test]
266 fn test_display_instruction_cmd() {
267 let instruction =
268 Instruction::Cmd(vec![String::from("echo"), String::from("Hello, World!")]);
269
270 let expected = "CMD [\"echo\", \"Hello, World!\"]";
271 assert_eq!(format!("{}", instruction), expected);
272 }
273
274 #[test]
275 fn test_display_instruction_copy() {
276 let instruction = Instruction::Copy {
277 from: Some(String::from("builder")),
278 chown: None,
279 chmod: None,
280 link: None,
281 sources: vec![String::from("source1"), String::from("source2")],
282 destination: String::from("/destination"),
283 };
284
285 let expected = "COPY --from=builder source1 source2 /destination";
286 assert_eq!(format!("{}", instruction), expected);
287 }
288
289 #[test]
290 fn test_display_instruction_entrypoint() {
291 let instruction = Instruction::Entrypoint(vec![String::from("entrypoint.sh")]);
292
293 let expected = "ENTRYPOINT [\"entrypoint.sh\"]";
294 assert_eq!(format!("{}", instruction), expected);
295 }
296
297 #[test]
298 fn test_display_instruction_env() {
299 let instruction = Instruction::Env(BTreeMap::from([
300 (String::from("ENV2"), String::from("value2")),
301 (String::from("ENV1"), String::from("value1")),
302 ]));
303
304 let expected = "ENV ENV1=\"value1\" ENV2=\"value2\"";
306 assert_eq!(format!("{}", instruction), expected);
307 }
308
309 #[test]
310 fn test_display_instruction_expose() {
311 let instruction = Instruction::Expose {
312 port: String::from("8080"),
313 protocol: Some(Protocol::Udp),
314 };
315
316 let expected = "EXPOSE 8080/udp";
317 assert_eq!(format!("{}", instruction), expected);
318 }
319
320 #[test]
321 fn test_display_instruction_from() {
322 let instruction = Instruction::From {
323 platform: Some(String::from("linux/amd64")),
324 image: String::from("docker.io/library/fedora:latest"),
325 alias: Some(String::from("builder")),
326 };
327
328 let expected = "FROM --platform=linux/amd64 docker.io/library/fedora:latest AS builder";
329 assert_eq!(format!("{}", instruction), expected);
330 }
331
332 #[test]
333 fn test_display_instruction_label() {
334 let instruction = Instruction::Label(BTreeMap::from([
335 (String::from("version"), String::from("1.0")),
336 (String::from("maintainer"), String::from("John Doe")),
337 ]));
338
339 let expected = "LABEL maintainer=\"John Doe\" version=\"1.0\"";
341 assert_eq!(format!("{}", instruction), expected);
342 }
343
344 #[test]
345 fn test_display_instruction_run() {
346 let instruction = Instruction::Run {
347 mount: None,
348 network: None,
349 security: None,
350 command: vec![String::from("echo"), String::from("Hello, World!")],
351 };
352
353 let expected = "RUN [\"echo\", \"Hello, World!\"]";
354 assert_eq!(format!("{}", instruction), expected);
355 }
356
357 #[test]
358 fn test_display_instruction_shell() {
359 let instruction = Instruction::Shell(vec![String::from("/bin/sh"), String::from("-c")]);
360
361 let expected = "SHELL [\"/bin/sh\", \"-c\"]";
362 assert_eq!(format!("{}", instruction), expected);
363 }
364
365 #[test]
366 fn test_display_instruction_user() {
367 let instruction = Instruction::User {
368 user: String::from("root"),
369 group: Some(String::from("root")),
370 };
371
372 let expected = "USER root:root";
373 assert_eq!(format!("{}", instruction), expected);
374 }
375
376 #[test]
377 fn test_display_instruction_volume() {
378 let instruction = Instruction::Volume {
379 mounts: vec![String::from("/data"), String::from("/var/log")],
380 };
381
382 let expected = "VOLUME [\"/data\", \"/var/log\"]";
383 assert_eq!(format!("{}", instruction), expected);
384 }
385
386 #[test]
387 fn test_display_instruction_workdir() {
388 let instruction = Instruction::Workdir {
389 path: String::from("/app"),
390 };
391
392 let expected = "WORKDIR /app";
393 assert_eq!(format!("{}", instruction), expected);
394 }
395
396 #[test]
397 fn test_display_instruction_comment() {
398 let instruction = Instruction::Comment(String::from("# This is a comment"));
399
400 let expected = "# This is a comment";
401 assert_eq!(format!("{}", instruction), expected);
402 }
403
404 #[test]
405 fn test_display_instruction_empty() {
406 let instruction = Instruction::Empty;
407
408 let expected = "";
409 assert_eq!(format!("{}", instruction), expected);
410 }
411}