docker_wrapper/
command.rs

1//! Docker command implementations.
2//!
3//! This module contains all Docker CLI command wrappers. Each command is implemented
4//! as a struct with a builder pattern API.
5//!
6//! # The `DockerCommand` Trait
7//!
8//! All commands implement [`DockerCommand`], which provides:
9//! - [`execute()`](DockerCommand::execute) - Run the command and get typed output
10//! - [`arg()`](DockerCommand::arg) / [`args()`](DockerCommand::args) - Add raw CLI arguments
11//! - [`with_timeout()`](DockerCommand::with_timeout) - Set execution timeout
12//!
13//! # Example
14//!
15//! ```rust,no_run
16//! use docker_wrapper::{DockerCommand, RunCommand};
17//!
18//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
19//! let container = RunCommand::new("nginx:alpine")
20//!     .name("web")
21//!     .port(8080, 80)
22//!     .detach()
23//!     .execute()
24//!     .await?;
25//!
26//! println!("Started container: {}", container.short());
27//! # Ok(())
28//! # }
29//! ```
30//!
31//! # Extensibility
32//!
33//! For options not yet implemented, use the escape hatch methods:
34//!
35//! ```rust,no_run
36//! use docker_wrapper::{DockerCommand, RunCommand};
37//!
38//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
39//! let mut cmd = RunCommand::new("nginx");
40//! cmd.arg("--some-new-flag")
41//!    .args(["--option", "value"]);
42//! cmd.execute().await?;
43//! # Ok(())
44//! # }
45//! ```
46
47use crate::error::{Error, Result};
48use crate::platform::PlatformInfo;
49use async_trait::async_trait;
50use std::collections::HashMap;
51use std::ffi::OsStr;
52use std::path::PathBuf;
53use std::process::Stdio;
54use std::time::Duration;
55use tokio::process::Command as TokioCommand;
56use tracing::{debug, error, instrument, trace, warn};
57
58// Re-export all command modules
59pub mod attach;
60pub mod bake;
61pub mod build;
62pub mod builder;
63pub mod commit;
64#[cfg(feature = "compose")]
65pub mod compose;
66pub mod container_prune;
67pub mod context;
68pub mod cp;
69pub mod create;
70pub mod diff;
71pub mod events;
72pub mod exec;
73pub mod export;
74pub mod generic;
75pub mod history;
76pub mod image_prune;
77pub mod images;
78pub mod import;
79pub mod info;
80pub mod init;
81pub mod inspect;
82pub mod kill;
83pub mod load;
84pub mod login;
85pub mod logout;
86pub mod logs;
87#[cfg(feature = "manifest")]
88pub mod manifest;
89pub mod network;
90pub mod pause;
91pub mod port;
92pub mod ps;
93pub mod pull;
94pub mod push;
95pub mod rename;
96pub mod restart;
97pub mod rm;
98pub mod rmi;
99pub mod run;
100pub mod save;
101pub mod search;
102pub mod start;
103pub mod stats;
104pub mod stop;
105#[cfg(feature = "swarm")]
106pub mod swarm;
107pub mod system;
108pub mod tag;
109pub mod top;
110pub mod unpause;
111pub mod update;
112pub mod version;
113pub mod volume;
114pub mod wait;
115
116/// Unified trait for all Docker commands (both regular and compose)
117#[async_trait]
118pub trait DockerCommand {
119    /// The output type this command produces
120    type Output;
121
122    /// Get the command executor for extensibility
123    fn get_executor(&self) -> &CommandExecutor;
124
125    /// Get mutable command executor for extensibility
126    fn get_executor_mut(&mut self) -> &mut CommandExecutor;
127
128    /// Build the complete command arguments including subcommands
129    fn build_command_args(&self) -> Vec<String>;
130
131    /// Execute the command and return the typed output
132    async fn execute(&self) -> Result<Self::Output>;
133
134    /// Helper method to execute the command with proper error handling
135    async fn execute_command(&self, command_args: Vec<String>) -> Result<CommandOutput> {
136        let executor = self.get_executor();
137
138        // For compose commands, we need to handle "docker compose <subcommand>"
139        // For regular commands, we handle "docker <command>"
140        if command_args.first() == Some(&"compose".to_string()) {
141            // This is a compose command - args are already formatted correctly
142            executor.execute_command("docker", command_args).await
143        } else {
144            // Regular docker command - first arg is the command name
145            let command_name = command_args
146                .first()
147                .unwrap_or(&"docker".to_string())
148                .clone();
149            let remaining_args = command_args.iter().skip(1).cloned().collect();
150            executor
151                .execute_command(&command_name, remaining_args)
152                .await
153        }
154    }
155
156    /// Add a raw argument to the command (escape hatch)
157    fn arg<S: AsRef<OsStr>>(&mut self, arg: S) -> &mut Self {
158        self.get_executor_mut().add_arg(arg);
159        self
160    }
161
162    /// Add multiple raw arguments to the command (escape hatch)
163    fn args<I, S>(&mut self, args: I) -> &mut Self
164    where
165        I: IntoIterator<Item = S>,
166        S: AsRef<OsStr>,
167    {
168        self.get_executor_mut().add_args(args);
169        self
170    }
171
172    /// Add a flag option (e.g., --detach, --rm)
173    fn flag(&mut self, flag: &str) -> &mut Self {
174        self.get_executor_mut().add_flag(flag);
175        self
176    }
177
178    /// Add a key-value option (e.g., --name value, --env key=value)
179    fn option(&mut self, key: &str, value: &str) -> &mut Self {
180        self.get_executor_mut().add_option(key, value);
181        self
182    }
183
184    /// Set a timeout for command execution
185    ///
186    /// If the command takes longer than the specified duration, it will be
187    /// terminated and an `Error::Timeout` will be returned.
188    fn with_timeout(&mut self, timeout: std::time::Duration) -> &mut Self {
189        self.get_executor_mut().timeout = Some(timeout);
190        self
191    }
192
193    /// Set a timeout in seconds for command execution
194    fn with_timeout_secs(&mut self, seconds: u64) -> &mut Self {
195        self.get_executor_mut().timeout = Some(std::time::Duration::from_secs(seconds));
196        self
197    }
198}
199
200/// Base configuration for all compose commands
201#[derive(Debug, Clone, Default)]
202pub struct ComposeConfig {
203    /// Compose file paths (-f, --file)
204    pub files: Vec<PathBuf>,
205    /// Project name (-p, --project-name)
206    pub project_name: Option<String>,
207    /// Project directory (--project-directory)
208    pub project_directory: Option<PathBuf>,
209    /// Profiles to enable (--profile)
210    pub profiles: Vec<String>,
211    /// Environment file (--env-file)
212    pub env_file: Option<PathBuf>,
213    /// Run in compatibility mode
214    pub compatibility: bool,
215    /// Execute in dry run mode
216    pub dry_run: bool,
217    /// Progress output type
218    pub progress: Option<ProgressType>,
219    /// ANSI control characters
220    pub ansi: Option<AnsiMode>,
221    /// Max parallelism (-1 for unlimited)
222    pub parallel: Option<i32>,
223}
224
225/// Progress output type for compose commands
226#[derive(Debug, Clone, Copy)]
227pub enum ProgressType {
228    /// Auto-detect
229    Auto,
230    /// TTY output
231    Tty,
232    /// Plain text output
233    Plain,
234    /// JSON output
235    Json,
236    /// Quiet mode
237    Quiet,
238}
239
240impl std::fmt::Display for ProgressType {
241    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242        match self {
243            Self::Auto => write!(f, "auto"),
244            Self::Tty => write!(f, "tty"),
245            Self::Plain => write!(f, "plain"),
246            Self::Json => write!(f, "json"),
247            Self::Quiet => write!(f, "quiet"),
248        }
249    }
250}
251
252/// ANSI control character mode
253#[derive(Debug, Clone, Copy)]
254pub enum AnsiMode {
255    /// Never print ANSI
256    Never,
257    /// Always print ANSI
258    Always,
259    /// Auto-detect
260    Auto,
261}
262
263impl std::fmt::Display for AnsiMode {
264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        match self {
266            Self::Never => write!(f, "never"),
267            Self::Always => write!(f, "always"),
268            Self::Auto => write!(f, "auto"),
269        }
270    }
271}
272
273impl ComposeConfig {
274    /// Create a new compose configuration
275    #[must_use]
276    pub fn new() -> Self {
277        Self::default()
278    }
279
280    /// Add a compose file
281    #[must_use]
282    pub fn file(mut self, path: impl Into<PathBuf>) -> Self {
283        self.files.push(path.into());
284        self
285    }
286
287    /// Set project name
288    #[must_use]
289    pub fn project_name(mut self, name: impl Into<String>) -> Self {
290        self.project_name = Some(name.into());
291        self
292    }
293
294    /// Set project directory
295    #[must_use]
296    pub fn project_directory(mut self, dir: impl Into<PathBuf>) -> Self {
297        self.project_directory = Some(dir.into());
298        self
299    }
300
301    /// Add a profile
302    #[must_use]
303    pub fn profile(mut self, profile: impl Into<String>) -> Self {
304        self.profiles.push(profile.into());
305        self
306    }
307
308    /// Set environment file
309    #[must_use]
310    pub fn env_file(mut self, path: impl Into<PathBuf>) -> Self {
311        self.env_file = Some(path.into());
312        self
313    }
314
315    /// Enable compatibility mode
316    #[must_use]
317    pub fn compatibility(mut self) -> Self {
318        self.compatibility = true;
319        self
320    }
321
322    /// Enable dry run mode
323    #[must_use]
324    pub fn dry_run(mut self) -> Self {
325        self.dry_run = true;
326        self
327    }
328
329    /// Set progress output type
330    #[must_use]
331    pub fn progress(mut self, progress: ProgressType) -> Self {
332        self.progress = Some(progress);
333        self
334    }
335
336    /// Set ANSI mode
337    #[must_use]
338    pub fn ansi(mut self, ansi: AnsiMode) -> Self {
339        self.ansi = Some(ansi);
340        self
341    }
342
343    /// Set max parallelism
344    #[must_use]
345    pub fn parallel(mut self, parallel: i32) -> Self {
346        self.parallel = Some(parallel);
347        self
348    }
349
350    /// Build global compose arguments
351    #[must_use]
352    pub fn build_global_args(&self) -> Vec<String> {
353        let mut args = Vec::new();
354
355        // Add compose files
356        for file in &self.files {
357            args.push("--file".to_string());
358            args.push(file.to_string_lossy().to_string());
359        }
360
361        // Add project name
362        if let Some(ref name) = self.project_name {
363            args.push("--project-name".to_string());
364            args.push(name.clone());
365        }
366
367        // Add project directory
368        if let Some(ref dir) = self.project_directory {
369            args.push("--project-directory".to_string());
370            args.push(dir.to_string_lossy().to_string());
371        }
372
373        // Add profiles
374        for profile in &self.profiles {
375            args.push("--profile".to_string());
376            args.push(profile.clone());
377        }
378
379        // Add environment file
380        if let Some(ref env_file) = self.env_file {
381            args.push("--env-file".to_string());
382            args.push(env_file.to_string_lossy().to_string());
383        }
384
385        // Add flags
386        if self.compatibility {
387            args.push("--compatibility".to_string());
388        }
389
390        if self.dry_run {
391            args.push("--dry-run".to_string());
392        }
393
394        // Add progress type
395        if let Some(progress) = self.progress {
396            args.push("--progress".to_string());
397            args.push(progress.to_string());
398        }
399
400        // Add ANSI mode
401        if let Some(ansi) = self.ansi {
402            args.push("--ansi".to_string());
403            args.push(ansi.to_string());
404        }
405
406        // Add parallel limit
407        if let Some(parallel) = self.parallel {
408            args.push("--parallel".to_string());
409            args.push(parallel.to_string());
410        }
411
412        args
413    }
414}
415
416/// Extended trait for Docker Compose commands
417pub trait ComposeCommand: DockerCommand {
418    /// Get the compose configuration
419    fn get_config(&self) -> &ComposeConfig;
420
421    /// Get mutable compose configuration for builder pattern
422    fn get_config_mut(&mut self) -> &mut ComposeConfig;
423
424    /// Get the compose subcommand name (e.g., "up", "down", "ps")
425    fn subcommand(&self) -> &'static str;
426
427    /// Build command-specific arguments (without global compose args)
428    fn build_subcommand_args(&self) -> Vec<String>;
429
430    /// Build complete command arguments including "compose" and global args\
431    /// (This provides the implementation for `DockerCommandV2::build_command_args`)
432    fn build_command_args(&self) -> Vec<String> {
433        let mut args = vec!["compose".to_string()];
434
435        // Add global compose arguments
436        args.extend(self.get_config().build_global_args());
437
438        // Add the subcommand
439        args.push(self.subcommand().to_string());
440
441        // Add command-specific arguments
442        args.extend(self.build_subcommand_args());
443
444        // Add raw arguments from executor
445        args.extend(self.get_executor().raw_args.clone());
446
447        args
448    }
449
450    /// Helper builder methods for common compose config options
451    #[must_use]
452    fn file<P: Into<PathBuf>>(mut self, file: P) -> Self
453    where
454        Self: Sized,
455    {
456        self.get_config_mut().files.push(file.into());
457        self
458    }
459
460    /// Set project name for compose command
461    #[must_use]
462    fn project_name(mut self, name: impl Into<String>) -> Self
463    where
464        Self: Sized,
465    {
466        self.get_config_mut().project_name = Some(name.into());
467        self
468    }
469}
470
471/// Default timeout for command execution (30 seconds)
472pub const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
473
474/// Common functionality for executing Docker commands
475#[derive(Debug, Clone)]
476pub struct CommandExecutor {
477    /// Additional raw arguments added via escape hatch
478    pub raw_args: Vec<String>,
479    /// Platform information for runtime abstraction
480    pub platform_info: Option<PlatformInfo>,
481    /// Optional timeout for command execution
482    pub timeout: Option<Duration>,
483}
484
485impl CommandExecutor {
486    /// Create a new command executor
487    #[must_use]
488    pub fn new() -> Self {
489        Self {
490            raw_args: Vec::new(),
491            platform_info: None,
492            timeout: None,
493        }
494    }
495
496    /// Create a new command executor with platform detection
497    ///
498    /// # Errors
499    ///
500    /// Returns an error if platform detection fails
501    pub fn with_platform() -> Result<Self> {
502        let platform_info = PlatformInfo::detect()?;
503        Ok(Self {
504            raw_args: Vec::new(),
505            platform_info: Some(platform_info),
506            timeout: None,
507        })
508    }
509
510    /// Set the platform information
511    #[must_use]
512    pub fn platform(mut self, platform_info: PlatformInfo) -> Self {
513        self.platform_info = Some(platform_info);
514        self
515    }
516
517    /// Set a timeout for command execution
518    ///
519    /// If the command takes longer than the specified duration, it will be
520    /// terminated and an `Error::Timeout` will be returned.
521    #[must_use]
522    pub fn timeout(mut self, timeout: Duration) -> Self {
523        self.timeout = Some(timeout);
524        self
525    }
526
527    /// Set a timeout in seconds for command execution
528    #[must_use]
529    pub fn timeout_secs(mut self, seconds: u64) -> Self {
530        self.timeout = Some(Duration::from_secs(seconds));
531        self
532    }
533
534    /// Get the runtime command to use
535    fn get_runtime_command(&self) -> String {
536        if let Some(ref platform_info) = self.platform_info {
537            platform_info.runtime.command().to_string()
538        } else {
539            "docker".to_string()
540        }
541    }
542
543    /// Execute a Docker command with the given arguments
544    ///
545    /// # Errors
546    /// Returns an error if the Docker command fails to execute, returns a non-zero exit code,
547    /// or times out (if a timeout is configured)
548    #[instrument(
549        name = "docker.command",
550        skip(self, args),
551        fields(
552            command = %command_name,
553            runtime = %self.get_runtime_command(),
554            timeout_secs = self.timeout.map(|t| t.as_secs()),
555        )
556    )]
557    pub async fn execute_command(
558        &self,
559        command_name: &str,
560        args: Vec<String>,
561    ) -> Result<CommandOutput> {
562        // Prepend raw args (they should come before command-specific args)
563        let mut all_args = self.raw_args.clone();
564        all_args.extend(args);
565
566        // Insert the command name at the beginning
567        all_args.insert(0, command_name.to_string());
568
569        let runtime_command = self.get_runtime_command();
570
571        trace!(args = ?all_args, "executing docker command");
572
573        // Execute with or without timeout
574        let result = if let Some(timeout_duration) = self.timeout {
575            self.execute_with_timeout(&runtime_command, &all_args, timeout_duration)
576                .await
577        } else {
578            self.execute_internal(&runtime_command, &all_args).await
579        };
580
581        match &result {
582            Ok(output) => {
583                debug!(
584                    exit_code = output.exit_code,
585                    stdout_len = output.stdout.len(),
586                    stderr_len = output.stderr.len(),
587                    "command completed successfully"
588                );
589                trace!(stdout = %output.stdout, "command stdout");
590                if !output.stderr.is_empty() {
591                    trace!(stderr = %output.stderr, "command stderr");
592                }
593            }
594            Err(e) => {
595                error!(error = %e, "command failed");
596            }
597        }
598
599        result
600    }
601
602    /// Internal method to execute a command without timeout
603    #[instrument(
604        name = "docker.process",
605        skip(self, all_args),
606        fields(
607            full_command = %format!("{} {}", runtime_command, all_args.join(" ")),
608        )
609    )]
610    async fn execute_internal(
611        &self,
612        runtime_command: &str,
613        all_args: &[String],
614    ) -> Result<CommandOutput> {
615        let mut command = TokioCommand::new(runtime_command);
616
617        // Set environment variables from platform info
618        if let Some(ref platform_info) = self.platform_info {
619            let env_count = platform_info.environment_vars().len();
620            if env_count > 0 {
621                trace!(
622                    env_vars = env_count,
623                    "setting platform environment variables"
624                );
625            }
626            for (key, value) in platform_info.environment_vars() {
627                command.env(key, value);
628            }
629        }
630
631        trace!("spawning process");
632
633        let output = command
634            .args(all_args)
635            .stdout(Stdio::piped())
636            .stderr(Stdio::piped())
637            .output()
638            .await
639            .map_err(|e| {
640                error!(error = %e, "failed to spawn process");
641                Error::custom(format!(
642                    "Failed to execute {runtime_command} {}: {e}",
643                    all_args.first().unwrap_or(&String::new())
644                ))
645            })?;
646
647        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
648        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
649        let success = output.status.success();
650        let exit_code = output.status.code().unwrap_or(-1);
651
652        trace!(
653            exit_code = exit_code,
654            success = success,
655            stdout_bytes = output.stdout.len(),
656            stderr_bytes = output.stderr.len(),
657            "process completed"
658        );
659
660        if !success {
661            return Err(Error::command_failed(
662                format!("{} {}", runtime_command, all_args.join(" ")),
663                exit_code,
664                stdout,
665                stderr,
666            ));
667        }
668
669        Ok(CommandOutput {
670            stdout,
671            stderr,
672            exit_code,
673            success,
674        })
675    }
676
677    /// Execute a command with a timeout
678    #[instrument(
679        name = "docker.timeout",
680        skip(self, all_args),
681        fields(timeout_secs = timeout_duration.as_secs())
682    )]
683    async fn execute_with_timeout(
684        &self,
685        runtime_command: &str,
686        all_args: &[String],
687        timeout_duration: Duration,
688    ) -> Result<CommandOutput> {
689        use tokio::time::timeout;
690
691        debug!("executing with timeout");
692
693        if let Ok(result) = timeout(
694            timeout_duration,
695            self.execute_internal(runtime_command, all_args),
696        )
697        .await
698        {
699            result
700        } else {
701            warn!(
702                timeout_secs = timeout_duration.as_secs(),
703                "command timed out"
704            );
705            Err(Error::timeout(timeout_duration.as_secs()))
706        }
707    }
708
709    /// Add a raw argument
710    pub fn add_arg<S: AsRef<OsStr>>(&mut self, arg: S) {
711        self.raw_args
712            .push(arg.as_ref().to_string_lossy().to_string());
713    }
714
715    /// Add multiple raw arguments
716    pub fn add_args<I, S>(&mut self, args: I)
717    where
718        I: IntoIterator<Item = S>,
719        S: AsRef<OsStr>,
720    {
721        for arg in args {
722            self.add_arg(arg);
723        }
724    }
725
726    /// Add a flag option
727    pub fn add_flag(&mut self, flag: &str) {
728        let flag_arg = if flag.starts_with('-') {
729            flag.to_string()
730        } else if flag.len() == 1 {
731            format!("-{flag}")
732        } else {
733            format!("--{flag}")
734        };
735        self.raw_args.push(flag_arg);
736    }
737
738    /// Add a key-value option
739    pub fn add_option(&mut self, key: &str, value: &str) {
740        let key_arg = if key.starts_with('-') {
741            key.to_string()
742        } else if key.len() == 1 {
743            format!("-{key}")
744        } else {
745            format!("--{key}")
746        };
747        self.raw_args.push(key_arg);
748        self.raw_args.push(value.to_string());
749    }
750}
751
752impl Default for CommandExecutor {
753    fn default() -> Self {
754        Self::new()
755    }
756}
757
758/// Output from executing a Docker command
759#[derive(Debug, Clone)]
760pub struct CommandOutput {
761    /// Standard output from the command
762    pub stdout: String,
763    /// Standard error from the command
764    pub stderr: String,
765    /// Exit code
766    pub exit_code: i32,
767    /// Whether the command was successful
768    pub success: bool,
769}
770
771impl CommandOutput {
772    /// Get stdout lines as a vector
773    #[must_use]
774    pub fn stdout_lines(&self) -> Vec<&str> {
775        self.stdout.lines().collect()
776    }
777
778    /// Get stderr lines as a vector
779    #[must_use]
780    pub fn stderr_lines(&self) -> Vec<&str> {
781        self.stderr.lines().collect()
782    }
783
784    /// Check if stdout is empty
785    #[must_use]
786    pub fn stdout_is_empty(&self) -> bool {
787        self.stdout.trim().is_empty()
788    }
789
790    /// Check if stderr is empty
791    #[must_use]
792    pub fn stderr_is_empty(&self) -> bool {
793        self.stderr.trim().is_empty()
794    }
795}
796
797/// Helper for building environment variables
798#[derive(Debug, Clone, Default)]
799pub struct EnvironmentBuilder {
800    vars: HashMap<String, String>,
801}
802
803impl EnvironmentBuilder {
804    /// Create a new environment builder
805    #[must_use]
806    pub fn new() -> Self {
807        Self::default()
808    }
809
810    /// Add an environment variable
811    #[must_use]
812    pub fn var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
813        self.vars.insert(key.into(), value.into());
814        self
815    }
816
817    /// Add multiple environment variables from a `HashMap`
818    #[must_use]
819    pub fn vars(mut self, vars: HashMap<String, String>) -> Self {
820        self.vars.extend(vars);
821        self
822    }
823
824    /// Build the environment arguments for Docker
825    #[must_use]
826    pub fn build_args(&self) -> Vec<String> {
827        let mut args = Vec::new();
828        for (key, value) in &self.vars {
829            args.push("--env".to_string());
830            args.push(format!("{key}={value}"));
831        }
832        args
833    }
834
835    /// Get the environment variables as a `HashMap`
836    #[must_use]
837    pub fn as_map(&self) -> &HashMap<String, String> {
838        &self.vars
839    }
840}
841
842/// Helper for building port mappings
843#[derive(Debug, Clone, Default)]
844pub struct PortBuilder {
845    mappings: Vec<PortMapping>,
846}
847
848impl PortBuilder {
849    /// Create a new port builder
850    #[must_use]
851    pub fn new() -> Self {
852        Self::default()
853    }
854
855    /// Add a port mapping
856    #[must_use]
857    pub fn port(mut self, host_port: u16, container_port: u16) -> Self {
858        self.mappings.push(PortMapping {
859            host_port: Some(host_port),
860            container_port,
861            protocol: Protocol::Tcp,
862            host_ip: None,
863        });
864        self
865    }
866
867    /// Add a port mapping with protocol
868    #[must_use]
869    pub fn port_with_protocol(
870        mut self,
871        host_port: u16,
872        container_port: u16,
873        protocol: Protocol,
874    ) -> Self {
875        self.mappings.push(PortMapping {
876            host_port: Some(host_port),
877            container_port,
878            protocol,
879            host_ip: None,
880        });
881        self
882    }
883
884    /// Add a dynamic port mapping (Docker assigns host port)
885    #[must_use]
886    pub fn dynamic_port(mut self, container_port: u16) -> Self {
887        self.mappings.push(PortMapping {
888            host_port: None,
889            container_port,
890            protocol: Protocol::Tcp,
891            host_ip: None,
892        });
893        self
894    }
895
896    /// Build the port arguments for Docker
897    #[must_use]
898    pub fn build_args(&self) -> Vec<String> {
899        let mut args = Vec::new();
900        for mapping in &self.mappings {
901            args.push("--publish".to_string());
902            args.push(mapping.to_string());
903        }
904        args
905    }
906
907    /// Get the port mappings
908    #[must_use]
909    pub fn mappings(&self) -> &[PortMapping] {
910        &self.mappings
911    }
912}
913
914/// Port mapping configuration
915#[derive(Debug, Clone)]
916pub struct PortMapping {
917    /// Host port (None for dynamic allocation)
918    pub host_port: Option<u16>,
919    /// Container port
920    pub container_port: u16,
921    /// Protocol (TCP or UDP)
922    pub protocol: Protocol,
923    /// Host IP to bind to (None for all interfaces)
924    pub host_ip: Option<std::net::IpAddr>,
925}
926
927impl std::fmt::Display for PortMapping {
928    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
929        let protocol_suffix = match self.protocol {
930            Protocol::Tcp => "",
931            Protocol::Udp => "/udp",
932        };
933
934        if let Some(host_port) = self.host_port {
935            if let Some(host_ip) = self.host_ip {
936                write!(
937                    f,
938                    "{}:{}:{}{}",
939                    host_ip, host_port, self.container_port, protocol_suffix
940                )
941            } else {
942                write!(
943                    f,
944                    "{}:{}{}",
945                    host_port, self.container_port, protocol_suffix
946                )
947            }
948        } else {
949            write!(f, "{}{}", self.container_port, protocol_suffix)
950        }
951    }
952}
953
954/// Network protocol for port mappings
955#[derive(Debug, Clone, Copy, PartialEq, Eq)]
956pub enum Protocol {
957    /// TCP protocol
958    Tcp,
959    /// UDP protocol
960    Udp,
961}
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966
967    #[test]
968    fn test_command_executor_args() {
969        let mut executor = CommandExecutor::new();
970        executor.add_arg("test");
971        executor.add_args(vec!["arg1", "arg2"]);
972        executor.add_flag("detach");
973        executor.add_flag("d");
974        executor.add_option("name", "test-container");
975
976        assert_eq!(
977            executor.raw_args,
978            vec![
979                "test",
980                "arg1",
981                "arg2",
982                "--detach",
983                "-d",
984                "--name",
985                "test-container"
986            ]
987        );
988    }
989
990    #[test]
991    fn test_command_executor_timeout() {
992        let executor = CommandExecutor::new();
993        assert!(executor.timeout.is_none());
994
995        let executor_with_timeout = CommandExecutor::new().timeout(Duration::from_secs(10));
996        assert_eq!(executor_with_timeout.timeout, Some(Duration::from_secs(10)));
997
998        let executor_with_secs = CommandExecutor::new().timeout_secs(30);
999        assert_eq!(executor_with_secs.timeout, Some(Duration::from_secs(30)));
1000    }
1001
1002    #[test]
1003    fn test_environment_builder() {
1004        let env = EnvironmentBuilder::new()
1005            .var("KEY1", "value1")
1006            .var("KEY2", "value2");
1007
1008        let args = env.build_args();
1009        assert!(args.contains(&"--env".to_string()));
1010        assert!(args.contains(&"KEY1=value1".to_string()));
1011        assert!(args.contains(&"KEY2=value2".to_string()));
1012    }
1013
1014    #[test]
1015    fn test_port_builder() {
1016        let ports = PortBuilder::new()
1017            .port(8080, 80)
1018            .dynamic_port(443)
1019            .port_with_protocol(8081, 81, Protocol::Udp);
1020
1021        let args = ports.build_args();
1022        assert!(args.contains(&"--publish".to_string()));
1023        assert!(args.contains(&"8080:80".to_string()));
1024        assert!(args.contains(&"443".to_string()));
1025        assert!(args.contains(&"8081:81/udp".to_string()));
1026    }
1027
1028    #[test]
1029    fn test_port_mapping_display() {
1030        let tcp_mapping = PortMapping {
1031            host_port: Some(8080),
1032            container_port: 80,
1033            protocol: Protocol::Tcp,
1034            host_ip: None,
1035        };
1036        assert_eq!(tcp_mapping.to_string(), "8080:80");
1037
1038        let udp_mapping = PortMapping {
1039            host_port: Some(8081),
1040            container_port: 81,
1041            protocol: Protocol::Udp,
1042            host_ip: None,
1043        };
1044        assert_eq!(udp_mapping.to_string(), "8081:81/udp");
1045
1046        let dynamic_mapping = PortMapping {
1047            host_port: None,
1048            container_port: 443,
1049            protocol: Protocol::Tcp,
1050            host_ip: None,
1051        };
1052        assert_eq!(dynamic_mapping.to_string(), "443");
1053    }
1054
1055    #[test]
1056    fn test_command_output_helpers() {
1057        let output = CommandOutput {
1058            stdout: "line1\nline2".to_string(),
1059            stderr: "error1\nerror2".to_string(),
1060            exit_code: 0,
1061            success: true,
1062        };
1063
1064        assert_eq!(output.stdout_lines(), vec!["line1", "line2"]);
1065        assert_eq!(output.stderr_lines(), vec!["error1", "error2"]);
1066        assert!(!output.stdout_is_empty());
1067        assert!(!output.stderr_is_empty());
1068
1069        let empty_output = CommandOutput {
1070            stdout: "   ".to_string(),
1071            stderr: String::new(),
1072            exit_code: 0,
1073            success: true,
1074        };
1075
1076        assert!(empty_output.stdout_is_empty());
1077        assert!(empty_output.stderr_is_empty());
1078    }
1079}