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