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    // Manifest Commands
649    // =========================================================================
650
651    /// Create a new manifest list.
652    ///
653    /// `buildah manifest create <name>`
654    #[must_use]
655    pub fn manifest_create(name: &str) -> Self {
656        Self::new("manifest").arg("create").arg(name)
657    }
658
659    /// Add an image to a manifest list.
660    ///
661    /// `buildah manifest add <list> <image>`
662    #[must_use]
663    pub fn manifest_add(list: &str, image: &str) -> Self {
664        Self::new("manifest").arg("add").arg(list).arg(image)
665    }
666
667    /// Push a manifest list and all referenced images.
668    ///
669    /// `buildah manifest push --all <list> <destination>`
670    #[must_use]
671    pub fn manifest_push(list: &str, destination: &str) -> Self {
672        Self::new("manifest")
673            .arg("push")
674            .arg("--all")
675            .arg(list)
676            .arg(destination)
677    }
678
679    /// Remove a manifest list.
680    ///
681    /// `buildah manifest rm <list>`
682    #[must_use]
683    pub fn manifest_rm(list: &str) -> Self {
684        Self::new("manifest").arg("rm").arg(list)
685    }
686
687    // =========================================================================
688    // Convert Instruction to Commands
689    // =========================================================================
690
691    /// Convert a Dockerfile instruction to buildah command(s)
692    ///
693    /// Some instructions map to multiple buildah commands (e.g., multiple ENV vars)
694    pub fn from_instruction(container: &str, instruction: &Instruction) -> Vec<Self> {
695        match instruction {
696            Instruction::Run(run) => {
697                // Use run_with_mounts if there are any mounts, otherwise use simple run
698                if run.mounts.is_empty() {
699                    vec![Self::run(container, &run.command)]
700                } else {
701                    vec![Self::run_with_mounts(container, run)]
702                }
703            }
704
705            Instruction::Copy(copy) => {
706                vec![Self::copy_instruction(container, copy)]
707            }
708
709            Instruction::Add(add) => {
710                vec![Self::add_instruction(container, add)]
711            }
712
713            Instruction::Env(env) => Self::config_envs(container, env),
714
715            Instruction::Workdir(dir) => {
716                vec![Self::config_workdir(container, dir)]
717            }
718
719            Instruction::Expose(expose) => {
720                vec![Self::config_expose(container, expose)]
721            }
722
723            Instruction::Label(labels) => Self::config_labels(container, labels),
724
725            Instruction::User(user) => {
726                vec![Self::config_user(container, user)]
727            }
728
729            Instruction::Entrypoint(cmd) => {
730                vec![Self::config_entrypoint(container, cmd)]
731            }
732
733            Instruction::Cmd(cmd) => {
734                vec![Self::config_cmd(container, cmd)]
735            }
736
737            Instruction::Volume(paths) => paths
738                .iter()
739                .map(|p| Self::config_volume(container, p))
740                .collect(),
741
742            Instruction::Shell(shell) => {
743                vec![Self::config_shell(container, shell)]
744            }
745
746            Instruction::Arg(_) => {
747                // ARG is handled during variable expansion, not as a buildah command
748                vec![]
749            }
750
751            Instruction::Stopsignal(signal) => {
752                vec![Self::config_stopsignal(container, signal)]
753            }
754
755            Instruction::Healthcheck(hc) => {
756                vec![Self::config_healthcheck(container, hc)]
757            }
758
759            Instruction::Onbuild(_) => {
760                // ONBUILD would need special handling
761                tracing::warn!("ONBUILD instruction not supported in buildah conversion");
762                vec![]
763            }
764        }
765    }
766}
767
768/// Escape a string for use in JSON
769fn escape_json_string(s: &str) -> String {
770    s.replace('\\', "\\\\")
771        .replace('"', "\\\"")
772        .replace('\n', "\\n")
773        .replace('\r', "\\r")
774        .replace('\t', "\\t")
775}
776
777#[cfg(test)]
778mod tests {
779    use super::*;
780    use crate::dockerfile::RunInstruction;
781
782    #[test]
783    fn test_from_image() {
784        let cmd = BuildahCommand::from_image("alpine:3.18");
785        assert_eq!(cmd.program, "buildah");
786        assert_eq!(cmd.args, vec!["from", "alpine:3.18"]);
787    }
788
789    #[test]
790    fn test_run_shell() {
791        let cmd = BuildahCommand::run_shell("container-1", "apt-get update");
792        assert_eq!(
793            cmd.args,
794            vec![
795                "run",
796                "container-1",
797                "--",
798                "/bin/sh",
799                "-c",
800                "apt-get update"
801            ]
802        );
803    }
804
805    #[test]
806    fn test_run_exec() {
807        let args = vec!["echo".to_string(), "hello".to_string()];
808        let cmd = BuildahCommand::run_exec("container-1", &args);
809        assert_eq!(cmd.args, vec!["run", "container-1", "--", "echo", "hello"]);
810    }
811
812    #[test]
813    fn test_copy() {
814        let sources = vec!["src/".to_string(), "Cargo.toml".to_string()];
815        let cmd = BuildahCommand::copy("container-1", &sources, "/app/");
816        assert_eq!(
817            cmd.args,
818            vec!["copy", "container-1", "src/", "Cargo.toml", "/app/"]
819        );
820    }
821
822    #[test]
823    fn test_copy_from() {
824        let sources = vec!["/app".to_string()];
825        let cmd = BuildahCommand::copy_from("container-1", "builder", &sources, "/app");
826        assert_eq!(
827            cmd.args,
828            vec!["copy", "--from", "builder", "container-1", "/app", "/app"]
829        );
830    }
831
832    #[test]
833    fn test_config_env() {
834        let cmd = BuildahCommand::config_env("container-1", "PATH", "/usr/local/bin");
835        assert_eq!(
836            cmd.args,
837            vec!["config", "--env", "PATH=/usr/local/bin", "container-1"]
838        );
839    }
840
841    #[test]
842    fn test_config_workdir() {
843        let cmd = BuildahCommand::config_workdir("container-1", "/app");
844        assert_eq!(
845            cmd.args,
846            vec!["config", "--workingdir", "/app", "container-1"]
847        );
848    }
849
850    #[test]
851    fn test_config_entrypoint_exec() {
852        let args = vec!["/app".to_string(), "--config".to_string()];
853        let cmd = BuildahCommand::config_entrypoint_exec("container-1", &args);
854        assert!(cmd.args.contains(&"--entrypoint".to_string()));
855        assert!(cmd
856            .args
857            .iter()
858            .any(|a| a.contains('[') && a.contains("/app")));
859    }
860
861    #[test]
862    fn test_commit() {
863        let cmd = BuildahCommand::commit("container-1", "myimage:latest");
864        assert_eq!(cmd.args, vec!["commit", "container-1", "myimage:latest"]);
865    }
866
867    #[test]
868    fn test_to_command_string() {
869        let cmd = BuildahCommand::config_env("container-1", "VAR", "value with spaces");
870        let s = cmd.to_command_string();
871        assert!(s.starts_with("buildah config"));
872        assert!(s.contains("VAR=value with spaces"));
873    }
874
875    #[test]
876    fn test_from_instruction_run() {
877        let instruction = Instruction::Run(RunInstruction {
878            command: ShellOrExec::Shell("echo hello".to_string()),
879            mounts: vec![],
880            network: None,
881            security: None,
882        });
883
884        let cmds = BuildahCommand::from_instruction("container-1", &instruction);
885        assert_eq!(cmds.len(), 1);
886        assert!(cmds[0].args.contains(&"run".to_string()));
887    }
888
889    #[test]
890    fn test_from_instruction_env_multiple() {
891        let mut vars = HashMap::new();
892        vars.insert("FOO".to_string(), "bar".to_string());
893        vars.insert("BAZ".to_string(), "qux".to_string());
894
895        let instruction = Instruction::Env(EnvInstruction { vars });
896        let cmds = BuildahCommand::from_instruction("container-1", &instruction);
897
898        // Should produce two config commands (one per env var)
899        assert_eq!(cmds.len(), 2);
900        for cmd in &cmds {
901            assert!(cmd.args.contains(&"config".to_string()));
902            assert!(cmd.args.contains(&"--env".to_string()));
903        }
904    }
905
906    #[test]
907    fn test_escape_json_string() {
908        assert_eq!(escape_json_string("hello"), "hello");
909        assert_eq!(escape_json_string("hello \"world\""), "hello \\\"world\\\"");
910        assert_eq!(escape_json_string("line1\nline2"), "line1\\nline2");
911    }
912
913    #[test]
914    fn test_run_with_mounts_cache() {
915        use crate::dockerfile::{CacheSharing, RunMount};
916
917        let run = RunInstruction {
918            command: ShellOrExec::Shell("apt-get update".to_string()),
919            mounts: vec![RunMount::Cache {
920                target: "/var/cache/apt".to_string(),
921                id: Some("apt-cache".to_string()),
922                sharing: CacheSharing::Shared,
923                readonly: false,
924            }],
925            network: None,
926            security: None,
927        };
928
929        let cmd = BuildahCommand::run_with_mounts("container-1", &run);
930
931        // Verify --mount comes BEFORE container ID
932        let mount_idx = cmd
933            .args
934            .iter()
935            .position(|a| a.starts_with("--mount="))
936            .expect("should have --mount arg");
937        let container_idx = cmd
938            .args
939            .iter()
940            .position(|a| a == "container-1")
941            .expect("should have container id");
942
943        assert!(
944            mount_idx < container_idx,
945            "--mount should come before container ID"
946        );
947
948        // Verify mount argument content
949        assert!(cmd.args[mount_idx].contains("type=cache"));
950        assert!(cmd.args[mount_idx].contains("target=/var/cache/apt"));
951        assert!(cmd.args[mount_idx].contains("id=apt-cache"));
952        assert!(cmd.args[mount_idx].contains("sharing=shared"));
953    }
954
955    #[test]
956    fn test_run_with_multiple_mounts() {
957        use crate::dockerfile::{CacheSharing, RunMount};
958
959        let run = RunInstruction {
960            command: ShellOrExec::Shell("cargo build".to_string()),
961            mounts: vec![
962                RunMount::Cache {
963                    target: "/usr/local/cargo/registry".to_string(),
964                    id: Some("cargo-registry".to_string()),
965                    sharing: CacheSharing::Shared,
966                    readonly: false,
967                },
968                RunMount::Cache {
969                    target: "/app/target".to_string(),
970                    id: Some("cargo-target".to_string()),
971                    sharing: CacheSharing::Locked,
972                    readonly: false,
973                },
974            ],
975            network: None,
976            security: None,
977        };
978
979        let cmd = BuildahCommand::run_with_mounts("container-1", &run);
980
981        // Count --mount arguments
982        let mount_count = cmd
983            .args
984            .iter()
985            .filter(|a| a.starts_with("--mount="))
986            .count();
987        assert_eq!(mount_count, 2, "should have 2 mount arguments");
988
989        // Verify all mounts come before container ID
990        let container_idx = cmd
991            .args
992            .iter()
993            .position(|a| a == "container-1")
994            .expect("should have container id");
995
996        for (idx, arg) in cmd.args.iter().enumerate() {
997            if arg.starts_with("--mount=") {
998                assert!(
999                    idx < container_idx,
1000                    "--mount at index {idx} should come before container ID at {container_idx}",
1001                );
1002            }
1003        }
1004    }
1005
1006    #[test]
1007    fn test_from_instruction_run_with_mounts() {
1008        use crate::dockerfile::{CacheSharing, RunMount};
1009
1010        let instruction = Instruction::Run(RunInstruction {
1011            command: ShellOrExec::Shell("npm install".to_string()),
1012            mounts: vec![RunMount::Cache {
1013                target: "/root/.npm".to_string(),
1014                id: Some("npm-cache".to_string()),
1015                sharing: CacheSharing::Shared,
1016                readonly: false,
1017            }],
1018            network: None,
1019            security: None,
1020        });
1021
1022        let cmds = BuildahCommand::from_instruction("container-1", &instruction);
1023        assert_eq!(cmds.len(), 1);
1024
1025        let cmd = &cmds[0];
1026        assert!(
1027            cmd.args.iter().any(|a| a.starts_with("--mount=")),
1028            "should include --mount argument"
1029        );
1030    }
1031
1032    #[test]
1033    fn test_run_with_mounts_exec_form() {
1034        use crate::dockerfile::{CacheSharing, RunMount};
1035
1036        let run = RunInstruction {
1037            command: ShellOrExec::Exec(vec![
1038                "pip".to_string(),
1039                "install".to_string(),
1040                "-r".to_string(),
1041                "requirements.txt".to_string(),
1042            ]),
1043            mounts: vec![RunMount::Cache {
1044                target: "/root/.cache/pip".to_string(),
1045                id: Some("pip-cache".to_string()),
1046                sharing: CacheSharing::Shared,
1047                readonly: false,
1048            }],
1049            network: None,
1050            security: None,
1051        };
1052
1053        let cmd = BuildahCommand::run_with_mounts("container-1", &run);
1054
1055        // Should have mount, container, --, and then the exec args
1056        assert!(cmd.args.contains(&"--".to_string()));
1057        assert!(cmd.args.contains(&"pip".to_string()));
1058        assert!(cmd.args.contains(&"install".to_string()));
1059    }
1060
1061    #[test]
1062    fn test_manifest_create() {
1063        let cmd = BuildahCommand::manifest_create("myapp:latest");
1064        assert_eq!(cmd.program, "buildah");
1065        assert_eq!(cmd.args, vec!["manifest", "create", "myapp:latest"]);
1066    }
1067
1068    #[test]
1069    fn test_manifest_add() {
1070        let cmd = BuildahCommand::manifest_add("myapp:latest", "myapp-amd64:latest");
1071        assert_eq!(
1072            cmd.args,
1073            vec!["manifest", "add", "myapp:latest", "myapp-amd64:latest"]
1074        );
1075    }
1076
1077    #[test]
1078    fn test_manifest_push() {
1079        let cmd =
1080            BuildahCommand::manifest_push("myapp:latest", "docker://registry.example.com/myapp");
1081        assert_eq!(
1082            cmd.args,
1083            vec![
1084                "manifest",
1085                "push",
1086                "--all",
1087                "myapp:latest",
1088                "docker://registry.example.com/myapp"
1089            ]
1090        );
1091    }
1092
1093    #[test]
1094    fn test_manifest_rm() {
1095        let cmd = BuildahCommand::manifest_rm("myapp:latest");
1096        assert_eq!(cmd.args, vec!["manifest", "rm", "myapp:latest"]);
1097    }
1098}