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                // The external parser separates sources and destination already.
378                let sources: Vec<String> = copy
379                    .sources
380                    .iter()
381                    .map(std::string::ToString::to_string)
382                    .collect();
383                let destination = copy.destination.to_string();
384
385                Instruction::Copy(CopyInstruction {
386                    sources,
387                    destination,
388                    from,
389                    chown,
390                    chmod,
391                    link,
392                    exclude: Vec::new(),
393                })
394            }
395
396            RawInstruction::Entrypoint(ep) => {
397                let command = match &ep.expr {
398                    dockerfile_parser::ShellOrExecExpr::Shell(s) => {
399                        ShellOrExec::Shell(s.to_string())
400                    }
401                    dockerfile_parser::ShellOrExecExpr::Exec(args) => {
402                        ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
403                    }
404                };
405                Instruction::Entrypoint(command)
406            }
407
408            RawInstruction::Cmd(cmd) => {
409                let command = match &cmd.expr {
410                    dockerfile_parser::ShellOrExecExpr::Shell(s) => {
411                        ShellOrExec::Shell(s.to_string())
412                    }
413                    dockerfile_parser::ShellOrExecExpr::Exec(args) => {
414                        ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
415                    }
416                };
417                Instruction::Cmd(command)
418            }
419
420            RawInstruction::Env(env) => {
421                let mut vars = HashMap::new();
422                for var in &env.vars {
423                    vars.insert(var.key.to_string(), var.value.to_string());
424                }
425                Instruction::Env(EnvInstruction { vars })
426            }
427
428            RawInstruction::Label(label) => {
429                let mut labels = HashMap::new();
430                for l in &label.labels {
431                    labels.insert(l.name.to_string(), l.value.to_string());
432                }
433                Instruction::Label(labels)
434            }
435
436            RawInstruction::Arg(arg) => Instruction::Arg(ArgInstruction {
437                name: arg.name.to_string(),
438                default: arg.value.as_ref().map(std::string::ToString::to_string),
439            }),
440
441            RawInstruction::Misc(misc) => {
442                let instruction_upper = misc.instruction.content.to_uppercase();
443                match instruction_upper.as_str() {
444                    "WORKDIR" => Instruction::Workdir(misc.arguments.to_string()),
445
446                    "USER" => Instruction::User(misc.arguments.to_string()),
447
448                    "VOLUME" => {
449                        let args = misc.arguments.to_string();
450                        let volumes = if args.trim().starts_with('[') {
451                            serde_json::from_str(&args).unwrap_or_else(|_| vec![args])
452                        } else {
453                            args.split_whitespace().map(String::from).collect()
454                        };
455                        Instruction::Volume(volumes)
456                    }
457
458                    "EXPOSE" => {
459                        let args = misc.arguments.to_string();
460                        let (port_str, protocol) = if let Some((p, proto)) = args.split_once('/') {
461                            let proto = match proto.to_lowercase().as_str() {
462                                "udp" => ExposeProtocol::Udp,
463                                _ => ExposeProtocol::Tcp,
464                            };
465                            (p, proto)
466                        } else {
467                            (args.as_str(), ExposeProtocol::Tcp)
468                        };
469
470                        let port: u16 = port_str.trim().parse().map_err(|_| {
471                            BuildError::InvalidInstruction {
472                                instruction: "EXPOSE".to_string(),
473                                reason: format!("Invalid port number: {port_str}"),
474                            }
475                        })?;
476
477                        Instruction::Expose(ExposeInstruction { port, protocol })
478                    }
479
480                    "SHELL" => {
481                        let args = misc.arguments.to_string();
482                        let shell: Vec<String> = serde_json::from_str(&args).map_err(|_| {
483                            BuildError::InvalidInstruction {
484                                instruction: "SHELL".to_string(),
485                                reason: "SHELL requires a JSON array".to_string(),
486                            }
487                        })?;
488                        Instruction::Shell(shell)
489                    }
490
491                    "STOPSIGNAL" => Instruction::Stopsignal(misc.arguments.to_string()),
492
493                    "HEALTHCHECK" => {
494                        let args = misc.arguments.to_string().trim().to_string();
495                        if args.eq_ignore_ascii_case("NONE") {
496                            Instruction::Healthcheck(HealthcheckInstruction::None)
497                        } else {
498                            let command = if let Some(stripped) = args.strip_prefix("CMD ") {
499                                ShellOrExec::Shell(stripped.to_string())
500                            } else {
501                                ShellOrExec::Shell(args)
502                            };
503                            Instruction::Healthcheck(HealthcheckInstruction::cmd(command))
504                        }
505                    }
506
507                    "ONBUILD" => {
508                        tracing::warn!("ONBUILD instruction parsing not fully implemented");
509                        return Ok(None);
510                    }
511
512                    "MAINTAINER" => {
513                        let mut labels = HashMap::new();
514                        labels.insert("maintainer".to_string(), misc.arguments.to_string());
515                        Instruction::Label(labels)
516                    }
517
518                    "ADD" => {
519                        let args = misc.arguments.to_string();
520                        let parts: Vec<String> =
521                            args.split_whitespace().map(String::from).collect();
522
523                        if parts.len() < 2 {
524                            return Err(BuildError::InvalidInstruction {
525                                instruction: "ADD".to_string(),
526                                reason: "ADD requires at least one source and a destination"
527                                    .to_string(),
528                            });
529                        }
530
531                        let (sources, dest) = parts.split_at(parts.len() - 1);
532                        let destination = dest.first().cloned().unwrap_or_default();
533
534                        Instruction::Add(AddInstruction {
535                            sources: sources.to_vec(),
536                            destination,
537                            chown: None,
538                            chmod: None,
539                            link: false,
540                            checksum: None,
541                            keep_git_dir: false,
542                        })
543                    }
544
545                    other => {
546                        tracing::warn!("Unknown Dockerfile instruction: {}", other);
547                        return Ok(None);
548                    }
549                }
550            }
551        };
552
553        Ok(Some(instruction))
554    }
555
556    /// Get a stage by name or index
557    #[must_use]
558    pub fn get_stage(&self, name_or_index: &str) -> Option<&Stage> {
559        self.stages.iter().find(|s| s.matches(name_or_index))
560    }
561
562    /// Get the final stage (last one in the Dockerfile)
563    #[must_use]
564    pub fn final_stage(&self) -> Option<&Stage> {
565        self.stages.last()
566    }
567
568    /// Get all stage names/identifiers
569    #[must_use]
570    pub fn stage_names(&self) -> Vec<String> {
571        self.stages.iter().map(Stage::identifier).collect()
572    }
573
574    /// Check if a stage exists
575    #[must_use]
576    pub fn has_stage(&self, name_or_index: &str) -> bool {
577        self.get_stage(name_or_index).is_some()
578    }
579
580    /// Returns the number of stages
581    #[must_use]
582    pub fn stage_count(&self) -> usize {
583        self.stages.len()
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    #[test]
592    fn test_image_ref_parse_simple() {
593        let img = ImageRef::parse("alpine");
594        assert!(matches!(
595            img,
596            ImageRef::Registry {
597                ref image,
598                tag: None,
599                digest: None
600            } if image == "alpine"
601        ));
602    }
603
604    #[test]
605    fn test_image_ref_parse_with_tag() {
606        let img = ImageRef::parse("alpine:3.18");
607        assert!(matches!(
608            img,
609            ImageRef::Registry {
610                ref image,
611                tag: Some(ref t),
612                digest: None
613            } if image == "alpine" && t == "3.18"
614        ));
615    }
616
617    #[test]
618    fn test_image_ref_parse_with_digest() {
619        let img = ImageRef::parse("alpine@sha256:abc123");
620        assert!(matches!(
621            img,
622            ImageRef::Registry {
623                ref image,
624                tag: None,
625                digest: Some(ref d)
626            } if image == "alpine" && d == "sha256:abc123"
627        ));
628    }
629
630    #[test]
631    fn test_image_ref_parse_scratch() {
632        let img = ImageRef::parse("scratch");
633        assert!(matches!(img, ImageRef::Scratch));
634
635        let img = ImageRef::parse("SCRATCH");
636        assert!(matches!(img, ImageRef::Scratch));
637    }
638
639    #[test]
640    fn test_image_ref_parse_registry_with_port() {
641        let img = ImageRef::parse("localhost:5000/myimage:latest");
642        assert!(matches!(
643            img,
644            ImageRef::Registry {
645                ref image,
646                tag: Some(ref t),
647                ..
648            } if image == "localhost:5000/myimage" && t == "latest"
649        ));
650    }
651
652    // -----------------------------------------------------------------------
653    // ImageRef::qualify() tests
654    // -----------------------------------------------------------------------
655
656    #[test]
657    fn test_qualify_official_image_no_tag() {
658        let img = ImageRef::parse("alpine");
659        let q = img.qualify();
660        assert!(matches!(
661            q,
662            ImageRef::Registry { ref image, tag: None, digest: None }
663            if image == "docker.io/library/alpine"
664        ));
665    }
666
667    #[test]
668    fn test_qualify_official_image_with_tag() {
669        let img = ImageRef::parse("rust:1.90-bookworm");
670        let q = img.qualify();
671        assert!(matches!(
672            q,
673            ImageRef::Registry { ref image, tag: Some(ref t), digest: None }
674            if image == "docker.io/library/rust" && t == "1.90-bookworm"
675        ));
676    }
677
678    #[test]
679    fn test_qualify_user_image() {
680        let img = ImageRef::parse("lukemathwalker/cargo-chef:latest-rust-1.90");
681        let q = img.qualify();
682        assert!(matches!(
683            q,
684            ImageRef::Registry { ref image, tag: Some(ref t), .. }
685            if image == "docker.io/lukemathwalker/cargo-chef" && t == "latest-rust-1.90"
686        ));
687    }
688
689    #[test]
690    fn test_qualify_already_qualified_ghcr() {
691        let img = ImageRef::parse("ghcr.io/org/image:v1");
692        let q = img.qualify();
693        assert_eq!(q.to_string_ref(), "ghcr.io/org/image:v1");
694    }
695
696    #[test]
697    fn test_qualify_already_qualified_quay() {
698        let img = ImageRef::parse("quay.io/org/image:latest");
699        let q = img.qualify();
700        assert_eq!(q.to_string_ref(), "quay.io/org/image:latest");
701    }
702
703    #[test]
704    fn test_qualify_already_qualified_custom_registry() {
705        let img = ImageRef::parse("registry.example.com/org/image:v2");
706        let q = img.qualify();
707        assert_eq!(q.to_string_ref(), "registry.example.com/org/image:v2");
708    }
709
710    #[test]
711    fn test_qualify_localhost_with_port() {
712        let img = ImageRef::parse("localhost:5000/myimage:latest");
713        let q = img.qualify();
714        assert_eq!(q.to_string_ref(), "localhost:5000/myimage:latest");
715    }
716
717    #[test]
718    fn test_qualify_localhost_without_port() {
719        let img = ImageRef::parse("localhost/myimage:v1");
720        let q = img.qualify();
721        assert_eq!(q.to_string_ref(), "localhost/myimage:v1");
722    }
723
724    #[test]
725    fn test_qualify_with_digest() {
726        let img = ImageRef::parse("alpine@sha256:abc123def");
727        let q = img.qualify();
728        assert!(matches!(
729            q,
730            ImageRef::Registry { ref image, tag: None, digest: Some(ref d) }
731            if image == "docker.io/library/alpine" && d == "sha256:abc123def"
732        ));
733    }
734
735    #[test]
736    fn test_qualify_docker_io_explicit() {
737        let img = ImageRef::parse("docker.io/library/nginx:alpine");
738        let q = img.qualify();
739        assert_eq!(q.to_string_ref(), "docker.io/library/nginx:alpine");
740    }
741
742    #[test]
743    fn test_qualify_scratch() {
744        let img = ImageRef::parse("scratch");
745        let q = img.qualify();
746        assert!(matches!(q, ImageRef::Scratch));
747    }
748
749    #[test]
750    fn test_qualify_stage_ref() {
751        let img = ImageRef::Stage("builder".to_string());
752        let q = img.qualify();
753        assert!(matches!(q, ImageRef::Stage(ref name) if name == "builder"));
754    }
755
756    #[test]
757    fn test_parse_simple_dockerfile() {
758        let content = r#"
759FROM alpine:3.18
760RUN apk add --no-cache curl
761COPY . /app
762WORKDIR /app
763CMD ["./app"]
764"#;
765
766        let dockerfile = Dockerfile::parse(content).unwrap();
767        assert_eq!(dockerfile.stages.len(), 1);
768
769        let stage = &dockerfile.stages[0];
770        assert_eq!(stage.index, 0);
771        assert!(stage.name.is_none());
772        assert_eq!(stage.instructions.len(), 4);
773    }
774
775    #[test]
776    fn test_parse_multistage_dockerfile() {
777        let content = r#"
778FROM golang:1.21 AS builder
779WORKDIR /src
780COPY . .
781RUN go build -o /app
782
783FROM alpine:3.18
784COPY --from=builder /app /app
785CMD ["/app"]
786"#;
787
788        let dockerfile = Dockerfile::parse(content).unwrap();
789        assert_eq!(dockerfile.stages.len(), 2);
790
791        let builder = &dockerfile.stages[0];
792        assert_eq!(builder.name, Some("builder".to_string()));
793
794        let runtime = &dockerfile.stages[1];
795        assert!(runtime.name.is_none());
796
797        let copy = runtime
798            .instructions
799            .iter()
800            .find(|i| matches!(i, Instruction::Copy(_)));
801        assert!(copy.is_some());
802        if let Some(Instruction::Copy(c)) = copy {
803            assert_eq!(c.from, Some("builder".to_string()));
804        }
805    }
806
807    #[test]
808    fn test_parse_global_args() {
809        let content = r#"
810ARG BASE_IMAGE=alpine:3.18
811FROM ${BASE_IMAGE}
812RUN echo "hello"
813"#;
814
815        let dockerfile = Dockerfile::parse(content).unwrap();
816        assert_eq!(dockerfile.global_args.len(), 1);
817        assert_eq!(dockerfile.global_args[0].name, "BASE_IMAGE");
818        assert_eq!(
819            dockerfile.global_args[0].default,
820            Some("alpine:3.18".to_string())
821        );
822    }
823
824    #[test]
825    fn test_get_stage_by_name() {
826        let content = r#"
827FROM alpine:3.18 AS base
828RUN echo "base"
829
830FROM base AS builder
831RUN echo "builder"
832"#;
833
834        let dockerfile = Dockerfile::parse(content).unwrap();
835
836        let base = dockerfile.get_stage("base");
837        assert!(base.is_some());
838        assert_eq!(base.unwrap().index, 0);
839
840        let builder = dockerfile.get_stage("builder");
841        assert!(builder.is_some());
842        assert_eq!(builder.unwrap().index, 1);
843
844        let stage_0 = dockerfile.get_stage("0");
845        assert!(stage_0.is_some());
846        assert_eq!(stage_0.unwrap().name, Some("base".to_string()));
847    }
848
849    #[test]
850    fn test_final_stage() {
851        let content = r#"
852FROM alpine:3.18 AS builder
853RUN echo "builder"
854
855FROM scratch
856COPY --from=builder /app /app
857"#;
858
859        let dockerfile = Dockerfile::parse(content).unwrap();
860        let final_stage = dockerfile.final_stage().unwrap();
861
862        assert_eq!(final_stage.index, 1);
863        assert!(matches!(final_stage.base_image, ImageRef::Scratch));
864    }
865
866    #[test]
867    fn test_parse_env_instruction() {
868        let content = r"
869FROM alpine
870ENV FOO=bar BAZ=qux
871";
872
873        let dockerfile = Dockerfile::parse(content).unwrap();
874        let stage = &dockerfile.stages[0];
875
876        let env = stage
877            .instructions
878            .iter()
879            .find(|i| matches!(i, Instruction::Env(_)));
880        assert!(env.is_some());
881
882        if let Some(Instruction::Env(e)) = env {
883            assert_eq!(e.vars.get("FOO"), Some(&"bar".to_string()));
884            assert_eq!(e.vars.get("BAZ"), Some(&"qux".to_string()));
885        }
886    }
887}