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