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