1use std::collections::BTreeMap;
4use std::fmt;
5
6use crate::quoter::Quoter;
7
8#[derive(Debug)]
9pub enum Instruction {
11 ADD {
12 checksum: Option<String>,
13 chown: Option<String>,
14 chmod: Option<String>,
15 link: Option<String>,
16 sources: Vec<String>,
17 destination: String,
18 },
19 ARG(BTreeMap<String, Option<String>>),
20 CMD(Vec<String>),
21 COPY {
22 from: Option<String>,
23 chown: Option<String>,
24 chmod: Option<String>,
25 link: Option<String>,
26 sources: Vec<String>,
27 destination: String,
28 },
29 ENTRYPOINT(Vec<String>),
30 ENV(BTreeMap<String, String>),
31 EXPOSE {
32 ports: Vec<String>,
33 },
34 FROM {
35 platform: Option<String>,
36 image: String,
37 alias: Option<String>,
38 },
39 LABEL(BTreeMap<String, String>),
40 RUN {
56 mount: Option<String>,
57 network: Option<String>,
58 security: Option<String>,
59 command: Vec<String>,
60 heredoc: Option<Vec<String>>,
61 },
62 SHELL(Vec<String>),
63 STOPSIGNAL {
64 signal: String,
65 },
66 USER {
67 user: String,
68 group: Option<String>,
69 },
70 VOLUME {
71 mounts: Vec<String>,
72 },
73 WORKDIR {
74 path: String,
75 },
76 COMMENT(String),
80 EMPTY,
81}
82
83impl fmt::Display for Instruction {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 match self {
86 Instruction::ADD {
87 checksum,
88 chown,
89 chmod,
90 link,
91 sources,
92 destination,
93 } => {
94 let options = vec![
95 helpers::format_instruction_option("checksum", checksum),
96 helpers::format_instruction_option("chown", chown),
97 helpers::format_instruction_option("chmod", chmod),
98 helpers::format_instruction_option("link", link),
99 ];
100
101 let prefix = helpers::format_options_string(&options);
102 write!(f, "ADD {}{} {}", prefix, sources.join(" "), destination)
103 }
104 Instruction::ARG(args) => {
105 let arg_string = args
106 .iter()
107 .map(|(key, value)| {
108 if let Some(default) = value {
109 format!("{key}={default}")
110 } else {
111 key.to_owned()
112 }
113 })
114 .collect::<Vec<String>>()
115 .join(" ");
116
117 write!(f, "ARG {arg_string}")
118 }
119 Instruction::CMD(cmd) => write!(f, "CMD {cmd:?}"),
120 Instruction::COPY {
121 from,
122 chown,
123 chmod,
124 link,
125 sources,
126 destination,
127 } => {
128 let options = vec![
129 helpers::format_instruction_option("from", from),
130 helpers::format_instruction_option("chown", chown),
131 helpers::format_instruction_option("chmod", chmod),
132 helpers::format_instruction_option("link", link),
133 ];
134
135 let prefix = helpers::format_options_string(&options);
136 write!(f, "COPY {prefix}{} {destination}", sources.join(" "))
137 }
138 Instruction::ENTRYPOINT(entrypoint) => write!(f, "ENTRYPOINT {entrypoint:?}"),
139 Instruction::ENV(env) => {
140 write!(f, "ENV {}", helpers::format_btree_map(env))
141 }
142 Instruction::EXPOSE { ports } => write!(f, "EXPOSE {}", ports.join(" ")),
143 Instruction::FROM {
144 platform,
145 image,
146 alias,
147 } => {
148 let options = vec![helpers::format_instruction_option("platform", platform)];
149 let prefix = helpers::format_options_string(&options);
150
151 let mut line = format!("FROM {prefix}{image}");
152 if let Some(alias) = alias {
153 line.push_str(" AS ");
154 line.push_str(alias);
155 }
156
157 write!(f, "{line}")
158 }
159 Instruction::LABEL(labels) => {
160 write!(f, "LABEL {}", helpers::format_btree_map(labels))
161 }
162 Instruction::RUN {
163 mount,
164 network,
165 security,
166 command,
167 heredoc,
168 } => {
169 let options = vec![
170 helpers::format_instruction_option("mount", mount),
171 helpers::format_instruction_option("network", network),
172 helpers::format_instruction_option("security", security),
173 ];
174
175 let prefix = helpers::format_options_string(&options);
176
177 if let Some(heredoc) = heredoc {
178 write!(
179 f,
180 "RUN {prefix}{}\n{}",
181 command.join(" "),
182 heredoc.join("\n")
183 )
184 } else {
185 write!(f, "RUN {prefix}{command:?}")
186 }
187 }
188 Instruction::SHELL(shell) => write!(f, "SHELL {shell:?}"),
189 Instruction::STOPSIGNAL { signal } => write!(f, "STOPSIGNAL {signal}"),
190 Instruction::USER { user, group } => {
191 if let Some(group) = group {
192 write!(f, "USER {user}:{group}")
193 } else {
194 write!(f, "USER {user}")
195 }
196 }
197 Instruction::VOLUME { mounts } => write!(f, "VOLUME {mounts:?}"),
198 Instruction::WORKDIR { path } => write!(f, "WORKDIR {path}"),
199 Instruction::COMMENT(comment) => write!(f, "{comment}"),
203 Instruction::EMPTY => write!(f, ""),
204 }
205 }
206}
207
208mod helpers {
209 use super::*;
210
211 pub fn format_instruction_option(key: &str, value: &Option<String>) -> String {
212 value
213 .as_ref()
214 .map(|v| format!("--{key}={v}"))
215 .unwrap_or_default()
216 }
217
218 pub fn format_options_string(options: &[String]) -> String {
219 let result = options
220 .iter()
221 .filter(|s| !s.is_empty())
222 .cloned()
223 .collect::<Vec<String>>()
224 .join(" ");
225
226 if result.is_empty() {
227 String::new()
228 } else {
229 format!("{result} ")
231 }
232 }
233
234 pub fn format_btree_map(pairs: &BTreeMap<String, String>) -> String {
235 pairs
236 .iter()
237 .map(|(key, value)| format!("{key}={}", value.enquote()))
238 .collect::<Vec<String>>()
239 .join(" ")
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn test_display_instruction_add() {
249 let instruction = Instruction::ADD {
250 checksum: None,
251 chown: None,
252 chmod: None,
253 link: None,
254 sources: vec![String::from("source1"), String::from("source2")],
255 destination: String::from("/destination"),
256 };
257
258 let expected = "ADD source1 source2 /destination";
259 assert_eq!(instruction.to_string(), expected);
260 }
261
262 #[test]
263 fn test_display_instruction_arg() {
264 let instruction = Instruction::ARG(BTreeMap::from([
265 (String::from("ARG2"), None),
266 (String::from("ARG1"), Some(String::from("value1"))),
267 ]));
268
269 let expected = "ARG ARG1=value1 ARG2";
271 assert_eq!(instruction.to_string(), expected);
272 }
273
274 #[test]
275 fn test_display_instruction_cmd() {
276 let instruction =
277 Instruction::CMD(vec![String::from("echo"), String::from("Hello, World!")]);
278
279 let expected = "CMD [\"echo\", \"Hello, World!\"]";
280 assert_eq!(instruction.to_string(), expected);
281 }
282
283 #[test]
284 fn test_display_instruction_copy() {
285 let instruction = Instruction::COPY {
286 from: Some(String::from("builder")),
287 chown: None,
288 chmod: None,
289 link: None,
290 sources: vec![String::from("source1"), String::from("source2")],
291 destination: String::from("/destination"),
292 };
293
294 let expected = "COPY --from=builder source1 source2 /destination";
295 assert_eq!(instruction.to_string(), expected);
296 }
297
298 #[test]
299 fn test_display_instruction_entrypoint() {
300 let instruction = Instruction::ENTRYPOINT(vec![String::from("entrypoint.sh")]);
301
302 let expected = "ENTRYPOINT [\"entrypoint.sh\"]";
303 assert_eq!(instruction.to_string(), expected);
304 }
305
306 #[test]
307 fn test_display_instruction_env() {
308 let instruction = Instruction::ENV(BTreeMap::from([
309 (String::from("ENV2"), String::from("value2")),
310 (String::from("ENV1"), String::from("value1")),
311 ]));
312
313 let expected = "ENV ENV1=\"value1\" ENV2=\"value2\"";
315 assert_eq!(instruction.to_string(), expected);
316 }
317
318 #[test]
319 fn test_display_instruction_expose() {
320 let instruction = Instruction::EXPOSE {
321 ports: vec![String::from("80"), String::from("443")],
322 };
323
324 let expected = "EXPOSE 80 443";
325 assert_eq!(instruction.to_string(), expected);
326 }
327
328 #[test]
329 fn test_display_instruction_from() {
330 let instruction = Instruction::FROM {
331 platform: Some(String::from("linux/amd64")),
332 image: String::from("docker.io/library/fedora:latest"),
333 alias: Some(String::from("builder")),
334 };
335
336 let expected = "FROM --platform=linux/amd64 docker.io/library/fedora:latest AS builder";
337 assert_eq!(instruction.to_string(), expected);
338 }
339
340 #[test]
341 fn test_display_instruction_label() {
342 let instruction = Instruction::LABEL(BTreeMap::from([
343 (String::from("version"), String::from("1.0")),
344 (String::from("maintainer"), String::from("John Doe")),
345 ]));
346
347 let expected = "LABEL maintainer=\"John Doe\" version=\"1.0\"";
349 assert_eq!(instruction.to_string(), expected);
350 }
351
352 #[test]
353 fn test_display_instruction_run() {
354 let instruction = Instruction::RUN {
355 mount: None,
356 network: None,
357 security: None,
358 command: vec![String::from("echo"), String::from("Hello, World!")],
359 heredoc: None,
360 };
361
362 let expected = "RUN [\"echo\", \"Hello, World!\"]";
363 assert_eq!(instruction.to_string(), expected);
364 }
365
366 #[test]
367 fn test_display_instruction_run_with_heredoc() {
368 let instruction = Instruction::RUN {
369 mount: None,
370 network: None,
371 security: None,
372 command: vec![String::from("<<EOF")],
373 heredoc: Some(vec![
374 String::from("dnf upgrade -y"),
375 String::from("dnf install -y rustup"),
376 String::from("EOF"),
377 ]),
378 };
379
380 let expected = "RUN <<EOF\ndnf upgrade -y\ndnf install -y rustup\nEOF";
381 assert_eq!(instruction.to_string(), expected);
382 }
383
384 #[test]
385 fn test_display_instruction_run_with_heredoc_and_tabs() {
386 let instruction = Instruction::RUN {
387 mount: None,
388 network: None,
389 security: None,
390 command: vec![String::from("python"), String::from("<<EOF")],
391 heredoc: Some(vec![
392 String::from("def main():"),
393 String::from("\tx = 42"),
394 String::from("\tprint(x)"),
395 String::from(""),
396 String::from("main()"),
397 String::from("EOF"),
398 ]),
399 };
400
401 let expected = "RUN python <<EOF\ndef main():\n\tx = 42\n\tprint(x)\n\nmain()\nEOF";
402 assert_eq!(instruction.to_string(), expected);
403 }
404
405 #[test]
406 fn test_display_instruction_shell() {
407 let instruction = Instruction::SHELL(vec![String::from("/bin/sh"), String::from("-c")]);
408
409 let expected = "SHELL [\"/bin/sh\", \"-c\"]";
410 assert_eq!(instruction.to_string(), expected);
411 }
412
413 #[test]
414 fn test_display_instruction_user() {
415 let instruction = Instruction::USER {
416 user: String::from("root"),
417 group: Some(String::from("root")),
418 };
419
420 let expected = "USER root:root";
421 assert_eq!(instruction.to_string(), expected);
422 }
423
424 #[test]
425 fn test_display_instruction_volume() {
426 let instruction = Instruction::VOLUME {
427 mounts: vec![String::from("/data"), String::from("/var/log")],
428 };
429
430 let expected = "VOLUME [\"/data\", \"/var/log\"]";
431 assert_eq!(instruction.to_string(), expected);
432 }
433
434 #[test]
435 fn test_display_instruction_workdir() {
436 let instruction = Instruction::WORKDIR {
437 path: String::from("/app"),
438 };
439
440 let expected = "WORKDIR /app";
441 assert_eq!(instruction.to_string(), expected);
442 }
443
444 #[test]
445 fn test_display_instruction_comment() {
446 let instruction = Instruction::COMMENT(String::from("# This is a comment"));
447
448 let expected = "# This is a comment";
449 assert_eq!(instruction.to_string(), expected);
450 }
451
452 #[test]
453 fn test_display_instruction_empty() {
454 let instruction = Instruction::EMPTY;
455
456 let expected = "";
457 assert_eq!(instruction.to_string(), expected);
458 }
459}