1use std::collections::BTreeMap;
4use std::fmt;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum Instruction {
9 Add {
26 checksum: Option<String>,
27 chown: Option<String>,
28 chmod: Option<String>,
29 link: Option<String>,
30 sources: Vec<String>,
31 destination: String,
32 },
33 Arg(BTreeMap<String, Option<String>>),
48 Cmd(Vec<String>),
61 Comment(String),
71 Copy {
88 from: Option<String>,
89 chown: Option<String>,
90 chmod: Option<String>,
91 link: Option<String>,
92 sources: Vec<String>,
93 destination: String,
94 },
95 Empty,
105 Entrypoint(Vec<String>),
115 Env(BTreeMap<String, String>),
130 Expose { ports: Vec<String> },
142 From {
156 platform: Option<String>,
157 image: String,
158 alias: Option<String>,
159 },
160 Label(BTreeMap<String, String>),
175 Run {
195 mount: Option<String>,
196 network: Option<String>,
197 security: Option<String>,
198 command: Vec<String>,
199 heredoc: Option<Vec<String>>,
200 },
201 Shell(Vec<String>),
211 Stopsignal { signal: String },
223 User { user: String, group: Option<String> },
236 Volume { mounts: Vec<String> },
248 Workdir { path: String },
260}
261
262impl fmt::Display for Instruction {
263 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264 match self {
265 Self::Add {
266 checksum,
267 chown,
268 chmod,
269 link,
270 sources,
271 destination,
272 } => {
273 let options = vec![
274 helpers::format_instruction_option("checksum", checksum.as_ref()),
275 helpers::format_instruction_option("chown", chown.as_ref()),
276 helpers::format_instruction_option("chmod", chmod.as_ref()),
277 helpers::format_instruction_option("link", link.as_ref()),
278 ];
279 let prefix = helpers::format_options_string(&options);
280 write!(f, "ADD {prefix}{} {destination}", sources.join(" "))
281 }
282 Self::Arg(args) => write!(f, "ARG {}", helpers::format_optional_btree_map(args)),
283 Self::Cmd(cmd) => write!(f, "CMD {cmd:?}"),
284 Self::Comment(comment) => write!(f, "{comment}"),
285 Self::Copy {
286 from,
287 chown,
288 chmod,
289 link,
290 sources,
291 destination,
292 } => {
293 let options = vec![
294 helpers::format_instruction_option("from", from.as_ref()),
295 helpers::format_instruction_option("chown", chown.as_ref()),
296 helpers::format_instruction_option("chmod", chmod.as_ref()),
297 helpers::format_instruction_option("link", link.as_ref()),
298 ];
299 let prefix = helpers::format_options_string(&options);
300 write!(f, "COPY {prefix}{} {destination}", sources.join(" "))
301 }
302 Self::Empty => write!(f, ""),
303 Self::Entrypoint(entrypoint) => write!(f, "ENTRYPOINT {entrypoint:?}"),
304 Self::Env(env) => write!(f, "ENV {}", helpers::format_btree_map(env)),
305 Self::Expose { ports } => write!(f, "EXPOSE {}", ports.join(" ")),
306 Self::From {
307 platform,
308 image,
309 alias,
310 } => {
311 let options = vec![helpers::format_instruction_option(
312 "platform",
313 platform.as_ref(),
314 )];
315 let prefix = helpers::format_options_string(&options);
316
317 let mut line = format!("FROM {prefix}{image}");
318 if let Some(alias) = alias {
319 line.push_str(" AS ");
320 line.push_str(alias);
321 }
322 write!(f, "{line}")
323 }
324 Self::Label(labels) => write!(f, "LABEL {}", helpers::format_btree_map(labels)),
325 Self::Run {
326 mount,
327 network,
328 security,
329 command,
330 heredoc,
331 } => {
332 let options = vec![
333 helpers::format_instruction_option("mount", mount.as_ref()),
334 helpers::format_instruction_option("network", network.as_ref()),
335 helpers::format_instruction_option("security", security.as_ref()),
336 ];
337 let prefix = helpers::format_options_string(&options);
338 match heredoc {
339 Some(heredoc) => write!(
340 f,
341 "RUN {prefix}{}\n{}",
342 command.join(" "),
343 heredoc.join("\n")
344 ),
345 None => write!(f, "RUN {prefix}{command:?}"),
346 }
347 }
348 Self::Shell(shell) => write!(f, "SHELL {shell:?}"),
349 Self::Stopsignal { signal } => write!(f, "STOPSIGNAL {signal}"),
350 Self::User { user, group } => match group {
351 Some(group) => write!(f, "USER {user}:{group}"),
352 None => write!(f, "USER {user}"),
353 },
354 Self::Volume { mounts } => write!(f, "VOLUME {mounts:?}"),
355 Self::Workdir { path } => write!(f, "WORKDIR {path}"),
356 }
357 }
358}
359
360mod helpers {
361 use std::collections::BTreeMap;
362
363 use crate::quoter::Quoter;
364
365 pub fn format_instruction_option(key: &str, value: Option<&String>) -> String {
366 value
367 .as_ref()
368 .map(|v| format!("--{key}={v}"))
369 .unwrap_or_default()
370 }
371
372 pub fn format_options_string(options: &[String]) -> String {
373 let result = options
374 .iter()
375 .filter(|s| !s.is_empty())
376 .cloned()
377 .collect::<Vec<String>>()
378 .join(" ");
379
380 if result.is_empty() {
381 String::new()
382 } else {
383 format!("{result} ")
385 }
386 }
387
388 pub fn format_btree_map(pairs: &BTreeMap<String, String>) -> String {
389 pairs
390 .iter()
391 .map(|(key, value)| format!("{key}={}", value.enquote()))
392 .collect::<Vec<String>>()
393 .join(" ")
394 }
395
396 pub fn format_optional_btree_map(pairs: &BTreeMap<String, Option<String>>) -> String {
397 pairs
398 .iter()
399 .map(|(k, v)| v.as_ref().map_or_else(|| k.clone(), |v| format!("{k}={v}")))
400 .collect::<Vec<_>>()
401 .join(" ")
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
410 fn test_display_instruction_add() {
411 let instruction = Instruction::Add {
412 checksum: None,
413 chown: None,
414 chmod: None,
415 link: None,
416 sources: vec![String::from("source1"), String::from("source2")],
417 destination: String::from("/destination"),
418 };
419
420 let expected = "ADD source1 source2 /destination";
421 assert_eq!(instruction.to_string(), expected);
422 }
423
424 #[test]
425 fn test_display_instruction_arg() {
426 let instruction = Instruction::Arg(BTreeMap::from([
427 (String::from("ARG2"), None),
428 (String::from("ARG1"), Some(String::from("value1"))),
429 ]));
430
431 let expected = "ARG ARG1=value1 ARG2";
433 assert_eq!(instruction.to_string(), expected);
434 }
435
436 #[test]
437 fn test_display_instruction_cmd() {
438 let instruction =
439 Instruction::Cmd(vec![String::from("echo"), String::from("Hello, World!")]);
440
441 let expected = "CMD [\"echo\", \"Hello, World!\"]";
442 assert_eq!(instruction.to_string(), expected);
443 }
444
445 #[test]
446 fn test_display_instruction_copy() {
447 let instruction = Instruction::Copy {
448 from: Some(String::from("builder")),
449 chown: None,
450 chmod: None,
451 link: None,
452 sources: vec![String::from("source1"), String::from("source2")],
453 destination: String::from("/destination"),
454 };
455
456 let expected = "COPY --from=builder source1 source2 /destination";
457 assert_eq!(instruction.to_string(), expected);
458 }
459
460 #[test]
461 fn test_display_instruction_entrypoint() {
462 let instruction = Instruction::Entrypoint(vec![String::from("entrypoint.sh")]);
463
464 let expected = "ENTRYPOINT [\"entrypoint.sh\"]";
465 assert_eq!(instruction.to_string(), expected);
466 }
467
468 #[test]
469 fn test_display_instruction_env() {
470 let instruction = Instruction::Env(BTreeMap::from([
471 (String::from("ENV2"), String::from("value2")),
472 (String::from("ENV1"), String::from("value1")),
473 ]));
474
475 let expected = "ENV ENV1=\"value1\" ENV2=\"value2\"";
477 assert_eq!(instruction.to_string(), expected);
478 }
479
480 #[test]
481 fn test_display_instruction_expose() {
482 let instruction = Instruction::Expose {
483 ports: vec![String::from("80"), String::from("443")],
484 };
485
486 let expected = "EXPOSE 80 443";
487 assert_eq!(instruction.to_string(), expected);
488 }
489
490 #[test]
491 fn test_display_instruction_from() {
492 let instruction = Instruction::From {
493 platform: Some(String::from("linux/amd64")),
494 image: String::from("docker.io/library/fedora:latest"),
495 alias: Some(String::from("builder")),
496 };
497
498 let expected = "FROM --platform=linux/amd64 docker.io/library/fedora:latest AS builder";
499 assert_eq!(instruction.to_string(), expected);
500 }
501
502 #[test]
503 fn test_display_instruction_label() {
504 let instruction = Instruction::Label(BTreeMap::from([
505 (String::from("version"), String::from("1.0")),
506 (String::from("maintainer"), String::from("John Doe")),
507 ]));
508
509 let expected = "LABEL maintainer=\"John Doe\" version=\"1.0\"";
511 assert_eq!(instruction.to_string(), expected);
512 }
513
514 #[test]
515 fn test_display_instruction_run() {
516 let instruction = Instruction::Run {
517 mount: None,
518 network: None,
519 security: None,
520 command: vec![String::from("cat"), String::from("/etc/os-release")],
521 heredoc: None,
522 };
523
524 let expected = "RUN [\"cat\", \"/etc/os-release\"]";
525 assert_eq!(instruction.to_string(), expected);
526 }
527
528 #[test]
529 fn test_display_instruction_run_with_heredoc() {
530 let instruction = Instruction::Run {
531 mount: None,
532 network: None,
533 security: None,
534 command: vec![String::from("<<EOF")],
535 heredoc: Some(vec![
536 String::from("dnf upgrade -y"),
537 String::from("dnf install -y rustup"),
538 String::from("EOF"),
539 ]),
540 };
541
542 let expected = "RUN <<EOF\ndnf upgrade -y\ndnf install -y rustup\nEOF";
543 assert_eq!(instruction.to_string(), expected);
544 }
545
546 #[test]
547 fn test_display_instruction_run_with_heredoc_and_tabs() {
548 let instruction = Instruction::Run {
549 mount: None,
550 network: None,
551 security: None,
552 command: vec![String::from("python"), String::from("<<EOF")],
553 heredoc: Some(vec![
554 String::from("def main():"),
555 String::from("\tx = 42"),
556 String::from("\tprint(x)"),
557 String::from(""),
558 String::from("main()"),
559 String::from("EOF"),
560 ]),
561 };
562
563 let expected = "RUN python <<EOF\ndef main():\n\tx = 42\n\tprint(x)\n\nmain()\nEOF";
564 assert_eq!(instruction.to_string(), expected);
565 }
566
567 #[test]
568 fn test_display_instruction_shell() {
569 let instruction = Instruction::Shell(vec![String::from("/bin/sh"), String::from("-c")]);
570
571 let expected = "SHELL [\"/bin/sh\", \"-c\"]";
572 assert_eq!(instruction.to_string(), expected);
573 }
574
575 #[test]
576 fn test_display_instruction_user() {
577 let instruction = Instruction::User {
578 user: String::from("root"),
579 group: Some(String::from("root")),
580 };
581
582 let expected = "USER root:root";
583 assert_eq!(instruction.to_string(), expected);
584 }
585
586 #[test]
587 fn test_display_instruction_volume() {
588 let instruction = Instruction::Volume {
589 mounts: vec![String::from("/data"), String::from("/var/log")],
590 };
591
592 let expected = "VOLUME [\"/data\", \"/var/log\"]";
593 assert_eq!(instruction.to_string(), expected);
594 }
595
596 #[test]
597 fn test_display_instruction_workdir() {
598 let instruction = Instruction::Workdir {
599 path: String::from("/app"),
600 };
601
602 let expected = "WORKDIR /app";
603 assert_eq!(instruction.to_string(), expected);
604 }
605
606 #[test]
607 fn test_display_instruction_comment() {
608 let instruction = Instruction::Comment(String::from("# This is a comment"));
609
610 let expected = "# This is a comment";
611 assert_eq!(instruction.to_string(), expected);
612 }
613
614 #[test]
615 fn test_display_instruction_empty() {
616 let instruction = Instruction::Empty;
617
618 let expected = "";
619 assert_eq!(instruction.to_string(), expected);
620 }
621}