docker_wrapper/
command.rs

1//! Command trait architecture for extensible Docker command implementations.
2//!
3//! This module provides a base trait that all Docker commands implement,
4//! allowing for both structured high-level APIs and escape hatches for
5//! any unimplemented options via raw arguments.
6
7use crate::error::{Error, Result};
8use async_trait::async_trait;
9use std::collections::HashMap;
10use std::ffi::OsStr;
11use std::process::Stdio;
12use tokio::process::Command as TokioCommand;
13
14// Re-export all command modules
15pub mod bake;
16pub mod build;
17pub mod exec;
18pub mod images;
19pub mod info;
20pub mod login;
21pub mod logout;
22pub mod ps;
23pub mod pull;
24pub mod push;
25pub mod run;
26pub mod search;
27pub mod version;
28
29/// Base trait for all Docker commands
30#[async_trait]
31pub trait DockerCommand {
32    /// The output type this command produces
33    type Output;
34
35    /// Get the command name (e.g., "run", "exec", "ps")
36    fn command_name(&self) -> &'static str;
37
38    /// Build the command arguments
39    fn build_args(&self) -> Vec<String>;
40
41    /// Execute the command and return the typed output
42    async fn execute(&self) -> Result<Self::Output>;
43
44    /// Add a raw argument to the command (escape hatch)
45    fn arg<S: AsRef<OsStr>>(&mut self, arg: S) -> &mut Self;
46
47    /// Add multiple raw arguments to the command (escape hatch)
48    fn args<I, S>(&mut self, args: I) -> &mut Self
49    where
50        I: IntoIterator<Item = S>,
51        S: AsRef<OsStr>;
52
53    /// Add a flag option (e.g., --detach, --rm)
54    fn flag(&mut self, flag: &str) -> &mut Self;
55
56    /// Add a key-value option (e.g., --name value, --env key=value)
57    fn option(&mut self, key: &str, value: &str) -> &mut Self;
58}
59
60/// Common functionality for executing Docker commands
61#[derive(Debug, Clone)]
62pub struct CommandExecutor {
63    /// Additional raw arguments added via escape hatch
64    pub raw_args: Vec<String>,
65}
66
67impl CommandExecutor {
68    /// Create a new command executor
69    #[must_use]
70    pub fn new() -> Self {
71        Self {
72            raw_args: Vec::new(),
73        }
74    }
75
76    /// Execute a Docker command with the given arguments
77    ///
78    /// # Errors
79    /// Returns an error if the Docker command fails to execute or returns a non-zero exit code
80    pub async fn execute_command(
81        &self,
82        command_name: &str,
83        args: Vec<String>,
84    ) -> Result<CommandOutput> {
85        // Prepend raw args (they should come before command-specific args)
86        let mut all_args = self.raw_args.clone();
87        all_args.extend(args);
88
89        // Insert the command name at the beginning
90        all_args.insert(0, command_name.to_string());
91
92        let output = TokioCommand::new("docker")
93            .args(&all_args)
94            .stdout(Stdio::piped())
95            .stderr(Stdio::piped())
96            .output()
97            .await
98            .map_err(|e| Error::custom(format!("Failed to execute docker {command_name}: {e}")))?;
99
100        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
101        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
102        let success = output.status.success();
103        let exit_code = output.status.code().unwrap_or(-1);
104
105        if !success {
106            return Err(Error::command_failed(
107                format!("docker {}", all_args.join(" ")),
108                exit_code,
109                stdout,
110                stderr,
111            ));
112        }
113
114        Ok(CommandOutput {
115            stdout,
116            stderr,
117            exit_code,
118            success,
119        })
120    }
121
122    /// Add a raw argument
123    pub fn add_arg<S: AsRef<OsStr>>(&mut self, arg: S) {
124        self.raw_args
125            .push(arg.as_ref().to_string_lossy().to_string());
126    }
127
128    /// Add multiple raw arguments
129    pub fn add_args<I, S>(&mut self, args: I)
130    where
131        I: IntoIterator<Item = S>,
132        S: AsRef<OsStr>,
133    {
134        for arg in args {
135            self.add_arg(arg);
136        }
137    }
138
139    /// Add a flag option
140    pub fn add_flag(&mut self, flag: &str) {
141        let flag_arg = if flag.starts_with('-') {
142            flag.to_string()
143        } else if flag.len() == 1 {
144            format!("-{flag}")
145        } else {
146            format!("--{flag}")
147        };
148        self.raw_args.push(flag_arg);
149    }
150
151    /// Add a key-value option
152    pub fn add_option(&mut self, key: &str, value: &str) {
153        let key_arg = if key.starts_with('-') {
154            key.to_string()
155        } else if key.len() == 1 {
156            format!("-{key}")
157        } else {
158            format!("--{key}")
159        };
160        self.raw_args.push(key_arg);
161        self.raw_args.push(value.to_string());
162    }
163}
164
165impl Default for CommandExecutor {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171/// Output from executing a Docker command
172#[derive(Debug, Clone)]
173pub struct CommandOutput {
174    /// Standard output from the command
175    pub stdout: String,
176    /// Standard error from the command
177    pub stderr: String,
178    /// Exit code
179    pub exit_code: i32,
180    /// Whether the command was successful
181    pub success: bool,
182}
183
184impl CommandOutput {
185    /// Get stdout lines as a vector
186    #[must_use]
187    pub fn stdout_lines(&self) -> Vec<&str> {
188        self.stdout.lines().collect()
189    }
190
191    /// Get stderr lines as a vector
192    #[must_use]
193    pub fn stderr_lines(&self) -> Vec<&str> {
194        self.stderr.lines().collect()
195    }
196
197    /// Check if stdout is empty
198    #[must_use]
199    pub fn stdout_is_empty(&self) -> bool {
200        self.stdout.trim().is_empty()
201    }
202
203    /// Check if stderr is empty
204    #[must_use]
205    pub fn stderr_is_empty(&self) -> bool {
206        self.stderr.trim().is_empty()
207    }
208}
209
210/// Helper for building environment variables
211#[derive(Debug, Clone, Default)]
212pub struct EnvironmentBuilder {
213    vars: HashMap<String, String>,
214}
215
216impl EnvironmentBuilder {
217    /// Create a new environment builder
218    #[must_use]
219    pub fn new() -> Self {
220        Self::default()
221    }
222
223    /// Add an environment variable
224    #[must_use]
225    pub fn var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
226        self.vars.insert(key.into(), value.into());
227        self
228    }
229
230    /// Add multiple environment variables from a `HashMap`
231    #[must_use]
232    pub fn vars(mut self, vars: HashMap<String, String>) -> Self {
233        self.vars.extend(vars);
234        self
235    }
236
237    /// Build the environment arguments for Docker
238    #[must_use]
239    pub fn build_args(&self) -> Vec<String> {
240        let mut args = Vec::new();
241        for (key, value) in &self.vars {
242            args.push("--env".to_string());
243            args.push(format!("{key}={value}"));
244        }
245        args
246    }
247
248    /// Get the environment variables as a `HashMap`
249    #[must_use]
250    pub fn as_map(&self) -> &HashMap<String, String> {
251        &self.vars
252    }
253}
254
255/// Helper for building port mappings
256#[derive(Debug, Clone, Default)]
257pub struct PortBuilder {
258    mappings: Vec<PortMapping>,
259}
260
261impl PortBuilder {
262    /// Create a new port builder
263    #[must_use]
264    pub fn new() -> Self {
265        Self::default()
266    }
267
268    /// Add a port mapping
269    #[must_use]
270    pub fn port(mut self, host_port: u16, container_port: u16) -> Self {
271        self.mappings.push(PortMapping {
272            host_port: Some(host_port),
273            container_port,
274            protocol: Protocol::Tcp,
275            host_ip: None,
276        });
277        self
278    }
279
280    /// Add a port mapping with protocol
281    #[must_use]
282    pub fn port_with_protocol(
283        mut self,
284        host_port: u16,
285        container_port: u16,
286        protocol: Protocol,
287    ) -> Self {
288        self.mappings.push(PortMapping {
289            host_port: Some(host_port),
290            container_port,
291            protocol,
292            host_ip: None,
293        });
294        self
295    }
296
297    /// Add a dynamic port mapping (Docker assigns host port)
298    #[must_use]
299    pub fn dynamic_port(mut self, container_port: u16) -> Self {
300        self.mappings.push(PortMapping {
301            host_port: None,
302            container_port,
303            protocol: Protocol::Tcp,
304            host_ip: None,
305        });
306        self
307    }
308
309    /// Build the port arguments for Docker
310    #[must_use]
311    pub fn build_args(&self) -> Vec<String> {
312        let mut args = Vec::new();
313        for mapping in &self.mappings {
314            args.push("--publish".to_string());
315            args.push(mapping.to_string());
316        }
317        args
318    }
319
320    /// Get the port mappings
321    #[must_use]
322    pub fn mappings(&self) -> &[PortMapping] {
323        &self.mappings
324    }
325}
326
327/// Port mapping configuration
328#[derive(Debug, Clone)]
329pub struct PortMapping {
330    /// Host port (None for dynamic allocation)
331    pub host_port: Option<u16>,
332    /// Container port
333    pub container_port: u16,
334    /// Protocol (TCP or UDP)
335    pub protocol: Protocol,
336    /// Host IP to bind to (None for all interfaces)
337    pub host_ip: Option<std::net::IpAddr>,
338}
339
340impl std::fmt::Display for PortMapping {
341    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342        let protocol_suffix = match self.protocol {
343            Protocol::Tcp => "",
344            Protocol::Udp => "/udp",
345        };
346
347        if let Some(host_port) = self.host_port {
348            if let Some(host_ip) = self.host_ip {
349                write!(
350                    f,
351                    "{}:{}:{}{}",
352                    host_ip, host_port, self.container_port, protocol_suffix
353                )
354            } else {
355                write!(
356                    f,
357                    "{}:{}{}",
358                    host_port, self.container_port, protocol_suffix
359                )
360            }
361        } else {
362            write!(f, "{}{}", self.container_port, protocol_suffix)
363        }
364    }
365}
366
367/// Network protocol for port mappings
368#[derive(Debug, Clone, Copy, PartialEq, Eq)]
369pub enum Protocol {
370    /// TCP protocol
371    Tcp,
372    /// UDP protocol
373    Udp,
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn test_command_executor_args() {
382        let mut executor = CommandExecutor::new();
383        executor.add_arg("test");
384        executor.add_args(vec!["arg1", "arg2"]);
385        executor.add_flag("detach");
386        executor.add_flag("d");
387        executor.add_option("name", "test-container");
388
389        assert_eq!(
390            executor.raw_args,
391            vec![
392                "test",
393                "arg1",
394                "arg2",
395                "--detach",
396                "-d",
397                "--name",
398                "test-container"
399            ]
400        );
401    }
402
403    #[test]
404    fn test_environment_builder() {
405        let env = EnvironmentBuilder::new()
406            .var("KEY1", "value1")
407            .var("KEY2", "value2");
408
409        let args = env.build_args();
410        assert!(args.contains(&"--env".to_string()));
411        assert!(args.contains(&"KEY1=value1".to_string()));
412        assert!(args.contains(&"KEY2=value2".to_string()));
413    }
414
415    #[test]
416    fn test_port_builder() {
417        let ports = PortBuilder::new()
418            .port(8080, 80)
419            .dynamic_port(443)
420            .port_with_protocol(8081, 81, Protocol::Udp);
421
422        let args = ports.build_args();
423        assert!(args.contains(&"--publish".to_string()));
424        assert!(args.contains(&"8080:80".to_string()));
425        assert!(args.contains(&"443".to_string()));
426        assert!(args.contains(&"8081:81/udp".to_string()));
427    }
428
429    #[test]
430    fn test_port_mapping_display() {
431        let tcp_mapping = PortMapping {
432            host_port: Some(8080),
433            container_port: 80,
434            protocol: Protocol::Tcp,
435            host_ip: None,
436        };
437        assert_eq!(tcp_mapping.to_string(), "8080:80");
438
439        let udp_mapping = PortMapping {
440            host_port: Some(8081),
441            container_port: 81,
442            protocol: Protocol::Udp,
443            host_ip: None,
444        };
445        assert_eq!(udp_mapping.to_string(), "8081:81/udp");
446
447        let dynamic_mapping = PortMapping {
448            host_port: None,
449            container_port: 443,
450            protocol: Protocol::Tcp,
451            host_ip: None,
452        };
453        assert_eq!(dynamic_mapping.to_string(), "443");
454    }
455
456    #[test]
457    fn test_command_output_helpers() {
458        let output = CommandOutput {
459            stdout: "line1\nline2".to_string(),
460            stderr: "error1\nerror2".to_string(),
461            exit_code: 0,
462            success: true,
463        };
464
465        assert_eq!(output.stdout_lines(), vec!["line1", "line2"]);
466        assert_eq!(output.stderr_lines(), vec!["error1", "error2"]);
467        assert!(!output.stdout_is_empty());
468        assert!(!output.stderr_is_empty());
469
470        let empty_output = CommandOutput {
471            stdout: "   ".to_string(),
472            stderr: String::new(),
473            exit_code: 0,
474            success: true,
475        };
476
477        assert!(empty_output.stdout_is_empty());
478        assert!(empty_output.stderr_is_empty());
479    }
480}