Skip to main content

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 crate::tracing_compat::{debug, error, info, trace, warn};
50use async_trait::async_trait;
51use std::collections::HashMap;
52use std::ffi::OsStr;
53use std::path::PathBuf;
54use std::process::Stdio;
55use std::time::Duration;
56use tokio::process::Command as TokioCommand;
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 files (--env-file), can be specified multiple times
214    pub env_files: Vec<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    /// Add an environment file (can be called multiple times)
311    #[must_use]
312    pub fn env_file(mut self, path: impl Into<PathBuf>) -> Self {
313        self.env_files.push(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 files
382        for env_file in &self.env_files {
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/// Maximum length (in bytes) of stderr snippets attached to tracing events.
477/// Bounded so that log sinks don't drown in large error payloads.
478const STDERR_LOG_SNIPPET_BYTES: usize = 512;
479
480/// Truncate `s` to at most `STDERR_LOG_SNIPPET_BYTES`, respecting UTF-8 char
481/// boundaries and appending an ellipsis marker when truncation occurs.
482#[cfg_attr(not(feature = "tracing"), allow(dead_code))]
483fn truncate_for_log(s: &str) -> String {
484    if s.len() <= STDERR_LOG_SNIPPET_BYTES {
485        return s.to_string();
486    }
487    // Walk back to a char boundary <= limit.
488    let mut end = STDERR_LOG_SNIPPET_BYTES;
489    while end > 0 && !s.is_char_boundary(end) {
490        end -= 1;
491    }
492    let mut out = String::with_capacity(end + 4);
493    out.push_str(&s[..end]);
494    out.push_str("...");
495    out
496}
497
498/// Common functionality for executing Docker commands
499#[derive(Debug, Clone)]
500pub struct CommandExecutor {
501    /// Additional raw arguments added via escape hatch
502    pub raw_args: Vec<String>,
503    /// Platform information for runtime abstraction
504    pub platform_info: Option<PlatformInfo>,
505    /// Optional timeout for command execution
506    pub timeout: Option<Duration>,
507}
508
509impl CommandExecutor {
510    /// Create a new command executor
511    #[must_use]
512    pub fn new() -> Self {
513        Self {
514            raw_args: Vec::new(),
515            platform_info: None,
516            timeout: None,
517        }
518    }
519
520    /// Create a new command executor with platform detection
521    ///
522    /// # Errors
523    ///
524    /// Returns an error if platform detection fails
525    pub fn with_platform() -> Result<Self> {
526        let platform_info = PlatformInfo::detect()?;
527        Ok(Self {
528            raw_args: Vec::new(),
529            platform_info: Some(platform_info),
530            timeout: None,
531        })
532    }
533
534    /// Set the platform information
535    #[must_use]
536    pub fn platform(mut self, platform_info: PlatformInfo) -> Self {
537        self.platform_info = Some(platform_info);
538        self
539    }
540
541    /// Set a timeout for command execution
542    ///
543    /// If the command takes longer than the specified duration, it will be
544    /// terminated and an `Error::Timeout` will be returned.
545    #[must_use]
546    pub fn timeout(mut self, timeout: Duration) -> Self {
547        self.timeout = Some(timeout);
548        self
549    }
550
551    /// Set a timeout in seconds for command execution
552    #[must_use]
553    pub fn timeout_secs(mut self, seconds: u64) -> Self {
554        self.timeout = Some(Duration::from_secs(seconds));
555        self
556    }
557
558    /// Get the runtime command to use
559    fn get_runtime_command(&self) -> String {
560        if let Some(ref platform_info) = self.platform_info {
561            platform_info.runtime.command().to_string()
562        } else {
563            "docker".to_string()
564        }
565    }
566
567    /// Get the runtime label suitable for a tracing field (e.g. "docker",
568    /// "podman"). Returns `None` when no platform has been detected.
569    #[cfg_attr(not(feature = "tracing"), allow(dead_code))]
570    fn tracing_platform(&self) -> Option<&'static str> {
571        use crate::platform::Runtime;
572        let runtime = self.platform_info.as_ref().map(|p| &p.runtime)?;
573        Some(match runtime {
574            Runtime::Docker | Runtime::DockerDesktop => "docker",
575            Runtime::Podman => "podman",
576            Runtime::Colima => "colima",
577            Runtime::RancherDesktop => "rancher-desktop",
578            Runtime::OrbStack => "orbstack",
579        })
580    }
581
582    /// Execute a Docker command with the given arguments
583    ///
584    /// # Errors
585    /// Returns an error if the Docker command fails to execute, returns a non-zero exit code,
586    /// or times out (if a timeout is configured)
587    #[cfg_attr(
588        feature = "tracing",
589        tracing::instrument(
590            name = "docker.command",
591            skip(self, args),
592            fields(
593                command = %command_name,
594                args_count = args.len(),
595                platform = self.tracing_platform(),
596                runtime = %self.get_runtime_command(),
597                timeout_secs = self.timeout.map(|t| t.as_secs()),
598            )
599        )
600    )]
601    #[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
602    pub async fn execute_command(
603        &self,
604        command_name: &str,
605        args: Vec<String>,
606    ) -> Result<CommandOutput> {
607        // Prepend raw args (they should come before command-specific args)
608        let mut all_args = self.raw_args.clone();
609        all_args.extend(args);
610
611        // Insert the command name at the beginning
612        all_args.insert(0, command_name.to_string());
613
614        let runtime_command = self.get_runtime_command();
615
616        trace!(args = ?all_args, "executing docker command");
617
618        let started_at = std::time::Instant::now();
619
620        // Execute with or without timeout
621        let result = if let Some(timeout_duration) = self.timeout {
622            self.execute_with_timeout(&runtime_command, &all_args, timeout_duration)
623                .await
624        } else {
625            self.execute_internal(&runtime_command, &all_args).await
626        };
627
628        let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
629
630        match &result {
631            Ok(output) => {
632                info!(
633                    exit_code = output.exit_code,
634                    duration_ms = duration_ms,
635                    stdout_len = output.stdout.len(),
636                    stderr_len = output.stderr.len(),
637                    "command completed"
638                );
639                trace!(stdout = %output.stdout, "command stdout");
640                if !output.stderr.is_empty() {
641                    trace!(stderr = %output.stderr, "command stderr");
642                }
643            }
644            Err(e) => {
645                let (exit_code, stderr_snippet) = match e {
646                    Error::CommandFailed {
647                        exit_code, stderr, ..
648                    } => (Some(*exit_code), Some(truncate_for_log(stderr))),
649                    _ => (None, None),
650                };
651                warn!(
652                    command = %command_name,
653                    exit_code = exit_code,
654                    duration_ms = duration_ms,
655                    stderr_snippet = stderr_snippet.as_deref(),
656                    error = %e,
657                    "command failed"
658                );
659            }
660        }
661
662        result
663    }
664
665    /// Internal method to execute a command without timeout
666    #[cfg_attr(
667        feature = "tracing",
668        tracing::instrument(
669            name = "docker.process",
670            skip(self, all_args),
671            fields(
672                full_command = %format!("{} {}", runtime_command, all_args.join(" ")),
673            )
674        )
675    )]
676    async fn execute_internal(
677        &self,
678        runtime_command: &str,
679        all_args: &[String],
680    ) -> Result<CommandOutput> {
681        let mut command = TokioCommand::new(runtime_command);
682
683        // Set environment variables from platform info
684        if let Some(ref platform_info) = self.platform_info {
685            let env_count = platform_info.environment_vars().len();
686            if env_count > 0 {
687                trace!(
688                    env_vars = env_count,
689                    "setting platform environment variables"
690                );
691            }
692            for (key, value) in platform_info.environment_vars() {
693                command.env(key, value);
694            }
695        }
696
697        trace!("spawning process");
698
699        let output = command
700            .args(all_args)
701            .stdout(Stdio::piped())
702            .stderr(Stdio::piped())
703            .output()
704            .await
705            .map_err(|e| {
706                error!(error = %e, "failed to spawn process");
707                Error::custom(format!(
708                    "Failed to execute {runtime_command} {}: {e}",
709                    all_args.first().unwrap_or(&String::new())
710                ))
711            })?;
712
713        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
714        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
715        let success = output.status.success();
716        let exit_code = output.status.code().unwrap_or(-1);
717
718        trace!(
719            exit_code = exit_code,
720            success = success,
721            stdout_bytes = output.stdout.len(),
722            stderr_bytes = output.stderr.len(),
723            "process completed"
724        );
725
726        if !success {
727            return Err(Error::command_failed(
728                format!("{} {}", runtime_command, all_args.join(" ")),
729                exit_code,
730                stdout,
731                stderr,
732            ));
733        }
734
735        Ok(CommandOutput {
736            stdout,
737            stderr,
738            exit_code,
739            success,
740        })
741    }
742
743    /// Execute a command with a timeout
744    #[cfg_attr(
745        feature = "tracing",
746        tracing::instrument(
747            name = "docker.timeout",
748            skip(self, all_args),
749            fields(timeout_secs = timeout_duration.as_secs())
750        )
751    )]
752    async fn execute_with_timeout(
753        &self,
754        runtime_command: &str,
755        all_args: &[String],
756        timeout_duration: Duration,
757    ) -> Result<CommandOutput> {
758        use tokio::time::timeout;
759
760        debug!("executing with timeout");
761
762        if let Ok(result) = timeout(
763            timeout_duration,
764            self.execute_internal(runtime_command, all_args),
765        )
766        .await
767        {
768            result
769        } else {
770            warn!(
771                timeout_secs = timeout_duration.as_secs(),
772                "command timed out"
773            );
774            Err(Error::timeout(timeout_duration.as_secs()))
775        }
776    }
777
778    /// Add a raw argument
779    pub fn add_arg<S: AsRef<OsStr>>(&mut self, arg: S) {
780        self.raw_args
781            .push(arg.as_ref().to_string_lossy().to_string());
782    }
783
784    /// Add multiple raw arguments
785    pub fn add_args<I, S>(&mut self, args: I)
786    where
787        I: IntoIterator<Item = S>,
788        S: AsRef<OsStr>,
789    {
790        for arg in args {
791            self.add_arg(arg);
792        }
793    }
794
795    /// Add a flag option
796    pub fn add_flag(&mut self, flag: &str) {
797        let flag_arg = if flag.starts_with('-') {
798            flag.to_string()
799        } else if flag.len() == 1 {
800            format!("-{flag}")
801        } else {
802            format!("--{flag}")
803        };
804        self.raw_args.push(flag_arg);
805    }
806
807    /// Add a key-value option
808    pub fn add_option(&mut self, key: &str, value: &str) {
809        let key_arg = if key.starts_with('-') {
810            key.to_string()
811        } else if key.len() == 1 {
812            format!("-{key}")
813        } else {
814            format!("--{key}")
815        };
816        self.raw_args.push(key_arg);
817        self.raw_args.push(value.to_string());
818    }
819}
820
821impl Default for CommandExecutor {
822    fn default() -> Self {
823        Self::new()
824    }
825}
826
827/// Output from executing a Docker command
828#[derive(Debug, Clone)]
829pub struct CommandOutput {
830    /// Standard output from the command
831    pub stdout: String,
832    /// Standard error from the command
833    pub stderr: String,
834    /// Exit code
835    pub exit_code: i32,
836    /// Whether the command was successful
837    pub success: bool,
838}
839
840impl CommandOutput {
841    /// Get stdout lines as a vector
842    #[must_use]
843    pub fn stdout_lines(&self) -> Vec<&str> {
844        self.stdout.lines().collect()
845    }
846
847    /// Get stderr lines as a vector
848    #[must_use]
849    pub fn stderr_lines(&self) -> Vec<&str> {
850        self.stderr.lines().collect()
851    }
852
853    /// Check if stdout is empty
854    #[must_use]
855    pub fn stdout_is_empty(&self) -> bool {
856        self.stdout.trim().is_empty()
857    }
858
859    /// Check if stderr is empty
860    #[must_use]
861    pub fn stderr_is_empty(&self) -> bool {
862        self.stderr.trim().is_empty()
863    }
864}
865
866/// Helper for building environment variables
867#[derive(Debug, Clone, Default)]
868pub struct EnvironmentBuilder {
869    vars: HashMap<String, String>,
870}
871
872impl EnvironmentBuilder {
873    /// Create a new environment builder
874    #[must_use]
875    pub fn new() -> Self {
876        Self::default()
877    }
878
879    /// Add an environment variable
880    #[must_use]
881    pub fn var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
882        self.vars.insert(key.into(), value.into());
883        self
884    }
885
886    /// Add multiple environment variables from a `HashMap`
887    #[must_use]
888    pub fn vars(mut self, vars: HashMap<String, String>) -> Self {
889        self.vars.extend(vars);
890        self
891    }
892
893    /// Build the environment arguments for Docker
894    #[must_use]
895    pub fn build_args(&self) -> Vec<String> {
896        let mut args = Vec::new();
897        for (key, value) in &self.vars {
898            args.push("--env".to_string());
899            args.push(format!("{key}={value}"));
900        }
901        args
902    }
903
904    /// Get the environment variables as a `HashMap`
905    #[must_use]
906    pub fn as_map(&self) -> &HashMap<String, String> {
907        &self.vars
908    }
909}
910
911/// Helper for building port mappings
912#[derive(Debug, Clone, Default)]
913pub struct PortBuilder {
914    mappings: Vec<PortMapping>,
915}
916
917impl PortBuilder {
918    /// Create a new port builder
919    #[must_use]
920    pub fn new() -> Self {
921        Self::default()
922    }
923
924    /// Add a port mapping
925    #[must_use]
926    pub fn port(mut self, host_port: u16, container_port: u16) -> Self {
927        self.mappings.push(PortMapping {
928            host_port: Some(host_port),
929            container_port,
930            protocol: Protocol::Tcp,
931            host_ip: None,
932        });
933        self
934    }
935
936    /// Add a port mapping with protocol
937    #[must_use]
938    pub fn port_with_protocol(
939        mut self,
940        host_port: u16,
941        container_port: u16,
942        protocol: Protocol,
943    ) -> Self {
944        self.mappings.push(PortMapping {
945            host_port: Some(host_port),
946            container_port,
947            protocol,
948            host_ip: None,
949        });
950        self
951    }
952
953    /// Add a dynamic port mapping (Docker assigns host port)
954    #[must_use]
955    pub fn dynamic_port(mut self, container_port: u16) -> Self {
956        self.mappings.push(PortMapping {
957            host_port: None,
958            container_port,
959            protocol: Protocol::Tcp,
960            host_ip: None,
961        });
962        self
963    }
964
965    /// Build the port arguments for Docker
966    #[must_use]
967    pub fn build_args(&self) -> Vec<String> {
968        let mut args = Vec::new();
969        for mapping in &self.mappings {
970            args.push("--publish".to_string());
971            args.push(mapping.to_string());
972        }
973        args
974    }
975
976    /// Get the port mappings
977    #[must_use]
978    pub fn mappings(&self) -> &[PortMapping] {
979        &self.mappings
980    }
981}
982
983/// Port mapping configuration
984#[derive(Debug, Clone)]
985pub struct PortMapping {
986    /// Host port (None for dynamic allocation)
987    pub host_port: Option<u16>,
988    /// Container port
989    pub container_port: u16,
990    /// Protocol (TCP or UDP)
991    pub protocol: Protocol,
992    /// Host IP to bind to (None for all interfaces)
993    pub host_ip: Option<std::net::IpAddr>,
994}
995
996impl std::fmt::Display for PortMapping {
997    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
998        let protocol_suffix = match self.protocol {
999            Protocol::Tcp => "",
1000            Protocol::Udp => "/udp",
1001        };
1002
1003        if let Some(host_port) = self.host_port {
1004            if let Some(host_ip) = self.host_ip {
1005                write!(
1006                    f,
1007                    "{}:{}:{}{}",
1008                    host_ip, host_port, self.container_port, protocol_suffix
1009                )
1010            } else {
1011                write!(
1012                    f,
1013                    "{}:{}{}",
1014                    host_port, self.container_port, protocol_suffix
1015                )
1016            }
1017        } else {
1018            write!(f, "{}{}", self.container_port, protocol_suffix)
1019        }
1020    }
1021}
1022
1023/// Network protocol for port mappings
1024#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1025pub enum Protocol {
1026    /// TCP protocol
1027    Tcp,
1028    /// UDP protocol
1029    Udp,
1030}
1031
1032#[cfg(test)]
1033mod tests {
1034    use super::*;
1035
1036    #[test]
1037    fn test_command_executor_args() {
1038        let mut executor = CommandExecutor::new();
1039        executor.add_arg("test");
1040        executor.add_args(vec!["arg1", "arg2"]);
1041        executor.add_flag("detach");
1042        executor.add_flag("d");
1043        executor.add_option("name", "test-container");
1044
1045        assert_eq!(
1046            executor.raw_args,
1047            vec![
1048                "test",
1049                "arg1",
1050                "arg2",
1051                "--detach",
1052                "-d",
1053                "--name",
1054                "test-container"
1055            ]
1056        );
1057    }
1058
1059    #[test]
1060    fn test_command_executor_timeout() {
1061        let executor = CommandExecutor::new();
1062        assert!(executor.timeout.is_none());
1063
1064        let executor_with_timeout = CommandExecutor::new().timeout(Duration::from_secs(10));
1065        assert_eq!(executor_with_timeout.timeout, Some(Duration::from_secs(10)));
1066
1067        let executor_with_secs = CommandExecutor::new().timeout_secs(30);
1068        assert_eq!(executor_with_secs.timeout, Some(Duration::from_secs(30)));
1069    }
1070
1071    #[test]
1072    fn test_environment_builder() {
1073        let env = EnvironmentBuilder::new()
1074            .var("KEY1", "value1")
1075            .var("KEY2", "value2");
1076
1077        let args = env.build_args();
1078        assert!(args.contains(&"--env".to_string()));
1079        assert!(args.contains(&"KEY1=value1".to_string()));
1080        assert!(args.contains(&"KEY2=value2".to_string()));
1081    }
1082
1083    #[test]
1084    fn test_port_builder() {
1085        let ports = PortBuilder::new()
1086            .port(8080, 80)
1087            .dynamic_port(443)
1088            .port_with_protocol(8081, 81, Protocol::Udp);
1089
1090        let args = ports.build_args();
1091        assert!(args.contains(&"--publish".to_string()));
1092        assert!(args.contains(&"8080:80".to_string()));
1093        assert!(args.contains(&"443".to_string()));
1094        assert!(args.contains(&"8081:81/udp".to_string()));
1095    }
1096
1097    #[test]
1098    fn test_port_mapping_display() {
1099        let tcp_mapping = PortMapping {
1100            host_port: Some(8080),
1101            container_port: 80,
1102            protocol: Protocol::Tcp,
1103            host_ip: None,
1104        };
1105        assert_eq!(tcp_mapping.to_string(), "8080:80");
1106
1107        let udp_mapping = PortMapping {
1108            host_port: Some(8081),
1109            container_port: 81,
1110            protocol: Protocol::Udp,
1111            host_ip: None,
1112        };
1113        assert_eq!(udp_mapping.to_string(), "8081:81/udp");
1114
1115        let dynamic_mapping = PortMapping {
1116            host_port: None,
1117            container_port: 443,
1118            protocol: Protocol::Tcp,
1119            host_ip: None,
1120        };
1121        assert_eq!(dynamic_mapping.to_string(), "443");
1122    }
1123
1124    #[test]
1125    fn test_command_output_helpers() {
1126        let output = CommandOutput {
1127            stdout: "line1\nline2".to_string(),
1128            stderr: "error1\nerror2".to_string(),
1129            exit_code: 0,
1130            success: true,
1131        };
1132
1133        assert_eq!(output.stdout_lines(), vec!["line1", "line2"]);
1134        assert_eq!(output.stderr_lines(), vec!["error1", "error2"]);
1135        assert!(!output.stdout_is_empty());
1136        assert!(!output.stderr_is_empty());
1137
1138        let empty_output = CommandOutput {
1139            stdout: "   ".to_string(),
1140            stderr: String::new(),
1141            exit_code: 0,
1142            success: true,
1143        };
1144
1145        assert!(empty_output.stdout_is_empty());
1146        assert!(empty_output.stderr_is_empty());
1147    }
1148
1149    #[test]
1150    fn test_compose_config_single_env_file() {
1151        let config = ComposeConfig::new().env_file("/path/to/.env");
1152        let args = config.build_global_args();
1153
1154        let env_file_count = args.iter().filter(|a| a.as_str() == "--env-file").count();
1155        assert_eq!(env_file_count, 1);
1156        assert!(args.contains(&"/path/to/.env".to_string()));
1157    }
1158
1159    #[test]
1160    fn test_compose_config_multiple_env_files() {
1161        let config = ComposeConfig::new()
1162            .env_file("/path/to/.env")
1163            .env_file("/path/to/.env.local")
1164            .env_file("/path/to/.env.production");
1165        let args = config.build_global_args();
1166
1167        let env_file_count = args.iter().filter(|a| a.as_str() == "--env-file").count();
1168        assert_eq!(env_file_count, 3);
1169        assert!(args.contains(&"/path/to/.env".to_string()));
1170        assert!(args.contains(&"/path/to/.env.local".to_string()));
1171        assert!(args.contains(&"/path/to/.env.production".to_string()));
1172    }
1173
1174    #[test]
1175    fn test_compose_config_no_env_file() {
1176        let config = ComposeConfig::new();
1177        let args = config.build_global_args();
1178
1179        assert!(!args.contains(&"--env-file".to_string()));
1180    }
1181
1182    /// Regression test for issue #233: Verify that compose commands don't produce
1183    /// "docker docker compose" when executed. The args returned by `ComposeCommand`
1184    /// should start with "compose" (not "docker"), and the `execute_command` logic
1185    /// should properly handle this by passing "compose" as the command name.
1186    #[cfg(feature = "compose")]
1187    #[test]
1188    fn test_compose_command_args_structure() {
1189        use crate::compose::ComposeUpCommand;
1190
1191        let cmd = ComposeUpCommand::new()
1192            .file("docker-compose.yml")
1193            .detach()
1194            .service("web");
1195
1196        let args = ComposeCommand::build_command_args(&cmd);
1197
1198        // First arg must be "compose" - this becomes the command name
1199        assert_eq!(args[0], "compose", "compose args must start with 'compose'");
1200
1201        // "docker" should never appear in these args - the runtime binary
1202        // is added separately by CommandExecutor
1203        assert!(
1204            !args.iter().any(|arg| arg == "docker"),
1205            "compose args should not contain 'docker': {args:?}"
1206        );
1207
1208        // Verify expected structure: compose [global opts] up [subcommand opts] [services]
1209        assert!(args.contains(&"up".to_string()), "must contain subcommand");
1210        assert!(args.contains(&"--file".to_string()), "must contain --file");
1211        assert!(
1212            args.contains(&"--detach".to_string()),
1213            "must contain --detach"
1214        );
1215        assert!(
1216            args.contains(&"web".to_string()),
1217            "must contain service name"
1218        );
1219    }
1220}