Skip to main content

zlayer_builder/buildah/
mod.rs

1//! Buildah command generation and execution
2//!
3//! This module provides types and functions for converting Dockerfile instructions
4//! into buildah commands and executing them.
5//!
6//! # Installation
7//!
8//! The [`BuildahInstaller`] type can be used to find existing buildah installations
9//! or provide helpful installation instructions when buildah is not found.
10//!
11//! ```no_run
12//! use zlayer_builder::buildah::{BuildahInstaller, BuildahExecutor};
13//!
14//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
15//! // Ensure buildah is available
16//! let installer = BuildahInstaller::new();
17//! let installation = installer.ensure().await?;
18//!
19//! // Create executor using found installation
20//! let executor = BuildahExecutor::with_path(installation.path);
21//! # Ok(())
22//! # }
23//! ```
24
25mod executor;
26mod install;
27
28pub use executor::*;
29pub use install::{
30    current_platform, install_instructions, is_platform_supported, BuildahInstallation,
31    BuildahInstaller, InstallError,
32};
33
34use crate::dockerfile::{
35    AddInstruction, CopyInstruction, EnvInstruction, ExposeInstruction, HealthcheckInstruction,
36    Instruction, RunInstruction, ShellOrExec,
37};
38
39use std::collections::HashMap;
40
41/// A buildah command ready for execution
42#[derive(Debug, Clone)]
43pub struct BuildahCommand {
44    /// The program to execute (typically "buildah")
45    pub program: String,
46
47    /// Command arguments
48    pub args: Vec<String>,
49
50    /// Optional environment variables for the command
51    pub env: HashMap<String, String>,
52}
53
54impl BuildahCommand {
55    /// Create a new buildah command
56    #[must_use]
57    pub fn new(subcommand: &str) -> Self {
58        Self {
59            program: "buildah".to_string(),
60            args: vec![subcommand.to_string()],
61            env: HashMap::new(),
62        }
63    }
64
65    /// Add an argument
66    #[must_use]
67    pub fn arg(mut self, arg: impl Into<String>) -> Self {
68        self.args.push(arg.into());
69        self
70    }
71
72    /// Add multiple arguments
73    #[must_use]
74    pub fn args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
75        self.args.extend(args.into_iter().map(Into::into));
76        self
77    }
78
79    /// Add an optional argument (only added if value is Some)
80    #[must_use]
81    pub fn arg_opt(self, flag: &str, value: Option<impl Into<String>>) -> Self {
82        if let Some(v) = value {
83            self.arg(flag).arg(v)
84        } else {
85            self
86        }
87    }
88
89    /// Add an environment variable for command execution
90    #[must_use]
91    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
92        self.env.insert(key.into(), value.into());
93        self
94    }
95
96    /// Convert to a command line string for display/logging
97    #[must_use]
98    pub fn to_command_string(&self) -> String {
99        let mut parts = vec![self.program.clone()];
100        parts.extend(self.args.iter().map(|a| {
101            if a.contains(' ') || a.contains('"') {
102                format!("\"{}\"", a.replace('"', "\\\""))
103            } else {
104                a.clone()
105            }
106        }));
107        parts.join(" ")
108    }
109
110    // =========================================================================
111    // Container Lifecycle Commands
112    // =========================================================================
113
114    /// Create a new working container from an image
115    ///
116    /// `buildah from <image>`
117    #[must_use]
118    pub fn from_image(image: &str) -> Self {
119        Self::new("from").arg(image)
120    }
121
122    /// Create a new working container from an image with a specific name
123    ///
124    /// `buildah from --name <name> <image>`
125    #[must_use]
126    pub fn from_image_named(image: &str, name: &str) -> Self {
127        Self::new("from").arg("--name").arg(name).arg(image)
128    }
129
130    /// Create a scratch container
131    ///
132    /// `buildah from scratch`
133    #[must_use]
134    pub fn from_scratch() -> Self {
135        Self::new("from").arg("scratch")
136    }
137
138    /// Remove a working container
139    ///
140    /// `buildah rm <container>`
141    #[must_use]
142    pub fn rm(container: &str) -> Self {
143        Self::new("rm").arg(container)
144    }
145
146    /// Commit a container to create an image
147    ///
148    /// `buildah commit <container> <image>`
149    #[must_use]
150    pub fn commit(container: &str, image_name: &str) -> Self {
151        Self::new("commit").arg(container).arg(image_name)
152    }
153
154    /// Commit with additional options
155    #[must_use]
156    pub fn commit_with_opts(
157        container: &str,
158        image_name: &str,
159        format: Option<&str>,
160        squash: bool,
161    ) -> Self {
162        let mut cmd = Self::new("commit");
163
164        if let Some(fmt) = format {
165            cmd = cmd.arg("--format").arg(fmt);
166        }
167
168        if squash {
169            cmd = cmd.arg("--squash");
170        }
171
172        cmd.arg(container).arg(image_name)
173    }
174
175    /// Tag an image with a new name
176    ///
177    /// `buildah tag <image> <new-name>`
178    #[must_use]
179    pub fn tag(image: &str, new_name: &str) -> Self {
180        Self::new("tag").arg(image).arg(new_name)
181    }
182
183    /// Remove an image
184    ///
185    /// `buildah rmi <image>`
186    #[must_use]
187    pub fn rmi(image: &str) -> Self {
188        Self::new("rmi").arg(image)
189    }
190
191    /// Push an image to a registry
192    ///
193    /// `buildah push <image>`
194    #[must_use]
195    pub fn push(image: &str) -> Self {
196        Self::new("push").arg(image)
197    }
198
199    /// Push an image to a registry with options
200    ///
201    /// `buildah push [options] <image> [destination]`
202    #[must_use]
203    pub fn push_to(image: &str, destination: &str) -> Self {
204        Self::new("push").arg(image).arg(destination)
205    }
206
207    /// Inspect an image or container
208    ///
209    /// `buildah inspect <name>`
210    #[must_use]
211    pub fn inspect(name: &str) -> Self {
212        Self::new("inspect").arg(name)
213    }
214
215    /// Inspect an image or container with format
216    ///
217    /// `buildah inspect --format <format> <name>`
218    #[must_use]
219    pub fn inspect_format(name: &str, format: &str) -> Self {
220        Self::new("inspect").arg("--format").arg(format).arg(name)
221    }
222
223    /// List images
224    ///
225    /// `buildah images`
226    #[must_use]
227    pub fn images() -> Self {
228        Self::new("images")
229    }
230
231    /// List containers
232    ///
233    /// `buildah containers`
234    #[must_use]
235    pub fn containers() -> Self {
236        Self::new("containers")
237    }
238
239    // =========================================================================
240    // Run Commands
241    // =========================================================================
242
243    /// Run a command in the container (shell form)
244    ///
245    /// `buildah run <container> -- /bin/sh -c "<command>"`
246    #[must_use]
247    pub fn run_shell(container: &str, command: &str) -> Self {
248        Self::new("run")
249            .arg(container)
250            .arg("--")
251            .arg("/bin/sh")
252            .arg("-c")
253            .arg(command)
254    }
255
256    /// Run a command in the container (exec form)
257    ///
258    /// `buildah run <container> -- <args...>`
259    #[must_use]
260    pub fn run_exec(container: &str, args: &[String]) -> Self {
261        let mut cmd = Self::new("run").arg(container).arg("--");
262        for arg in args {
263            cmd = cmd.arg(arg);
264        }
265        cmd
266    }
267
268    /// Run a command based on `ShellOrExec`
269    #[must_use]
270    pub fn run(container: &str, command: &ShellOrExec) -> Self {
271        match command {
272            ShellOrExec::Shell(s) => Self::run_shell(container, s),
273            ShellOrExec::Exec(args) => Self::run_exec(container, args),
274        }
275    }
276
277    /// Run a command with mount specifications from a `RunInstruction`.
278    ///
279    /// Buildah requires `--mount` arguments to appear BEFORE the container ID:
280    /// `buildah run [--mount=...] <container> -- <command>`
281    ///
282    /// This method properly orders the arguments to ensure mounts are applied.
283    #[must_use]
284    pub fn run_with_mounts(container: &str, run: &RunInstruction) -> Self {
285        let mut cmd = Self::new("run");
286
287        // Add --mount arguments BEFORE the container ID
288        for mount in &run.mounts {
289            cmd = cmd.arg(format!("--mount={}", mount.to_buildah_arg()));
290        }
291
292        // Now add container and the command
293        cmd = cmd.arg(container).arg("--");
294
295        match &run.command {
296            ShellOrExec::Shell(s) => cmd.arg("/bin/sh").arg("-c").arg(s),
297            ShellOrExec::Exec(args) => {
298                for arg in args {
299                    cmd = cmd.arg(arg);
300                }
301                cmd
302            }
303        }
304    }
305
306    // =========================================================================
307    // Copy/Add Commands
308    // =========================================================================
309
310    /// Copy files into the container
311    ///
312    /// `buildah copy <container> <src...> <dest>`
313    #[must_use]
314    pub fn copy(container: &str, sources: &[String], dest: &str) -> Self {
315        let mut cmd = Self::new("copy").arg(container);
316        for src in sources {
317            cmd = cmd.arg(src);
318        }
319        cmd.arg(dest)
320    }
321
322    /// Copy files from another container/image
323    ///
324    /// `buildah copy --from=<source> <container> <src...> <dest>`
325    #[must_use]
326    pub fn copy_from(container: &str, from: &str, sources: &[String], dest: &str) -> Self {
327        let mut cmd = Self::new("copy").arg("--from").arg(from).arg(container);
328        for src in sources {
329            cmd = cmd.arg(src);
330        }
331        cmd.arg(dest)
332    }
333
334    /// Copy with all options from `CopyInstruction`
335    #[must_use]
336    pub fn copy_instruction(container: &str, copy: &CopyInstruction) -> Self {
337        let mut cmd = Self::new("copy");
338
339        if let Some(ref from) = copy.from {
340            cmd = cmd.arg("--from").arg(from);
341        }
342
343        if let Some(ref chown) = copy.chown {
344            cmd = cmd.arg("--chown").arg(chown);
345        }
346
347        if let Some(ref chmod) = copy.chmod {
348            cmd = cmd.arg("--chmod").arg(chmod);
349        }
350
351        cmd = cmd.arg(container);
352
353        for src in &copy.sources {
354            cmd = cmd.arg(src);
355        }
356
357        cmd.arg(&copy.destination)
358    }
359
360    /// Add files (like copy but with URL support and extraction)
361    #[must_use]
362    pub fn add(container: &str, sources: &[String], dest: &str) -> Self {
363        let mut cmd = Self::new("add").arg(container);
364        for src in sources {
365            cmd = cmd.arg(src);
366        }
367        cmd.arg(dest)
368    }
369
370    /// Add with all options from `AddInstruction`
371    #[must_use]
372    pub fn add_instruction(container: &str, add: &AddInstruction) -> Self {
373        let mut cmd = Self::new("add");
374
375        if let Some(ref chown) = add.chown {
376            cmd = cmd.arg("--chown").arg(chown);
377        }
378
379        if let Some(ref chmod) = add.chmod {
380            cmd = cmd.arg("--chmod").arg(chmod);
381        }
382
383        cmd = cmd.arg(container);
384
385        for src in &add.sources {
386            cmd = cmd.arg(src);
387        }
388
389        cmd.arg(&add.destination)
390    }
391
392    // =========================================================================
393    // Config Commands
394    // =========================================================================
395
396    /// Set an environment variable
397    ///
398    /// `buildah config --env KEY=VALUE <container>`
399    #[must_use]
400    pub fn config_env(container: &str, key: &str, value: &str) -> Self {
401        Self::new("config")
402            .arg("--env")
403            .arg(format!("{key}={value}"))
404            .arg(container)
405    }
406
407    /// Set multiple environment variables
408    #[must_use]
409    pub fn config_envs(container: &str, env: &EnvInstruction) -> Vec<Self> {
410        env.vars
411            .iter()
412            .map(|(k, v)| Self::config_env(container, k, v))
413            .collect()
414    }
415
416    /// Set the working directory
417    ///
418    /// `buildah config --workingdir <dir> <container>`
419    #[must_use]
420    pub fn config_workdir(container: &str, dir: &str) -> Self {
421        Self::new("config")
422            .arg("--workingdir")
423            .arg(dir)
424            .arg(container)
425    }
426
427    /// Expose a port
428    ///
429    /// `buildah config --port <port>/<proto> <container>`
430    #[must_use]
431    pub fn config_expose(container: &str, expose: &ExposeInstruction) -> Self {
432        let port_spec = format!(
433            "{}/{}",
434            expose.port,
435            match expose.protocol {
436                crate::dockerfile::ExposeProtocol::Tcp => "tcp",
437                crate::dockerfile::ExposeProtocol::Udp => "udp",
438            }
439        );
440        Self::new("config")
441            .arg("--port")
442            .arg(port_spec)
443            .arg(container)
444    }
445
446    /// Set the entrypoint (shell form)
447    ///
448    /// `buildah config --entrypoint '<command>' <container>`
449    #[must_use]
450    pub fn config_entrypoint_shell(container: &str, command: &str) -> Self {
451        Self::new("config")
452            .arg("--entrypoint")
453            .arg(format!(
454                "[\"/bin/sh\", \"-c\", \"{}\"]",
455                escape_json_string(command)
456            ))
457            .arg(container)
458    }
459
460    /// Set the entrypoint (exec form)
461    ///
462    /// `buildah config --entrypoint '["exe", "arg1"]' <container>`
463    #[must_use]
464    pub fn config_entrypoint_exec(container: &str, args: &[String]) -> Self {
465        let json_array = format!(
466            "[{}]",
467            args.iter()
468                .map(|a| format!("\"{}\"", escape_json_string(a)))
469                .collect::<Vec<_>>()
470                .join(", ")
471        );
472        Self::new("config")
473            .arg("--entrypoint")
474            .arg(json_array)
475            .arg(container)
476    }
477
478    /// Set the entrypoint based on `ShellOrExec`
479    #[must_use]
480    pub fn config_entrypoint(container: &str, command: &ShellOrExec) -> Self {
481        match command {
482            ShellOrExec::Shell(s) => Self::config_entrypoint_shell(container, s),
483            ShellOrExec::Exec(args) => Self::config_entrypoint_exec(container, args),
484        }
485    }
486
487    /// Set the default command (shell form)
488    #[must_use]
489    pub fn config_cmd_shell(container: &str, command: &str) -> Self {
490        Self::new("config")
491            .arg("--cmd")
492            .arg(format!("/bin/sh -c \"{}\"", escape_json_string(command)))
493            .arg(container)
494    }
495
496    /// Set the default command (exec form)
497    #[must_use]
498    pub fn config_cmd_exec(container: &str, args: &[String]) -> Self {
499        let json_array = format!(
500            "[{}]",
501            args.iter()
502                .map(|a| format!("\"{}\"", escape_json_string(a)))
503                .collect::<Vec<_>>()
504                .join(", ")
505        );
506        Self::new("config")
507            .arg("--cmd")
508            .arg(json_array)
509            .arg(container)
510    }
511
512    /// Set the default command based on `ShellOrExec`
513    #[must_use]
514    pub fn config_cmd(container: &str, command: &ShellOrExec) -> Self {
515        match command {
516            ShellOrExec::Shell(s) => Self::config_cmd_shell(container, s),
517            ShellOrExec::Exec(args) => Self::config_cmd_exec(container, args),
518        }
519    }
520
521    /// Set the user
522    ///
523    /// `buildah config --user <user> <container>`
524    #[must_use]
525    pub fn config_user(container: &str, user: &str) -> Self {
526        Self::new("config").arg("--user").arg(user).arg(container)
527    }
528
529    /// Set a label
530    ///
531    /// `buildah config --label KEY=VALUE <container>`
532    #[must_use]
533    pub fn config_label(container: &str, key: &str, value: &str) -> Self {
534        Self::new("config")
535            .arg("--label")
536            .arg(format!("{key}={value}"))
537            .arg(container)
538    }
539
540    /// Set multiple labels
541    #[must_use]
542    pub fn config_labels(container: &str, labels: &HashMap<String, String>) -> Vec<Self> {
543        labels
544            .iter()
545            .map(|(k, v)| Self::config_label(container, k, v))
546            .collect()
547    }
548
549    /// Set volumes
550    ///
551    /// `buildah config --volume <path> <container>`
552    #[must_use]
553    pub fn config_volume(container: &str, path: &str) -> Self {
554        Self::new("config").arg("--volume").arg(path).arg(container)
555    }
556
557    /// Set the stop signal
558    ///
559    /// `buildah config --stop-signal <signal> <container>`
560    #[must_use]
561    pub fn config_stopsignal(container: &str, signal: &str) -> Self {
562        Self::new("config")
563            .arg("--stop-signal")
564            .arg(signal)
565            .arg(container)
566    }
567
568    /// Set the shell
569    ///
570    /// `buildah config --shell '["shell", "args"]' <container>`
571    #[must_use]
572    pub fn config_shell(container: &str, shell: &[String]) -> Self {
573        let json_array = format!(
574            "[{}]",
575            shell
576                .iter()
577                .map(|a| format!("\"{}\"", escape_json_string(a)))
578                .collect::<Vec<_>>()
579                .join(", ")
580        );
581        Self::new("config")
582            .arg("--shell")
583            .arg(json_array)
584            .arg(container)
585    }
586
587    /// Set healthcheck
588    #[must_use]
589    pub fn config_healthcheck(container: &str, healthcheck: &HealthcheckInstruction) -> Self {
590        match healthcheck {
591            HealthcheckInstruction::None => Self::new("config")
592                .arg("--healthcheck")
593                .arg("NONE")
594                .arg(container),
595            HealthcheckInstruction::Check {
596                command,
597                interval,
598                timeout,
599                start_period,
600                retries,
601                ..
602            } => {
603                let mut cmd = Self::new("config");
604
605                let cmd_str = match command {
606                    ShellOrExec::Shell(s) => format!("CMD {s}"),
607                    ShellOrExec::Exec(args) => {
608                        format!(
609                            "CMD [{}]",
610                            args.iter()
611                                .map(|a| format!("\"{}\"", escape_json_string(a)))
612                                .collect::<Vec<_>>()
613                                .join(", ")
614                        )
615                    }
616                };
617
618                cmd = cmd.arg("--healthcheck").arg(cmd_str);
619
620                if let Some(i) = interval {
621                    cmd = cmd
622                        .arg("--healthcheck-interval")
623                        .arg(format!("{}s", i.as_secs()));
624                }
625
626                if let Some(t) = timeout {
627                    cmd = cmd
628                        .arg("--healthcheck-timeout")
629                        .arg(format!("{}s", t.as_secs()));
630                }
631
632                if let Some(sp) = start_period {
633                    cmd = cmd
634                        .arg("--healthcheck-start-period")
635                        .arg(format!("{}s", sp.as_secs()));
636                }
637
638                if let Some(r) = retries {
639                    cmd = cmd.arg("--healthcheck-retries").arg(r.to_string());
640                }
641
642                cmd.arg(container)
643            }
644        }
645    }
646
647    // =========================================================================
648    // Convert Instruction to Commands
649    // =========================================================================
650
651    /// Convert a Dockerfile instruction to buildah command(s)
652    ///
653    /// Some instructions map to multiple buildah commands (e.g., multiple ENV vars)
654    pub fn from_instruction(container: &str, instruction: &Instruction) -> Vec<Self> {
655        match instruction {
656            Instruction::Run(run) => {
657                // Use run_with_mounts if there are any mounts, otherwise use simple run
658                if run.mounts.is_empty() {
659                    vec![Self::run(container, &run.command)]
660                } else {
661                    vec![Self::run_with_mounts(container, run)]
662                }
663            }
664
665            Instruction::Copy(copy) => {
666                vec![Self::copy_instruction(container, copy)]
667            }
668
669            Instruction::Add(add) => {
670                vec![Self::add_instruction(container, add)]
671            }
672
673            Instruction::Env(env) => Self::config_envs(container, env),
674
675            Instruction::Workdir(dir) => {
676                vec![Self::config_workdir(container, dir)]
677            }
678
679            Instruction::Expose(expose) => {
680                vec![Self::config_expose(container, expose)]
681            }
682
683            Instruction::Label(labels) => Self::config_labels(container, labels),
684
685            Instruction::User(user) => {
686                vec![Self::config_user(container, user)]
687            }
688
689            Instruction::Entrypoint(cmd) => {
690                vec![Self::config_entrypoint(container, cmd)]
691            }
692
693            Instruction::Cmd(cmd) => {
694                vec![Self::config_cmd(container, cmd)]
695            }
696
697            Instruction::Volume(paths) => paths
698                .iter()
699                .map(|p| Self::config_volume(container, p))
700                .collect(),
701
702            Instruction::Shell(shell) => {
703                vec![Self::config_shell(container, shell)]
704            }
705
706            Instruction::Arg(_) => {
707                // ARG is handled during variable expansion, not as a buildah command
708                vec![]
709            }
710
711            Instruction::Stopsignal(signal) => {
712                vec![Self::config_stopsignal(container, signal)]
713            }
714
715            Instruction::Healthcheck(hc) => {
716                vec![Self::config_healthcheck(container, hc)]
717            }
718
719            Instruction::Onbuild(_) => {
720                // ONBUILD would need special handling
721                tracing::warn!("ONBUILD instruction not supported in buildah conversion");
722                vec![]
723            }
724        }
725    }
726}
727
728/// Escape a string for use in JSON
729fn escape_json_string(s: &str) -> String {
730    s.replace('\\', "\\\\")
731        .replace('"', "\\\"")
732        .replace('\n', "\\n")
733        .replace('\r', "\\r")
734        .replace('\t', "\\t")
735}
736
737#[cfg(test)]
738mod tests {
739    use super::*;
740    use crate::dockerfile::RunInstruction;
741
742    #[test]
743    fn test_from_image() {
744        let cmd = BuildahCommand::from_image("alpine:3.18");
745        assert_eq!(cmd.program, "buildah");
746        assert_eq!(cmd.args, vec!["from", "alpine:3.18"]);
747    }
748
749    #[test]
750    fn test_run_shell() {
751        let cmd = BuildahCommand::run_shell("container-1", "apt-get update");
752        assert_eq!(
753            cmd.args,
754            vec![
755                "run",
756                "container-1",
757                "--",
758                "/bin/sh",
759                "-c",
760                "apt-get update"
761            ]
762        );
763    }
764
765    #[test]
766    fn test_run_exec() {
767        let args = vec!["echo".to_string(), "hello".to_string()];
768        let cmd = BuildahCommand::run_exec("container-1", &args);
769        assert_eq!(cmd.args, vec!["run", "container-1", "--", "echo", "hello"]);
770    }
771
772    #[test]
773    fn test_copy() {
774        let sources = vec!["src/".to_string(), "Cargo.toml".to_string()];
775        let cmd = BuildahCommand::copy("container-1", &sources, "/app/");
776        assert_eq!(
777            cmd.args,
778            vec!["copy", "container-1", "src/", "Cargo.toml", "/app/"]
779        );
780    }
781
782    #[test]
783    fn test_copy_from() {
784        let sources = vec!["/app".to_string()];
785        let cmd = BuildahCommand::copy_from("container-1", "builder", &sources, "/app");
786        assert_eq!(
787            cmd.args,
788            vec!["copy", "--from", "builder", "container-1", "/app", "/app"]
789        );
790    }
791
792    #[test]
793    fn test_config_env() {
794        let cmd = BuildahCommand::config_env("container-1", "PATH", "/usr/local/bin");
795        assert_eq!(
796            cmd.args,
797            vec!["config", "--env", "PATH=/usr/local/bin", "container-1"]
798        );
799    }
800
801    #[test]
802    fn test_config_workdir() {
803        let cmd = BuildahCommand::config_workdir("container-1", "/app");
804        assert_eq!(
805            cmd.args,
806            vec!["config", "--workingdir", "/app", "container-1"]
807        );
808    }
809
810    #[test]
811    fn test_config_entrypoint_exec() {
812        let args = vec!["/app".to_string(), "--config".to_string()];
813        let cmd = BuildahCommand::config_entrypoint_exec("container-1", &args);
814        assert!(cmd.args.contains(&"--entrypoint".to_string()));
815        assert!(cmd
816            .args
817            .iter()
818            .any(|a| a.contains("[") && a.contains("/app")));
819    }
820
821    #[test]
822    fn test_commit() {
823        let cmd = BuildahCommand::commit("container-1", "myimage:latest");
824        assert_eq!(cmd.args, vec!["commit", "container-1", "myimage:latest"]);
825    }
826
827    #[test]
828    fn test_to_command_string() {
829        let cmd = BuildahCommand::config_env("container-1", "VAR", "value with spaces");
830        let s = cmd.to_command_string();
831        assert!(s.starts_with("buildah config"));
832        assert!(s.contains("VAR=value with spaces"));
833    }
834
835    #[test]
836    fn test_from_instruction_run() {
837        let instruction = Instruction::Run(RunInstruction {
838            command: ShellOrExec::Shell("echo hello".to_string()),
839            mounts: vec![],
840            network: None,
841            security: None,
842        });
843
844        let cmds = BuildahCommand::from_instruction("container-1", &instruction);
845        assert_eq!(cmds.len(), 1);
846        assert!(cmds[0].args.contains(&"run".to_string()));
847    }
848
849    #[test]
850    fn test_from_instruction_env_multiple() {
851        let mut vars = HashMap::new();
852        vars.insert("FOO".to_string(), "bar".to_string());
853        vars.insert("BAZ".to_string(), "qux".to_string());
854
855        let instruction = Instruction::Env(EnvInstruction { vars });
856        let cmds = BuildahCommand::from_instruction("container-1", &instruction);
857
858        // Should produce two config commands (one per env var)
859        assert_eq!(cmds.len(), 2);
860        for cmd in &cmds {
861            assert!(cmd.args.contains(&"config".to_string()));
862            assert!(cmd.args.contains(&"--env".to_string()));
863        }
864    }
865
866    #[test]
867    fn test_escape_json_string() {
868        assert_eq!(escape_json_string("hello"), "hello");
869        assert_eq!(escape_json_string("hello \"world\""), "hello \\\"world\\\"");
870        assert_eq!(escape_json_string("line1\nline2"), "line1\\nline2");
871    }
872
873    #[test]
874    fn test_run_with_mounts_cache() {
875        use crate::dockerfile::{CacheSharing, RunMount};
876
877        let run = RunInstruction {
878            command: ShellOrExec::Shell("apt-get update".to_string()),
879            mounts: vec![RunMount::Cache {
880                target: "/var/cache/apt".to_string(),
881                id: Some("apt-cache".to_string()),
882                sharing: CacheSharing::Shared,
883                readonly: false,
884            }],
885            network: None,
886            security: None,
887        };
888
889        let cmd = BuildahCommand::run_with_mounts("container-1", &run);
890
891        // Verify --mount comes BEFORE container ID
892        let mount_idx = cmd
893            .args
894            .iter()
895            .position(|a| a.starts_with("--mount="))
896            .expect("should have --mount arg");
897        let container_idx = cmd
898            .args
899            .iter()
900            .position(|a| a == "container-1")
901            .expect("should have container id");
902
903        assert!(
904            mount_idx < container_idx,
905            "--mount should come before container ID"
906        );
907
908        // Verify mount argument content
909        assert!(cmd.args[mount_idx].contains("type=cache"));
910        assert!(cmd.args[mount_idx].contains("target=/var/cache/apt"));
911        assert!(cmd.args[mount_idx].contains("id=apt-cache"));
912        assert!(cmd.args[mount_idx].contains("sharing=shared"));
913    }
914
915    #[test]
916    fn test_run_with_multiple_mounts() {
917        use crate::dockerfile::{CacheSharing, RunMount};
918
919        let run = RunInstruction {
920            command: ShellOrExec::Shell("cargo build".to_string()),
921            mounts: vec![
922                RunMount::Cache {
923                    target: "/usr/local/cargo/registry".to_string(),
924                    id: Some("cargo-registry".to_string()),
925                    sharing: CacheSharing::Shared,
926                    readonly: false,
927                },
928                RunMount::Cache {
929                    target: "/app/target".to_string(),
930                    id: Some("cargo-target".to_string()),
931                    sharing: CacheSharing::Locked,
932                    readonly: false,
933                },
934            ],
935            network: None,
936            security: None,
937        };
938
939        let cmd = BuildahCommand::run_with_mounts("container-1", &run);
940
941        // Count --mount arguments
942        let mount_count = cmd
943            .args
944            .iter()
945            .filter(|a| a.starts_with("--mount="))
946            .count();
947        assert_eq!(mount_count, 2, "should have 2 mount arguments");
948
949        // Verify all mounts come before container ID
950        let container_idx = cmd
951            .args
952            .iter()
953            .position(|a| a == "container-1")
954            .expect("should have container id");
955
956        for (idx, arg) in cmd.args.iter().enumerate() {
957            if arg.starts_with("--mount=") {
958                assert!(
959                    idx < container_idx,
960                    "--mount at index {} should come before container ID at {}",
961                    idx,
962                    container_idx
963                );
964            }
965        }
966    }
967
968    #[test]
969    fn test_from_instruction_run_with_mounts() {
970        use crate::dockerfile::{CacheSharing, RunMount};
971
972        let instruction = Instruction::Run(RunInstruction {
973            command: ShellOrExec::Shell("npm install".to_string()),
974            mounts: vec![RunMount::Cache {
975                target: "/root/.npm".to_string(),
976                id: Some("npm-cache".to_string()),
977                sharing: CacheSharing::Shared,
978                readonly: false,
979            }],
980            network: None,
981            security: None,
982        });
983
984        let cmds = BuildahCommand::from_instruction("container-1", &instruction);
985        assert_eq!(cmds.len(), 1);
986
987        let cmd = &cmds[0];
988        assert!(
989            cmd.args.iter().any(|a| a.starts_with("--mount=")),
990            "should include --mount argument"
991        );
992    }
993
994    #[test]
995    fn test_run_with_mounts_exec_form() {
996        use crate::dockerfile::{CacheSharing, RunMount};
997
998        let run = RunInstruction {
999            command: ShellOrExec::Exec(vec![
1000                "pip".to_string(),
1001                "install".to_string(),
1002                "-r".to_string(),
1003                "requirements.txt".to_string(),
1004            ]),
1005            mounts: vec![RunMount::Cache {
1006                target: "/root/.cache/pip".to_string(),
1007                id: Some("pip-cache".to_string()),
1008                sharing: CacheSharing::Shared,
1009                readonly: false,
1010            }],
1011            network: None,
1012            security: None,
1013        };
1014
1015        let cmd = BuildahCommand::run_with_mounts("container-1", &run);
1016
1017        // Should have mount, container, --, and then the exec args
1018        assert!(cmd.args.contains(&"--".to_string()));
1019        assert!(cmd.args.contains(&"pip".to_string()));
1020        assert!(cmd.args.contains(&"install".to_string()));
1021    }
1022}