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