1use std::collections::HashMap;
7use std::path::Path;
8use std::str::FromStr;
9
10use dockerfile_parser::{Dockerfile as RawDockerfile, Instruction as RawInstruction};
11use serde::{Deserialize, Serialize};
12use zlayer_types::ImageReference;
13
14use crate::error::{BuildError, Result};
15
16use super::instruction::{
17 AddInstruction, ArgInstruction, CopyInstruction, EnvInstruction, ExposeInstruction,
18 ExposeProtocol, HealthcheckInstruction, Instruction, RunInstruction, ShellOrExec,
19};
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub enum DockerfileFromTarget {
32 Image(ImageReference),
34 Stage(String),
36 Scratch,
38}
39
40impl DockerfileFromTarget {
41 #[must_use]
53 pub fn parse(s: &str) -> Self {
54 let s = s.trim();
55
56 if s.eq_ignore_ascii_case("scratch") {
57 return Self::Scratch;
58 }
59
60 match ImageReference::from_str(s) {
61 Ok(r) => Self::Image(r),
62 Err(_) => Self::Stage(s.to_string()),
63 }
64 }
65
66 #[must_use]
68 pub fn is_stage(&self) -> bool {
69 matches!(self, Self::Stage(_))
70 }
71
72 #[must_use]
74 pub fn is_scratch(&self) -> bool {
75 matches!(self, Self::Scratch)
76 }
77}
78
79impl std::fmt::Display for DockerfileFromTarget {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 match self {
82 Self::Image(r) => write!(f, "{r}"),
83 Self::Stage(name) => f.write_str(name),
84 Self::Scratch => f.write_str("scratch"),
85 }
86 }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct Stage {
92 pub index: usize,
94
95 pub name: Option<String>,
97
98 pub base_image: DockerfileFromTarget,
100
101 pub platform: Option<String>,
103
104 pub instructions: Vec<Instruction>,
106}
107
108impl Stage {
109 #[must_use]
111 pub fn identifier(&self) -> String {
112 self.name.clone().unwrap_or_else(|| self.index.to_string())
113 }
114
115 #[must_use]
117 pub fn matches(&self, name_or_index: &str) -> bool {
118 if let Some(ref name) = self.name {
119 if name == name_or_index {
120 return true;
121 }
122 }
123
124 if let Ok(idx) = name_or_index.parse::<usize>() {
125 return idx == self.index;
126 }
127
128 false
129 }
130}
131
132fn expand_from_args(input: &str, vars: &HashMap<String, String>) -> String {
139 let mut out = String::with_capacity(input.len());
140 let mut chars = input.char_indices().peekable();
141 while let Some((_, c)) = chars.next() {
142 if c != '$' {
143 out.push(c);
144 continue;
145 }
146 match chars.peek() {
147 Some(&(_, '{')) => {
148 chars.next(); let mut body = String::new();
150 let mut closed = false;
151 for (_, bc) in chars.by_ref() {
152 if bc == '}' {
153 closed = true;
154 break;
155 }
156 body.push(bc);
157 }
158 if !closed {
159 out.push_str("${");
161 out.push_str(&body);
162 continue;
163 }
164 if let Some((name, default)) = body.split_once(":-") {
165 match vars.get(name).filter(|v| !v.is_empty()) {
166 Some(v) => out.push_str(v),
167 None => out.push_str(default),
168 }
169 } else if let Some((name, alt)) = body.split_once(":+") {
170 if vars.get(name).is_some_and(|v| !v.is_empty()) {
171 out.push_str(alt);
172 }
173 } else {
174 out.push_str(vars.get(&body).map_or("", String::as_str));
175 }
176 }
177 Some(&(_, next)) if next.is_ascii_alphabetic() || next == '_' => {
178 let mut name = String::new();
179 while let Some(&(_, nc)) = chars.peek() {
180 if nc.is_ascii_alphanumeric() || nc == '_' {
181 name.push(nc);
182 chars.next();
183 } else {
184 break;
185 }
186 }
187 out.push_str(vars.get(&name).map_or("", String::as_str));
188 }
189 Some(&(_, '$')) => {
191 chars.next();
192 out.push('$');
193 }
194 _ => out.push('$'),
195 }
196 }
197 out
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct Dockerfile {
203 pub global_args: Vec<ArgInstruction>,
205
206 pub stages: Vec<Stage>,
208}
209
210impl Dockerfile {
211 pub fn resolve_from_args(&mut self, build_args: &HashMap<String, String>) {
227 let mut vars: HashMap<String, String> = HashMap::new();
231 for arg in &self.global_args {
232 let value = build_args
233 .get(&arg.name)
234 .cloned()
235 .or_else(|| arg.default.clone())
236 .unwrap_or_default();
237 vars.insert(arg.name.clone(), value);
238 }
239
240 let mut known_stage_names: std::collections::HashSet<String> =
241 std::collections::HashSet::new();
242 for stage in &mut self.stages {
243 if let DockerfileFromTarget::Stage(raw) = &stage.base_image {
244 if raw.contains('$') {
245 let expanded = expand_from_args(raw, &vars);
246 if !expanded.trim().is_empty() {
247 let mut target = DockerfileFromTarget::parse(&expanded);
248 if matches!(target, DockerfileFromTarget::Image(_))
252 && known_stage_names.contains(expanded.trim())
253 {
254 target = DockerfileFromTarget::Stage(expanded.trim().to_string());
255 }
256 stage.base_image = target;
257 }
258 }
259 }
260 if let Some(name) = &stage.name {
261 known_stage_names.insert(name.clone());
262 }
263 }
264 }
265
266 pub fn parse(content: &str) -> Result<Self> {
272 let raw = RawDockerfile::parse(content).map_err(|e| BuildError::DockerfileParse {
273 message: e.to_string(),
274 line: 1,
275 })?;
276
277 Self::from_raw(raw)
278 }
279
280 pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
286 let content =
287 std::fs::read_to_string(path.as_ref()).map_err(|e| BuildError::ContextRead {
288 path: path.as_ref().to_path_buf(),
289 source: e,
290 })?;
291
292 Self::parse(&content)
293 }
294
295 fn from_raw(raw: RawDockerfile) -> Result<Self> {
297 let mut global_args = Vec::new();
298 let mut stages = Vec::new();
299 let mut current_stage: Option<Stage> = None;
300 let mut stage_index = 0;
301 let mut known_stage_names: std::collections::HashSet<String> =
305 std::collections::HashSet::new();
306
307 for instruction in raw.instructions {
308 match &instruction {
309 RawInstruction::From(from) => {
310 if let Some(stage) = current_stage.take() {
312 stages.push(stage);
313 }
314
315 let raw_from = from.image.content.trim().to_string();
317 let mut base_image = DockerfileFromTarget::parse(&raw_from);
318
319 if matches!(base_image, DockerfileFromTarget::Image(_))
325 && known_stage_names.contains(&raw_from)
326 {
327 base_image = DockerfileFromTarget::Stage(raw_from.clone());
328 }
329
330 let name = from.alias.as_ref().map(|a| a.content.clone());
332
333 if let Some(ref n) = name {
334 known_stage_names.insert(n.clone());
335 }
336
337 let platform = from
339 .flags
340 .iter()
341 .find(|f| f.name.content.as_str() == "platform")
342 .map(|f| f.value.to_string());
343
344 current_stage = Some(Stage {
345 index: stage_index,
346 name,
347 base_image,
348 platform,
349 instructions: Vec::new(),
350 });
351
352 stage_index += 1;
353 }
354
355 RawInstruction::Arg(arg) => {
356 let arg_inst = ArgInstruction {
357 name: arg.name.to_string(),
358 default: arg.value.as_ref().map(std::string::ToString::to_string),
359 };
360
361 if current_stage.is_none() {
362 global_args.push(arg_inst);
363 } else if let Some(ref mut stage) = current_stage {
364 stage.instructions.push(Instruction::Arg(arg_inst));
365 }
366 }
367
368 _ => {
369 if let Some(ref mut stage) = current_stage {
370 if let Some(inst) = Self::convert_instruction(&instruction)? {
371 stage.instructions.push(inst);
372 }
373 }
374 }
375 }
376 }
377
378 if let Some(stage) = current_stage {
380 stages.push(stage);
381 }
382
383 let _stage_names: HashMap<String, usize> = stages
387 .iter()
388 .filter_map(|s| s.name.as_ref().map(|n| (n.clone(), s.index)))
389 .collect();
390 let _num_stages = stages.len();
391
392 Ok(Self {
393 global_args,
394 stages,
395 })
396 }
397
398 #[allow(clippy::too_many_lines)]
400 fn convert_instruction(raw: &RawInstruction) -> Result<Option<Instruction>> {
401 let instruction = match raw {
402 RawInstruction::From(_) => {
403 return Ok(None);
404 }
405
406 RawInstruction::Run(run) => {
407 let command = match &run.expr {
408 dockerfile_parser::ShellOrExecExpr::Shell(s) => {
409 ShellOrExec::Shell(s.to_string())
410 }
411 dockerfile_parser::ShellOrExecExpr::Exec(args) => {
412 ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
413 }
414 };
415
416 Instruction::Run(RunInstruction {
417 command,
418 mounts: Vec::new(),
419 network: None,
420 security: None,
421 env: HashMap::new(),
422 })
423 }
424
425 RawInstruction::Copy(copy) => {
426 let from = copy
427 .flags
428 .iter()
429 .find(|f| f.name.content.as_str() == "from")
430 .map(|f| f.value.to_string());
431
432 let chown = copy
433 .flags
434 .iter()
435 .find(|f| f.name.content.as_str() == "chown")
436 .map(|f| f.value.to_string());
437
438 let chmod = copy
439 .flags
440 .iter()
441 .find(|f| f.name.content.as_str() == "chmod")
442 .map(|f| f.value.to_string());
443
444 let link = copy.flags.iter().any(|f| f.name.content.as_str() == "link");
445
446 let sources: Vec<String> = copy
448 .sources
449 .iter()
450 .map(std::string::ToString::to_string)
451 .collect();
452 let destination = copy.destination.to_string();
453
454 Instruction::Copy(CopyInstruction {
455 sources,
456 destination,
457 from,
458 chown,
459 chmod,
460 link,
461 exclude: Vec::new(),
462 })
463 }
464
465 RawInstruction::Entrypoint(ep) => {
466 let command = match &ep.expr {
467 dockerfile_parser::ShellOrExecExpr::Shell(s) => {
468 ShellOrExec::Shell(s.to_string())
469 }
470 dockerfile_parser::ShellOrExecExpr::Exec(args) => {
471 ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
472 }
473 };
474 Instruction::Entrypoint(command)
475 }
476
477 RawInstruction::Cmd(cmd) => {
478 let command = match &cmd.expr {
479 dockerfile_parser::ShellOrExecExpr::Shell(s) => {
480 ShellOrExec::Shell(s.to_string())
481 }
482 dockerfile_parser::ShellOrExecExpr::Exec(args) => {
483 ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
484 }
485 };
486 Instruction::Cmd(command)
487 }
488
489 RawInstruction::Env(env) => {
490 let mut vars = HashMap::new();
491 for var in &env.vars {
492 vars.insert(var.key.to_string(), var.value.to_string());
493 }
494 Instruction::Env(EnvInstruction { vars })
495 }
496
497 RawInstruction::Label(label) => {
498 let mut labels = HashMap::new();
499 for l in &label.labels {
500 labels.insert(l.name.to_string(), l.value.to_string());
501 }
502 Instruction::Label(labels)
503 }
504
505 RawInstruction::Arg(arg) => Instruction::Arg(ArgInstruction {
506 name: arg.name.to_string(),
507 default: arg.value.as_ref().map(std::string::ToString::to_string),
508 }),
509
510 RawInstruction::Misc(misc) => {
511 let instruction_upper = misc.instruction.content.to_uppercase();
512 match instruction_upper.as_str() {
513 "WORKDIR" => Instruction::Workdir(misc.arguments.to_string()),
514
515 "USER" => Instruction::User(misc.arguments.to_string()),
516
517 "VOLUME" => {
518 let args = misc.arguments.to_string();
519 let volumes = if args.trim().starts_with('[') {
520 serde_json::from_str(&args).unwrap_or_else(|_| vec![args])
521 } else {
522 args.split_whitespace().map(String::from).collect()
523 };
524 Instruction::Volume(volumes)
525 }
526
527 "EXPOSE" => {
528 let args = misc.arguments.to_string();
529 let (port_str, protocol) = if let Some((p, proto)) = args.split_once('/') {
530 let proto = match proto.to_lowercase().as_str() {
531 "udp" => ExposeProtocol::Udp,
532 _ => ExposeProtocol::Tcp,
533 };
534 (p, proto)
535 } else {
536 (args.as_str(), ExposeProtocol::Tcp)
537 };
538
539 let port: u16 = port_str.trim().parse().map_err(|_| {
540 BuildError::InvalidInstruction {
541 instruction: "EXPOSE".to_string(),
542 reason: format!("Invalid port number: {port_str}"),
543 }
544 })?;
545
546 Instruction::Expose(ExposeInstruction { port, protocol })
547 }
548
549 "SHELL" => {
550 let args = misc.arguments.to_string();
551 let shell: Vec<String> = serde_json::from_str(&args).map_err(|_| {
552 BuildError::InvalidInstruction {
553 instruction: "SHELL".to_string(),
554 reason: "SHELL requires a JSON array".to_string(),
555 }
556 })?;
557 Instruction::Shell(shell)
558 }
559
560 "STOPSIGNAL" => Instruction::Stopsignal(misc.arguments.to_string()),
561
562 "HEALTHCHECK" => {
563 let args = misc.arguments.to_string().trim().to_string();
564 if args.eq_ignore_ascii_case("NONE") {
565 Instruction::Healthcheck(HealthcheckInstruction::None)
566 } else {
567 let command = if let Some(stripped) = args.strip_prefix("CMD ") {
568 ShellOrExec::Shell(stripped.to_string())
569 } else {
570 ShellOrExec::Shell(args)
571 };
572 Instruction::Healthcheck(HealthcheckInstruction::cmd(command))
573 }
574 }
575
576 "ONBUILD" => {
577 tracing::warn!("ONBUILD instruction parsing not fully implemented");
578 return Ok(None);
579 }
580
581 "MAINTAINER" => {
582 let mut labels = HashMap::new();
583 labels.insert("maintainer".to_string(), misc.arguments.to_string());
584 Instruction::Label(labels)
585 }
586
587 "ADD" => {
588 let args = misc.arguments.to_string();
589 let parts: Vec<String> =
590 args.split_whitespace().map(String::from).collect();
591
592 if parts.len() < 2 {
593 return Err(BuildError::InvalidInstruction {
594 instruction: "ADD".to_string(),
595 reason: "ADD requires at least one source and a destination"
596 .to_string(),
597 });
598 }
599
600 let (sources, dest) = parts.split_at(parts.len() - 1);
601 let destination = dest.first().cloned().unwrap_or_default();
602
603 Instruction::Add(AddInstruction {
604 sources: sources.to_vec(),
605 destination,
606 chown: None,
607 chmod: None,
608 link: false,
609 checksum: None,
610 keep_git_dir: false,
611 })
612 }
613
614 other => {
615 tracing::warn!("Unknown Dockerfile instruction: {}", other);
616 return Ok(None);
617 }
618 }
619 }
620 };
621
622 Ok(Some(instruction))
623 }
624
625 #[must_use]
627 pub fn get_stage(&self, name_or_index: &str) -> Option<&Stage> {
628 self.stages.iter().find(|s| s.matches(name_or_index))
629 }
630
631 #[must_use]
633 pub fn final_stage(&self) -> Option<&Stage> {
634 self.stages.last()
635 }
636
637 #[must_use]
639 pub fn stage_names(&self) -> Vec<String> {
640 self.stages.iter().map(Stage::identifier).collect()
641 }
642
643 #[must_use]
645 pub fn has_stage(&self, name_or_index: &str) -> bool {
646 self.get_stage(name_or_index).is_some()
647 }
648
649 #[must_use]
651 pub fn stage_count(&self) -> usize {
652 self.stages.len()
653 }
654}
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659
660 #[test]
661 fn test_parse_simple_dockerfile() {
662 let content = r#"
663FROM alpine:3.18
664RUN apk add --no-cache curl
665COPY . /app
666WORKDIR /app
667CMD ["./app"]
668"#;
669
670 let dockerfile = Dockerfile::parse(content).unwrap();
671 assert_eq!(dockerfile.stages.len(), 1);
672
673 let stage = &dockerfile.stages[0];
674 assert_eq!(stage.index, 0);
675 assert!(stage.name.is_none());
676 assert_eq!(stage.instructions.len(), 4);
677 }
678
679 #[test]
680 fn test_parse_multistage_dockerfile() {
681 let content = r#"
682FROM golang:1.21 AS builder
683WORKDIR /src
684COPY . .
685RUN go build -o /app
686
687FROM alpine:3.18
688COPY --from=builder /app /app
689CMD ["/app"]
690"#;
691
692 let dockerfile = Dockerfile::parse(content).unwrap();
693 assert_eq!(dockerfile.stages.len(), 2);
694
695 let builder = &dockerfile.stages[0];
696 assert_eq!(builder.name, Some("builder".to_string()));
697
698 let runtime = &dockerfile.stages[1];
699 assert!(runtime.name.is_none());
700
701 let copy = runtime
702 .instructions
703 .iter()
704 .find(|i| matches!(i, Instruction::Copy(_)));
705 assert!(copy.is_some());
706 if let Some(Instruction::Copy(c)) = copy {
707 assert_eq!(c.from, Some("builder".to_string()));
708 }
709 }
710
711 #[test]
712 fn test_parse_copy_from_external_image_reference() {
713 let content = r"
717FROM alpine:3.18
718COPY --from=ghcr.io/astral-sh/uv:0.5.0 /uv /usr/local/bin/uv
719RUN /usr/local/bin/uv --version
720";
721
722 let dockerfile = Dockerfile::parse(content).unwrap();
723 assert_eq!(dockerfile.stages.len(), 1);
724
725 let copy = dockerfile.stages[0]
726 .instructions
727 .iter()
728 .find_map(|i| {
729 if let Instruction::Copy(c) = i {
730 Some(c)
731 } else {
732 None
733 }
734 })
735 .expect("COPY instruction present");
736
737 assert_eq!(
738 copy.from,
739 Some("ghcr.io/astral-sh/uv:0.5.0".to_string()),
740 "external image ref must be preserved verbatim in CopyInstruction.from",
741 );
742 assert_eq!(copy.sources, vec!["/uv".to_string()]);
743 assert_eq!(copy.destination, "/usr/local/bin/uv".to_string());
744
745 assert!(dockerfile.get_stage("ghcr.io/astral-sh/uv:0.5.0").is_none());
748 }
749
750 #[test]
751 fn expand_from_args_all_forms() {
752 let vars: HashMap<String, String> = [
753 ("BASE".to_string(), "ghcr.io/org/img".to_string()),
754 ("TAG".to_string(), "1.2".to_string()),
755 ("EMPTY".to_string(), String::new()),
756 ]
757 .into_iter()
758 .collect();
759
760 assert_eq!(
761 expand_from_args("${BASE}:${TAG}", &vars),
762 "ghcr.io/org/img:1.2"
763 );
764 assert_eq!(expand_from_args("$BASE", &vars), "ghcr.io/org/img");
765 assert_eq!(
766 expand_from_args("img:${MISSING:-latest}", &vars),
767 "img:latest"
768 );
769 assert_eq!(
770 expand_from_args("img:${EMPTY:-fallback}", &vars),
771 "img:fallback"
772 );
773 assert_eq!(expand_from_args("img:${TAG:+pinned}", &vars), "img:pinned");
774 assert_eq!(expand_from_args("img:${MISSING:+pinned}", &vars), "img:");
775 assert_eq!(expand_from_args("${MISSING}", &vars), "");
776 assert_eq!(expand_from_args("a$$b", &vars), "a$b");
779 assert_eq!(expand_from_args("price$", &vars), "price$");
780 }
781
782 #[test]
783 fn resolve_from_args_expands_from_lines() {
784 let content = r"
785ARG BASE_IMAGE=ghcr.io/org/alpine:latest
786ARG BASE_TAG=latest
787FROM ${BASE_IMAGE} AS builder
788RUN echo hi
789FROM ghcr.io/org/alpine:${BASE_TAG}
790COPY --from=builder /x /x
791";
792 let mut dockerfile = Dockerfile::parse(content).unwrap();
793 assert!(dockerfile.stages[0].base_image.is_stage());
795 assert!(dockerfile.stages[1].base_image.is_stage());
796
797 let build_args: HashMap<String, String> = [("BASE_TAG".to_string(), "3.20".to_string())]
798 .into_iter()
799 .collect();
800 dockerfile.resolve_from_args(&build_args);
801
802 match &dockerfile.stages[0].base_image {
803 DockerfileFromTarget::Image(r) => {
804 assert_eq!(r.to_string(), "ghcr.io/org/alpine:latest");
805 }
806 other => panic!("stage 0 not resolved to an image: {other:?}"),
807 }
808 match &dockerfile.stages[1].base_image {
809 DockerfileFromTarget::Image(r) => {
810 assert_eq!(r.to_string(), "ghcr.io/org/alpine:3.20");
812 }
813 other => panic!("stage 1 not resolved to an image: {other:?}"),
814 }
815 }
816
817 #[test]
818 fn resolve_from_args_keeps_stage_references() {
819 let content = r"
820ARG BASE=ghcr.io/org/alpine:latest
821FROM ${BASE} AS builder
822RUN echo hi
823FROM builder
824RUN echo again
825";
826 let mut dockerfile = Dockerfile::parse(content).unwrap();
827 dockerfile.resolve_from_args(&HashMap::new());
828 assert!(matches!(
829 &dockerfile.stages[0].base_image,
830 DockerfileFromTarget::Image(_)
831 ));
832 assert_eq!(
834 dockerfile.stages[1].base_image,
835 DockerfileFromTarget::Stage("builder".to_string())
836 );
837 }
838
839 #[test]
840 fn test_parse_global_args() {
841 let content = r#"
842ARG BASE_IMAGE=alpine:3.18
843FROM ${BASE_IMAGE}
844RUN echo "hello"
845"#;
846
847 let dockerfile = Dockerfile::parse(content).unwrap();
848 assert_eq!(dockerfile.global_args.len(), 1);
849 assert_eq!(dockerfile.global_args[0].name, "BASE_IMAGE");
850 assert_eq!(
851 dockerfile.global_args[0].default,
852 Some("alpine:3.18".to_string())
853 );
854 }
855
856 #[test]
857 fn test_get_stage_by_name() {
858 let content = r#"
859FROM alpine:3.18 AS base
860RUN echo "base"
861
862FROM base AS builder
863RUN echo "builder"
864"#;
865
866 let dockerfile = Dockerfile::parse(content).unwrap();
867
868 let base = dockerfile.get_stage("base");
869 assert!(base.is_some());
870 assert_eq!(base.unwrap().index, 0);
871
872 let builder = dockerfile.get_stage("builder");
873 assert!(builder.is_some());
874 assert_eq!(builder.unwrap().index, 1);
875
876 let stage_0 = dockerfile.get_stage("0");
877 assert!(stage_0.is_some());
878 assert_eq!(stage_0.unwrap().name, Some("base".to_string()));
879 }
880
881 #[test]
882 fn test_final_stage() {
883 let content = r#"
884FROM alpine:3.18 AS builder
885RUN echo "builder"
886
887FROM scratch
888COPY --from=builder /app /app
889"#;
890
891 let dockerfile = Dockerfile::parse(content).unwrap();
892 let final_stage = dockerfile.final_stage().unwrap();
893
894 assert_eq!(final_stage.index, 1);
895 assert!(matches!(
896 final_stage.base_image,
897 DockerfileFromTarget::Scratch
898 ));
899 }
900
901 #[test]
902 fn test_parse_env_instruction() {
903 let content = r"
904FROM alpine
905ENV FOO=bar BAZ=qux
906";
907
908 let dockerfile = Dockerfile::parse(content).unwrap();
909 let stage = &dockerfile.stages[0];
910
911 let env = stage
912 .instructions
913 .iter()
914 .find(|i| matches!(i, Instruction::Env(_)));
915 assert!(env.is_some());
916
917 if let Some(Instruction::Env(e)) = env {
918 assert_eq!(e.vars.get("FOO"), Some(&"bar".to_string()));
919 assert_eq!(e.vars.get("BAZ"), Some(&"qux".to_string()));
920 }
921 }
922}