dockerfile_parser_rs/
ast.rs

1// https://docs.docker.com/reference/dockerfile/#overview
2
3use std::collections::BTreeMap;
4use std::fmt;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7/// This enum represents available instructions in a Dockerfile and their associated data.
8pub enum Instruction {
9    /// Represents an ADD instruction in the Dockerfile.
10    ///
11    /// ### Example
12    ///
13    /// ```
14    /// use dockerfile_parser_rs::Instruction;
15    ///
16    /// let add = Instruction::Add {
17    ///     checksum: None,
18    ///     chown: None,
19    ///     chmod: None,
20    ///     link: None,
21    ///     sources: Vec::from([String::from("source1"), String::from("source2")]),
22    ///     destination: String::from("/destination"),
23    /// };
24    /// ```
25    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    /// Represents an ARG instruction in the Dockerfile.
34    ///
35    /// ### Example
36    ///
37    /// ```
38    /// use std::collections::BTreeMap;
39    ///
40    /// use dockerfile_parser_rs::Instruction;
41    ///
42    /// let arg = Instruction::Arg(BTreeMap::from([
43    ///     (String::from("ARG1"), Some(String::from("value1"))),
44    ///     (String::from("ARG2"), None),
45    /// ]));
46    /// ```
47    Arg(BTreeMap<String, Option<String>>),
48    /// Represents a CMD instruction in the Dockerfile.
49    ///
50    /// ### Example
51    ///
52    /// ```
53    /// use dockerfile_parser_rs::Instruction;
54    ///
55    /// let cmd = Instruction::Cmd(Vec::from([
56    ///     String::from("echo"),
57    ///     String::from("Hello, World!"),
58    /// ]));
59    /// ```
60    Cmd(Vec<String>),
61    /// Represents a comment in the Dockerfile.
62    ///
63    /// ### Example
64    ///
65    /// ```
66    /// use dockerfile_parser_rs::Instruction;
67    ///
68    /// let comment = Instruction::Comment(String::from("# This is a comment"));
69    /// ```
70    Comment(String),
71    /// Represents a COPY instruction in the Dockerfile.
72    ///
73    /// ### Example
74    ///
75    /// ```
76    /// use dockerfile_parser_rs::Instruction;
77    ///
78    /// let copy = Instruction::Copy {
79    ///     from: Some(String::from("builder")),
80    ///     chown: None,
81    ///     chmod: None,
82    ///     link: None,
83    ///     sources: Vec::from([String::from("source1"), String::from("source2")]),
84    ///     destination: String::from("/destination"),
85    /// };
86    /// ```
87    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    /// Represents an empty line in the Dockerfile.
96    ///
97    /// ### Example
98    ///
99    /// ```
100    /// use dockerfile_parser_rs::Instruction;
101    ///
102    /// let empty = Instruction::Empty;
103    /// ```
104    Empty,
105    /// Represents an ENTRYPOINT instruction in the Dockerfile.
106    ///
107    /// ### Example
108    ///
109    /// ```
110    /// use dockerfile_parser_rs::Instruction;
111    ///
112    /// let entrypoint = Instruction::Entrypoint(Vec::from([String::from("entrypoint.sh")]));
113    /// ```
114    Entrypoint(Vec<String>),
115    /// Represents an ENV instruction in the Dockerfile.
116    ///
117    /// ### Example
118    ///
119    /// ```
120    /// use std::collections::BTreeMap;
121    ///
122    /// use dockerfile_parser_rs::Instruction;
123    ///
124    /// let env = Instruction::Env(BTreeMap::from([
125    ///     (String::from("ENV1"), String::from("value1")),
126    ///     (String::from("ENV2"), String::from("value2")),
127    /// ]));
128    /// ```
129    Env(BTreeMap<String, String>),
130    /// Represents an EXPOSE instruction in the Dockerfile.
131    ///
132    /// ### Example
133    ///
134    /// ```
135    /// use dockerfile_parser_rs::Instruction;
136    ///
137    /// let expose = Instruction::Expose {
138    ///     ports: Vec::from([String::from("8080")]),
139    /// };
140    /// ```
141    Expose { ports: Vec<String> },
142    /// Represents a FROM instruction in the Dockerfile.
143    ///
144    /// ### Example
145    ///
146    /// ```
147    /// use dockerfile_parser_rs::Instruction;
148    ///
149    /// let from = Instruction::From {
150    ///     platform: Some(String::from("linux/amd64")),
151    ///     image: String::from("docker.io/library/fedora:latest"),
152    ///     alias: Some(String::from("builder")),
153    /// };
154    /// ```
155    From {
156        platform: Option<String>,
157        image: String,
158        alias: Option<String>,
159    },
160    /// Represents a LABEL instruction in the Dockerfile.
161    ///
162    /// ### Example
163    ///
164    /// ```
165    /// use std::collections::BTreeMap;
166    ///
167    /// use dockerfile_parser_rs::Instruction;
168    ///
169    /// let label = Instruction::Label(BTreeMap::from([
170    ///     (String::from("version"), String::from("1.0")),
171    ///     (String::from("maintainer"), String::from("John Doe")),
172    /// ]));
173    /// ```
174    Label(BTreeMap<String, String>),
175    /// Represents a RUN instruction in the Dockerfile.
176    ///
177    /// ### Example
178    ///
179    /// ```
180    /// use dockerfile_parser_rs::Instruction;
181    ///
182    /// let run = Instruction::Run {
183    ///     mount: None,
184    ///     network: None,
185    ///     security: None,
186    ///     command: Vec::from([String::from("<<EOF")]),
187    ///     heredoc: Some(Vec::from([
188    ///         String::from("dnf upgrade -y"),
189    ///         String::from("dnf install -y rustup"),
190    ///         String::from("EOF"),
191    ///     ])),
192    /// };
193    /// ```
194    Run {
195        mount: Option<String>,
196        network: Option<String>,
197        security: Option<String>,
198        command: Vec<String>,
199        heredoc: Option<Vec<String>>,
200    },
201    /// Represents a SHELL instruction in the Dockerfile.
202    ///
203    /// ### Example
204    ///
205    /// ```
206    /// use dockerfile_parser_rs::Instruction;
207    ///
208    /// let shell = Instruction::Shell(Vec::from([String::from("/bin/sh"), String::from("-c")]));
209    /// ```
210    Shell(Vec<String>),
211    /// Represents a STOPSIGNAL instruction in the Dockerfile.
212    ///
213    /// ### Example
214    ///
215    /// ```
216    /// use dockerfile_parser_rs::Instruction;
217    ///
218    /// let stopsignal = Instruction::Stopsignal {
219    ///     signal: String::from("SIGTERM"),
220    /// };
221    /// ```
222    Stopsignal { signal: String },
223    /// Represents a USER instruction in the Dockerfile.
224    ///
225    /// ### Example
226    ///
227    /// ```
228    /// use dockerfile_parser_rs::Instruction;
229    ///
230    /// let user = Instruction::User {
231    ///     user: String::from("1001"),
232    ///     group: None,
233    /// };
234    /// ```
235    User { user: String, group: Option<String> },
236    /// Represents a VOLUME instruction in the Dockerfile.
237    ///
238    /// ### Example
239    ///
240    /// ```
241    /// use dockerfile_parser_rs::Instruction;
242    ///
243    /// let volume = Instruction::Volume {
244    ///     mounts: Vec::from([String::from("/data")]),
245    /// };
246    /// ```
247    Volume { mounts: Vec<String> },
248    /// Represents a WORKDIR instruction in the Dockerfile.
249    ///
250    /// ### Example
251    ///
252    /// ```
253    /// use dockerfile_parser_rs::Instruction;
254    ///
255    /// let workdir = Instruction::Workdir {
256    ///     path: String::from("/app"),
257    /// };
258    /// ```
259    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            // add a space to separate options
384            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        // must be sorted
432        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        // must be sorted
476        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        // must be sorted
510        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}