Skip to main content

zlayer_builder/dockerfile/
parser.rs

1//! Dockerfile parser
2//!
3//! This module provides functionality to parse Dockerfiles into a structured representation
4//! using the `dockerfile-parser` crate as the parsing backend.
5
6use std::collections::HashMap;
7use std::path::Path;
8
9use dockerfile_parser::{Dockerfile as RawDockerfile, Instruction as RawInstruction};
10use serde::{Deserialize, Serialize};
11
12use crate::error::{BuildError, Result};
13
14use super::instruction::{
15    AddInstruction, ArgInstruction, CopyInstruction, EnvInstruction, ExposeInstruction,
16    ExposeProtocol, HealthcheckInstruction, Instruction, RunInstruction, ShellOrExec,
17};
18
19/// A reference to a Docker image
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub enum ImageRef {
22    /// A registry image reference
23    Registry {
24        /// The full image name (e.g., "docker.io/library/alpine")
25        image: String,
26        /// Optional tag (e.g., "3.18")
27        tag: Option<String>,
28        /// Optional digest (e.g., "sha256:...")
29        digest: Option<String>,
30    },
31    /// A reference to another stage in a multi-stage build
32    Stage(String),
33    /// The special "scratch" base image
34    Scratch,
35}
36
37impl ImageRef {
38    /// Parse an image reference string
39    #[must_use]
40    pub fn parse(s: &str) -> Self {
41        let s = s.trim();
42
43        // Handle scratch special case
44        if s.eq_ignore_ascii_case("scratch") {
45            return Self::Scratch;
46        }
47
48        // Parse image@digest or image:tag
49        if let Some((image, digest)) = s.rsplit_once('@') {
50            return Self::Registry {
51                image: image.to_string(),
52                tag: None,
53                digest: Some(digest.to_string()),
54            };
55        }
56
57        // Check for tag (but be careful with ports like localhost:5000/image)
58        let colon_count = s.matches(':').count();
59        if colon_count > 0 {
60            if let Some((prefix, suffix)) = s.rsplit_once(':') {
61                // If suffix doesn't contain '/', it's a tag
62                if !suffix.contains('/') {
63                    return Self::Registry {
64                        image: prefix.to_string(),
65                        tag: Some(suffix.to_string()),
66                        digest: None,
67                    };
68                }
69            }
70        }
71
72        // No tag or digest
73        Self::Registry {
74            image: s.to_string(),
75            tag: None,
76            digest: None,
77        }
78    }
79
80    /// Convert to a full image string
81    #[must_use]
82    pub fn to_string_ref(&self) -> String {
83        match self {
84            Self::Registry { image, tag, digest } => {
85                let mut s = image.clone();
86                if let Some(t) = tag {
87                    s.push(':');
88                    s.push_str(t);
89                }
90                if let Some(d) = digest {
91                    s.push('@');
92                    s.push_str(d);
93                }
94                s
95            }
96            Self::Stage(name) => name.clone(),
97            Self::Scratch => "scratch".to_string(),
98        }
99    }
100
101    /// Returns true if this is a reference to a build stage
102    #[must_use]
103    pub fn is_stage(&self) -> bool {
104        matches!(self, Self::Stage(_))
105    }
106
107    /// Returns true if this is the scratch base
108    #[must_use]
109    pub fn is_scratch(&self) -> bool {
110        matches!(self, Self::Scratch)
111    }
112
113    /// Qualify a short image name to a fully-qualified registry reference.
114    ///
115    /// Converts short Docker image names to their fully-qualified equivalents
116    /// for systems without unqualified-search registries configured (e.g. buildah
117    /// on CI runners without `/etc/containers/registries.conf`).
118    ///
119    /// - `rust:1.90` → `docker.io/library/rust:1.90` (official image)
120    /// - `user/image:tag` → `docker.io/user/image:tag` (user image)
121    /// - `ghcr.io/org/image:tag` → unchanged (already qualified)
122    /// - `localhost:5000/image:tag` → unchanged (already qualified)
123    /// - `scratch` / stage refs → unchanged
124    #[must_use]
125    pub fn qualify(&self) -> Self {
126        match self {
127            Self::Scratch | Self::Stage(_) => self.clone(),
128            Self::Registry { image, tag, digest } => {
129                let qualified = qualify_image_name(image);
130                Self::Registry {
131                    image: qualified,
132                    tag: tag.clone(),
133                    digest: digest.clone(),
134                }
135            }
136        }
137    }
138}
139
140/// Qualify a short image name to a fully-qualified registry reference.
141///
142/// If the first path segment contains `.` or `:` or equals `localhost`,
143/// the name is already qualified and returned as-is. Otherwise:
144/// - No `/` → official Docker Hub image: `docker.io/library/{name}`
145/// - Has `/` → Docker Hub user image: `docker.io/{name}`
146fn qualify_image_name(image: &str) -> String {
147    let parts: Vec<&str> = image.split('/').collect();
148
149    if parts.is_empty() {
150        return format!("docker.io/library/{image}");
151    }
152
153    let first = parts[0];
154    if first.contains('.') || first.contains(':') || first == "localhost" {
155        return image.to_string();
156    }
157
158    if parts.len() == 1 {
159        format!("docker.io/library/{image}")
160    } else {
161        format!("docker.io/{image}")
162    }
163}
164
165/// A single stage in a multi-stage Dockerfile
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct Stage {
168    /// Stage index (0-based)
169    pub index: usize,
170
171    /// Optional stage name (from `AS name`)
172    pub name: Option<String>,
173
174    /// The base image for this stage
175    pub base_image: ImageRef,
176
177    /// Optional platform specification (e.g., "linux/amd64")
178    pub platform: Option<String>,
179
180    /// Instructions in this stage (excluding the FROM)
181    pub instructions: Vec<Instruction>,
182}
183
184impl Stage {
185    /// Returns the stage identifier (name if present, otherwise index as string)
186    #[must_use]
187    pub fn identifier(&self) -> String {
188        self.name.clone().unwrap_or_else(|| self.index.to_string())
189    }
190
191    /// Returns true if this stage matches the given name or index
192    #[must_use]
193    pub fn matches(&self, name_or_index: &str) -> bool {
194        if let Some(ref name) = self.name {
195            if name == name_or_index {
196                return true;
197            }
198        }
199
200        if let Ok(idx) = name_or_index.parse::<usize>() {
201            return idx == self.index;
202        }
203
204        false
205    }
206}
207
208/// A parsed Dockerfile
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct Dockerfile {
211    /// Global ARG instructions that appear before the first FROM
212    pub global_args: Vec<ArgInstruction>,
213
214    /// Build stages
215    pub stages: Vec<Stage>,
216}
217
218impl Dockerfile {
219    /// Parse a Dockerfile from a string
220    ///
221    /// # Errors
222    ///
223    /// Returns an error if the Dockerfile content is malformed or contains invalid instructions.
224    pub fn parse(content: &str) -> Result<Self> {
225        let raw = RawDockerfile::parse(content).map_err(|e| BuildError::DockerfileParse {
226            message: e.to_string(),
227            line: 1,
228        })?;
229
230        Self::from_raw(raw)
231    }
232
233    /// Parse a Dockerfile from a file
234    ///
235    /// # Errors
236    ///
237    /// Returns an error if the file cannot be read or the Dockerfile is malformed.
238    pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
239        let content =
240            std::fs::read_to_string(path.as_ref()).map_err(|e| BuildError::ContextRead {
241                path: path.as_ref().to_path_buf(),
242                source: e,
243            })?;
244
245        Self::parse(&content)
246    }
247
248    /// Convert from the raw dockerfile-parser types to our internal representation
249    fn from_raw(raw: RawDockerfile) -> Result<Self> {
250        let mut global_args = Vec::new();
251        let mut stages = Vec::new();
252        let mut current_stage: Option<Stage> = None;
253        let mut stage_index = 0;
254
255        for instruction in raw.instructions {
256            match &instruction {
257                RawInstruction::From(from) => {
258                    // Save previous stage if any
259                    if let Some(stage) = current_stage.take() {
260                        stages.push(stage);
261                    }
262
263                    // Parse base image
264                    let base_image = ImageRef::parse(&from.image.content);
265
266                    // Get alias (stage name) - the field is `alias` not `image_alias`
267                    let name = from.alias.as_ref().map(|a| a.content.clone());
268
269                    // Get platform flag
270                    let platform = from
271                        .flags
272                        .iter()
273                        .find(|f| f.name.content.as_str() == "platform")
274                        .map(|f| f.value.to_string());
275
276                    current_stage = Some(Stage {
277                        index: stage_index,
278                        name,
279                        base_image,
280                        platform,
281                        instructions: Vec::new(),
282                    });
283
284                    stage_index += 1;
285                }
286
287                RawInstruction::Arg(arg) => {
288                    let arg_inst = ArgInstruction {
289                        name: arg.name.to_string(),
290                        default: arg.value.as_ref().map(std::string::ToString::to_string),
291                    };
292
293                    if current_stage.is_none() {
294                        global_args.push(arg_inst);
295                    } else if let Some(ref mut stage) = current_stage {
296                        stage.instructions.push(Instruction::Arg(arg_inst));
297                    }
298                }
299
300                _ => {
301                    if let Some(ref mut stage) = current_stage {
302                        if let Some(inst) = Self::convert_instruction(&instruction)? {
303                            stage.instructions.push(inst);
304                        }
305                    }
306                }
307            }
308        }
309
310        // Don't forget the last stage
311        if let Some(stage) = current_stage {
312            stages.push(stage);
313        }
314
315        // Resolve stage references in COPY --from
316        // (This is currently a no-op as stage references are already correct,
317        // but kept for future validation/resolution logic)
318        let _stage_names: HashMap<String, usize> = stages
319            .iter()
320            .filter_map(|s| s.name.as_ref().map(|n| (n.clone(), s.index)))
321            .collect();
322        let _num_stages = stages.len();
323
324        Ok(Self {
325            global_args,
326            stages,
327        })
328    }
329
330    /// Convert a raw instruction to our internal representation
331    #[allow(clippy::too_many_lines)]
332    fn convert_instruction(raw: &RawInstruction) -> Result<Option<Instruction>> {
333        let instruction = match raw {
334            RawInstruction::From(_) => {
335                return Ok(None);
336            }
337
338            RawInstruction::Run(run) => {
339                let command = match &run.expr {
340                    dockerfile_parser::ShellOrExecExpr::Shell(s) => {
341                        ShellOrExec::Shell(s.to_string())
342                    }
343                    dockerfile_parser::ShellOrExecExpr::Exec(args) => {
344                        ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
345                    }
346                };
347
348                Instruction::Run(RunInstruction {
349                    command,
350                    mounts: Vec::new(),
351                    network: None,
352                    security: None,
353                })
354            }
355
356            RawInstruction::Copy(copy) => {
357                let from = copy
358                    .flags
359                    .iter()
360                    .find(|f| f.name.content.as_str() == "from")
361                    .map(|f| f.value.to_string());
362
363                let chown = copy
364                    .flags
365                    .iter()
366                    .find(|f| f.name.content.as_str() == "chown")
367                    .map(|f| f.value.to_string());
368
369                let chmod = copy
370                    .flags
371                    .iter()
372                    .find(|f| f.name.content.as_str() == "chmod")
373                    .map(|f| f.value.to_string());
374
375                let link = copy.flags.iter().any(|f| f.name.content.as_str() == "link");
376
377                // Get all paths
378                let all_paths: Vec<String> = copy
379                    .sources
380                    .iter()
381                    .map(std::string::ToString::to_string)
382                    .collect();
383
384                if all_paths.is_empty() {
385                    return Err(BuildError::InvalidInstruction {
386                        instruction: "COPY".to_string(),
387                        reason: "COPY requires at least one source and a destination".to_string(),
388                    });
389                }
390
391                let (sources, dest) = all_paths.split_at(all_paths.len().saturating_sub(1));
392                let destination = dest.first().cloned().unwrap_or_default();
393
394                Instruction::Copy(CopyInstruction {
395                    sources: sources.to_vec(),
396                    destination,
397                    from,
398                    chown,
399                    chmod,
400                    link,
401                    exclude: Vec::new(),
402                })
403            }
404
405            RawInstruction::Entrypoint(ep) => {
406                let command = match &ep.expr {
407                    dockerfile_parser::ShellOrExecExpr::Shell(s) => {
408                        ShellOrExec::Shell(s.to_string())
409                    }
410                    dockerfile_parser::ShellOrExecExpr::Exec(args) => {
411                        ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
412                    }
413                };
414                Instruction::Entrypoint(command)
415            }
416
417            RawInstruction::Cmd(cmd) => {
418                let command = match &cmd.expr {
419                    dockerfile_parser::ShellOrExecExpr::Shell(s) => {
420                        ShellOrExec::Shell(s.to_string())
421                    }
422                    dockerfile_parser::ShellOrExecExpr::Exec(args) => {
423                        ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
424                    }
425                };
426                Instruction::Cmd(command)
427            }
428
429            RawInstruction::Env(env) => {
430                let mut vars = HashMap::new();
431                for var in &env.vars {
432                    vars.insert(var.key.to_string(), var.value.to_string());
433                }
434                Instruction::Env(EnvInstruction { vars })
435            }
436
437            RawInstruction::Label(label) => {
438                let mut labels = HashMap::new();
439                for l in &label.labels {
440                    labels.insert(l.name.to_string(), l.value.to_string());
441                }
442                Instruction::Label(labels)
443            }
444
445            RawInstruction::Arg(arg) => Instruction::Arg(ArgInstruction {
446                name: arg.name.to_string(),
447                default: arg.value.as_ref().map(std::string::ToString::to_string),
448            }),
449
450            RawInstruction::Misc(misc) => {
451                let instruction_upper = misc.instruction.content.to_uppercase();
452                match instruction_upper.as_str() {
453                    "WORKDIR" => Instruction::Workdir(misc.arguments.to_string()),
454
455                    "USER" => Instruction::User(misc.arguments.to_string()),
456
457                    "VOLUME" => {
458                        let args = misc.arguments.to_string();
459                        let volumes = if args.trim().starts_with('[') {
460                            serde_json::from_str(&args).unwrap_or_else(|_| vec![args])
461                        } else {
462                            args.split_whitespace().map(String::from).collect()
463                        };
464                        Instruction::Volume(volumes)
465                    }
466
467                    "EXPOSE" => {
468                        let args = misc.arguments.to_string();
469                        let (port_str, protocol) = if let Some((p, proto)) = args.split_once('/') {
470                            let proto = match proto.to_lowercase().as_str() {
471                                "udp" => ExposeProtocol::Udp,
472                                _ => ExposeProtocol::Tcp,
473                            };
474                            (p, proto)
475                        } else {
476                            (args.as_str(), ExposeProtocol::Tcp)
477                        };
478
479                        let port: u16 = port_str.trim().parse().map_err(|_| {
480                            BuildError::InvalidInstruction {
481                                instruction: "EXPOSE".to_string(),
482                                reason: format!("Invalid port number: {port_str}"),
483                            }
484                        })?;
485
486                        Instruction::Expose(ExposeInstruction { port, protocol })
487                    }
488
489                    "SHELL" => {
490                        let args = misc.arguments.to_string();
491                        let shell: Vec<String> = serde_json::from_str(&args).map_err(|_| {
492                            BuildError::InvalidInstruction {
493                                instruction: "SHELL".to_string(),
494                                reason: "SHELL requires a JSON array".to_string(),
495                            }
496                        })?;
497                        Instruction::Shell(shell)
498                    }
499
500                    "STOPSIGNAL" => Instruction::Stopsignal(misc.arguments.to_string()),
501
502                    "HEALTHCHECK" => {
503                        let args = misc.arguments.to_string().trim().to_string();
504                        if args.eq_ignore_ascii_case("NONE") {
505                            Instruction::Healthcheck(HealthcheckInstruction::None)
506                        } else {
507                            let command = if let Some(stripped) = args.strip_prefix("CMD ") {
508                                ShellOrExec::Shell(stripped.to_string())
509                            } else {
510                                ShellOrExec::Shell(args)
511                            };
512                            Instruction::Healthcheck(HealthcheckInstruction::cmd(command))
513                        }
514                    }
515
516                    "ONBUILD" => {
517                        tracing::warn!("ONBUILD instruction parsing not fully implemented");
518                        return Ok(None);
519                    }
520
521                    "MAINTAINER" => {
522                        let mut labels = HashMap::new();
523                        labels.insert("maintainer".to_string(), misc.arguments.to_string());
524                        Instruction::Label(labels)
525                    }
526
527                    "ADD" => {
528                        let args = misc.arguments.to_string();
529                        let parts: Vec<String> =
530                            args.split_whitespace().map(String::from).collect();
531
532                        if parts.len() < 2 {
533                            return Err(BuildError::InvalidInstruction {
534                                instruction: "ADD".to_string(),
535                                reason: "ADD requires at least one source and a destination"
536                                    .to_string(),
537                            });
538                        }
539
540                        let (sources, dest) = parts.split_at(parts.len() - 1);
541                        let destination = dest.first().cloned().unwrap_or_default();
542
543                        Instruction::Add(AddInstruction {
544                            sources: sources.to_vec(),
545                            destination,
546                            chown: None,
547                            chmod: None,
548                            link: false,
549                            checksum: None,
550                            keep_git_dir: false,
551                        })
552                    }
553
554                    other => {
555                        tracing::warn!("Unknown Dockerfile instruction: {}", other);
556                        return Ok(None);
557                    }
558                }
559            }
560        };
561
562        Ok(Some(instruction))
563    }
564
565    /// Get a stage by name or index
566    #[must_use]
567    pub fn get_stage(&self, name_or_index: &str) -> Option<&Stage> {
568        self.stages.iter().find(|s| s.matches(name_or_index))
569    }
570
571    /// Get the final stage (last one in the Dockerfile)
572    #[must_use]
573    pub fn final_stage(&self) -> Option<&Stage> {
574        self.stages.last()
575    }
576
577    /// Get all stage names/identifiers
578    #[must_use]
579    pub fn stage_names(&self) -> Vec<String> {
580        self.stages.iter().map(Stage::identifier).collect()
581    }
582
583    /// Check if a stage exists
584    #[must_use]
585    pub fn has_stage(&self, name_or_index: &str) -> bool {
586        self.get_stage(name_or_index).is_some()
587    }
588
589    /// Returns the number of stages
590    #[must_use]
591    pub fn stage_count(&self) -> usize {
592        self.stages.len()
593    }
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599
600    #[test]
601    fn test_image_ref_parse_simple() {
602        let img = ImageRef::parse("alpine");
603        assert!(matches!(
604            img,
605            ImageRef::Registry {
606                ref image,
607                tag: None,
608                digest: None
609            } if image == "alpine"
610        ));
611    }
612
613    #[test]
614    fn test_image_ref_parse_with_tag() {
615        let img = ImageRef::parse("alpine:3.18");
616        assert!(matches!(
617            img,
618            ImageRef::Registry {
619                ref image,
620                tag: Some(ref t),
621                digest: None
622            } if image == "alpine" && t == "3.18"
623        ));
624    }
625
626    #[test]
627    fn test_image_ref_parse_with_digest() {
628        let img = ImageRef::parse("alpine@sha256:abc123");
629        assert!(matches!(
630            img,
631            ImageRef::Registry {
632                ref image,
633                tag: None,
634                digest: Some(ref d)
635            } if image == "alpine" && d == "sha256:abc123"
636        ));
637    }
638
639    #[test]
640    fn test_image_ref_parse_scratch() {
641        let img = ImageRef::parse("scratch");
642        assert!(matches!(img, ImageRef::Scratch));
643
644        let img = ImageRef::parse("SCRATCH");
645        assert!(matches!(img, ImageRef::Scratch));
646    }
647
648    #[test]
649    fn test_image_ref_parse_registry_with_port() {
650        let img = ImageRef::parse("localhost:5000/myimage:latest");
651        assert!(matches!(
652            img,
653            ImageRef::Registry {
654                ref image,
655                tag: Some(ref t),
656                ..
657            } if image == "localhost:5000/myimage" && t == "latest"
658        ));
659    }
660
661    // -----------------------------------------------------------------------
662    // ImageRef::qualify() tests
663    // -----------------------------------------------------------------------
664
665    #[test]
666    fn test_qualify_official_image_no_tag() {
667        let img = ImageRef::parse("alpine");
668        let q = img.qualify();
669        assert!(matches!(
670            q,
671            ImageRef::Registry { ref image, tag: None, digest: None }
672            if image == "docker.io/library/alpine"
673        ));
674    }
675
676    #[test]
677    fn test_qualify_official_image_with_tag() {
678        let img = ImageRef::parse("rust:1.90-bookworm");
679        let q = img.qualify();
680        assert!(matches!(
681            q,
682            ImageRef::Registry { ref image, tag: Some(ref t), digest: None }
683            if image == "docker.io/library/rust" && t == "1.90-bookworm"
684        ));
685    }
686
687    #[test]
688    fn test_qualify_user_image() {
689        let img = ImageRef::parse("lukemathwalker/cargo-chef:latest-rust-1.90");
690        let q = img.qualify();
691        assert!(matches!(
692            q,
693            ImageRef::Registry { ref image, tag: Some(ref t), .. }
694            if image == "docker.io/lukemathwalker/cargo-chef" && t == "latest-rust-1.90"
695        ));
696    }
697
698    #[test]
699    fn test_qualify_already_qualified_ghcr() {
700        let img = ImageRef::parse("ghcr.io/org/image:v1");
701        let q = img.qualify();
702        assert_eq!(q.to_string_ref(), "ghcr.io/org/image:v1");
703    }
704
705    #[test]
706    fn test_qualify_already_qualified_quay() {
707        let img = ImageRef::parse("quay.io/org/image:latest");
708        let q = img.qualify();
709        assert_eq!(q.to_string_ref(), "quay.io/org/image:latest");
710    }
711
712    #[test]
713    fn test_qualify_already_qualified_custom_registry() {
714        let img = ImageRef::parse("registry.example.com/org/image:v2");
715        let q = img.qualify();
716        assert_eq!(q.to_string_ref(), "registry.example.com/org/image:v2");
717    }
718
719    #[test]
720    fn test_qualify_localhost_with_port() {
721        let img = ImageRef::parse("localhost:5000/myimage:latest");
722        let q = img.qualify();
723        assert_eq!(q.to_string_ref(), "localhost:5000/myimage:latest");
724    }
725
726    #[test]
727    fn test_qualify_localhost_without_port() {
728        let img = ImageRef::parse("localhost/myimage:v1");
729        let q = img.qualify();
730        assert_eq!(q.to_string_ref(), "localhost/myimage:v1");
731    }
732
733    #[test]
734    fn test_qualify_with_digest() {
735        let img = ImageRef::parse("alpine@sha256:abc123def");
736        let q = img.qualify();
737        assert!(matches!(
738            q,
739            ImageRef::Registry { ref image, tag: None, digest: Some(ref d) }
740            if image == "docker.io/library/alpine" && d == "sha256:abc123def"
741        ));
742    }
743
744    #[test]
745    fn test_qualify_docker_io_explicit() {
746        let img = ImageRef::parse("docker.io/library/nginx:alpine");
747        let q = img.qualify();
748        assert_eq!(q.to_string_ref(), "docker.io/library/nginx:alpine");
749    }
750
751    #[test]
752    fn test_qualify_scratch() {
753        let img = ImageRef::parse("scratch");
754        let q = img.qualify();
755        assert!(matches!(q, ImageRef::Scratch));
756    }
757
758    #[test]
759    fn test_qualify_stage_ref() {
760        let img = ImageRef::Stage("builder".to_string());
761        let q = img.qualify();
762        assert!(matches!(q, ImageRef::Stage(ref name) if name == "builder"));
763    }
764
765    #[test]
766    fn test_parse_simple_dockerfile() {
767        let content = r#"
768FROM alpine:3.18
769RUN apk add --no-cache curl
770COPY . /app
771WORKDIR /app
772CMD ["./app"]
773"#;
774
775        let dockerfile = Dockerfile::parse(content).unwrap();
776        assert_eq!(dockerfile.stages.len(), 1);
777
778        let stage = &dockerfile.stages[0];
779        assert_eq!(stage.index, 0);
780        assert!(stage.name.is_none());
781        assert_eq!(stage.instructions.len(), 4);
782    }
783
784    #[test]
785    fn test_parse_multistage_dockerfile() {
786        let content = r#"
787FROM golang:1.21 AS builder
788WORKDIR /src
789COPY . .
790RUN go build -o /app
791
792FROM alpine:3.18
793COPY --from=builder /app /app
794CMD ["/app"]
795"#;
796
797        let dockerfile = Dockerfile::parse(content).unwrap();
798        assert_eq!(dockerfile.stages.len(), 2);
799
800        let builder = &dockerfile.stages[0];
801        assert_eq!(builder.name, Some("builder".to_string()));
802
803        let runtime = &dockerfile.stages[1];
804        assert!(runtime.name.is_none());
805
806        let copy = runtime
807            .instructions
808            .iter()
809            .find(|i| matches!(i, Instruction::Copy(_)));
810        assert!(copy.is_some());
811        if let Some(Instruction::Copy(c)) = copy {
812            assert_eq!(c.from, Some("builder".to_string()));
813        }
814    }
815
816    #[test]
817    fn test_parse_global_args() {
818        let content = r#"
819ARG BASE_IMAGE=alpine:3.18
820FROM ${BASE_IMAGE}
821RUN echo "hello"
822"#;
823
824        let dockerfile = Dockerfile::parse(content).unwrap();
825        assert_eq!(dockerfile.global_args.len(), 1);
826        assert_eq!(dockerfile.global_args[0].name, "BASE_IMAGE");
827        assert_eq!(
828            dockerfile.global_args[0].default,
829            Some("alpine:3.18".to_string())
830        );
831    }
832
833    #[test]
834    fn test_get_stage_by_name() {
835        let content = r#"
836FROM alpine:3.18 AS base
837RUN echo "base"
838
839FROM base AS builder
840RUN echo "builder"
841"#;
842
843        let dockerfile = Dockerfile::parse(content).unwrap();
844
845        let base = dockerfile.get_stage("base");
846        assert!(base.is_some());
847        assert_eq!(base.unwrap().index, 0);
848
849        let builder = dockerfile.get_stage("builder");
850        assert!(builder.is_some());
851        assert_eq!(builder.unwrap().index, 1);
852
853        let stage_0 = dockerfile.get_stage("0");
854        assert!(stage_0.is_some());
855        assert_eq!(stage_0.unwrap().name, Some("base".to_string()));
856    }
857
858    #[test]
859    fn test_final_stage() {
860        let content = r#"
861FROM alpine:3.18 AS builder
862RUN echo "builder"
863
864FROM scratch
865COPY --from=builder /app /app
866"#;
867
868        let dockerfile = Dockerfile::parse(content).unwrap();
869        let final_stage = dockerfile.final_stage().unwrap();
870
871        assert_eq!(final_stage.index, 1);
872        assert!(matches!(final_stage.base_image, ImageRef::Scratch));
873    }
874
875    #[test]
876    fn test_parse_env_instruction() {
877        let content = r#"
878FROM alpine
879ENV FOO=bar BAZ=qux
880"#;
881
882        let dockerfile = Dockerfile::parse(content).unwrap();
883        let stage = &dockerfile.stages[0];
884
885        let env = stage
886            .instructions
887            .iter()
888            .find(|i| matches!(i, Instruction::Env(_)));
889        assert!(env.is_some());
890
891        if let Some(Instruction::Env(e)) = env {
892            assert_eq!(e.vars.get("FOO"), Some(&"bar".to_string()));
893            assert_eq!(e.vars.get("BAZ"), Some(&"qux".to_string()));
894        }
895    }
896}