docker_wrapper/command/
exec.rs

1//! Docker exec command implementation.
2//!
3//! This module provides a comprehensive implementation of the `docker exec` command
4//! with support for all native options and an extensible architecture for any additional options.
5
6use super::{CommandExecutor, DockerCommand, EnvironmentBuilder};
7use crate::error::Result;
8use async_trait::async_trait;
9use std::ffi::OsStr;
10use std::path::PathBuf;
11
12/// Docker exec command builder with fluent API
13#[derive(Debug, Clone)]
14#[allow(clippy::struct_excessive_bools)]
15pub struct ExecCommand {
16    /// The container to execute the command in
17    container: String,
18    /// The command to execute
19    command: Vec<String>,
20    /// Command executor for extensibility
21    executor: CommandExecutor,
22    /// Run in detached mode
23    detach: bool,
24    /// Override the key sequence for detaching a container
25    detach_keys: Option<String>,
26    /// Environment variables
27    environment: EnvironmentBuilder,
28    /// Environment files
29    env_files: Vec<String>,
30    /// Keep STDIN open even if not attached
31    interactive: bool,
32    /// Give extended privileges to the command
33    privileged: bool,
34    /// Allocate a pseudo-TTY
35    tty: bool,
36    /// Username or UID (format: "<name|uid>[:<group|gid>]")
37    user: Option<String>,
38    /// Working directory inside the container
39    workdir: Option<PathBuf>,
40}
41
42/// Output from docker exec command
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ExecOutput {
45    /// Standard output from the command
46    pub stdout: String,
47    /// Standard error from the command
48    pub stderr: String,
49    /// Exit code of the executed command
50    pub exit_code: i32,
51}
52
53impl ExecOutput {
54    /// Check if the command executed successfully
55    #[must_use]
56    pub fn success(&self) -> bool {
57        self.exit_code == 0
58    }
59
60    /// Get combined output (stdout + stderr)
61    #[must_use]
62    pub fn combined_output(&self) -> String {
63        if self.stderr.is_empty() {
64            self.stdout.clone()
65        } else if self.stdout.is_empty() {
66            self.stderr.clone()
67        } else {
68            format!("{}\n{}", self.stdout, self.stderr)
69        }
70    }
71
72    /// Check if stdout is empty (ignoring whitespace)
73    #[must_use]
74    pub fn stdout_is_empty(&self) -> bool {
75        self.stdout.trim().is_empty()
76    }
77
78    /// Check if stderr is empty (ignoring whitespace)
79    #[must_use]
80    pub fn stderr_is_empty(&self) -> bool {
81        self.stderr.trim().is_empty()
82    }
83}
84
85impl ExecCommand {
86    /// Create a new exec command for the specified container and command
87    ///
88    /// # Examples
89    ///
90    /// ```
91    /// use docker_wrapper::ExecCommand;
92    ///
93    /// let exec_cmd = ExecCommand::new("my-container", vec!["ls".to_string(), "-la".to_string()]);
94    /// ```
95    pub fn new(container: impl Into<String>, command: Vec<String>) -> Self {
96        Self {
97            container: container.into(),
98            command,
99            executor: CommandExecutor::new(),
100            detach: false,
101            detach_keys: None,
102            environment: EnvironmentBuilder::new(),
103            env_files: Vec::new(),
104            interactive: false,
105            privileged: false,
106            tty: false,
107            user: None,
108            workdir: None,
109        }
110    }
111
112    /// Run in detached mode (background)
113    ///
114    /// # Examples
115    ///
116    /// ```
117    /// use docker_wrapper::ExecCommand;
118    ///
119    /// let exec_cmd = ExecCommand::new("my-container", vec!["sleep".to_string(), "10".to_string()])
120    ///     .detach();
121    /// ```
122    #[must_use]
123    pub fn detach(mut self) -> Self {
124        self.detach = true;
125        self
126    }
127
128    /// Override the key sequence for detaching a container
129    ///
130    /// # Examples
131    ///
132    /// ```
133    /// use docker_wrapper::ExecCommand;
134    ///
135    /// let exec_cmd = ExecCommand::new("my-container", vec!["bash".to_string()])
136    ///     .detach_keys("ctrl-p,ctrl-q");
137    /// ```
138    #[must_use]
139    pub fn detach_keys(mut self, keys: impl Into<String>) -> Self {
140        self.detach_keys = Some(keys.into());
141        self
142    }
143
144    /// Add an environment variable
145    ///
146    /// # Examples
147    ///
148    /// ```
149    /// use docker_wrapper::ExecCommand;
150    ///
151    /// let exec_cmd = ExecCommand::new("my-container", vec!["env".to_string()])
152    ///     .env("DEBUG", "1")
153    ///     .env("LOG_LEVEL", "info");
154    /// ```
155    #[must_use]
156    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
157        self.environment = self.environment.var(key, value);
158        self
159    }
160
161    /// Add multiple environment variables
162    ///
163    /// # Examples
164    ///
165    /// ```
166    /// use docker_wrapper::ExecCommand;
167    /// use std::collections::HashMap;
168    ///
169    /// let mut env_vars = HashMap::new();
170    /// env_vars.insert("DEBUG".to_string(), "1".to_string());
171    /// env_vars.insert("LOG_LEVEL".to_string(), "info".to_string());
172    ///
173    /// let exec_cmd = ExecCommand::new("my-container", vec!["env".to_string()])
174    ///     .envs(env_vars);
175    /// ```
176    #[must_use]
177    pub fn envs(mut self, vars: std::collections::HashMap<String, String>) -> Self {
178        self.environment = self.environment.vars(vars);
179        self
180    }
181
182    /// Add an environment file
183    ///
184    /// # Examples
185    ///
186    /// ```
187    /// use docker_wrapper::ExecCommand;
188    ///
189    /// let exec_cmd = ExecCommand::new("my-container", vec!["env".to_string()])
190    ///     .env_file("/path/to/env.file");
191    /// ```
192    #[must_use]
193    pub fn env_file(mut self, file: impl Into<String>) -> Self {
194        self.env_files.push(file.into());
195        self
196    }
197
198    /// Keep STDIN open even if not attached
199    ///
200    /// # Examples
201    ///
202    /// ```
203    /// use docker_wrapper::ExecCommand;
204    ///
205    /// let exec_cmd = ExecCommand::new("my-container", vec!["bash".to_string()])
206    ///     .interactive();
207    /// ```
208    #[must_use]
209    pub fn interactive(mut self) -> Self {
210        self.interactive = true;
211        self
212    }
213
214    /// Give extended privileges to the command
215    ///
216    /// # Examples
217    ///
218    /// ```
219    /// use docker_wrapper::ExecCommand;
220    ///
221    /// let exec_cmd = ExecCommand::new("my-container", vec!["mount".to_string()])
222    ///     .privileged();
223    /// ```
224    #[must_use]
225    pub fn privileged(mut self) -> Self {
226        self.privileged = true;
227        self
228    }
229
230    /// Allocate a pseudo-TTY
231    ///
232    /// # Examples
233    ///
234    /// ```
235    /// use docker_wrapper::ExecCommand;
236    ///
237    /// let exec_cmd = ExecCommand::new("my-container", vec!["bash".to_string()])
238    ///     .tty();
239    /// ```
240    #[must_use]
241    pub fn tty(mut self) -> Self {
242        self.tty = true;
243        self
244    }
245
246    /// Set username or UID (format: "<name|uid>[:<group|gid>]")
247    ///
248    /// # Examples
249    ///
250    /// ```
251    /// use docker_wrapper::ExecCommand;
252    ///
253    /// let exec_cmd = ExecCommand::new("my-container", vec!["whoami".to_string()])
254    ///     .user("root");
255    ///
256    /// let exec_cmd2 = ExecCommand::new("my-container", vec!["id".to_string()])
257    ///     .user("1000:1000");
258    /// ```
259    #[must_use]
260    pub fn user(mut self, user: impl Into<String>) -> Self {
261        self.user = Some(user.into());
262        self
263    }
264
265    /// Set working directory inside the container
266    ///
267    /// # Examples
268    ///
269    /// ```
270    /// use docker_wrapper::ExecCommand;
271    ///
272    /// let exec_cmd = ExecCommand::new("my-container", vec!["pwd".to_string()])
273    ///     .workdir("/app");
274    /// ```
275    #[must_use]
276    pub fn workdir(mut self, workdir: impl Into<PathBuf>) -> Self {
277        self.workdir = Some(workdir.into());
278        self
279    }
280
281    /// Convenience method for interactive TTY mode
282    ///
283    /// # Examples
284    ///
285    /// ```
286    /// use docker_wrapper::ExecCommand;
287    ///
288    /// let exec_cmd = ExecCommand::new("my-container", vec!["bash".to_string()])
289    ///     .it();
290    /// ```
291    #[must_use]
292    pub fn it(self) -> Self {
293        self.interactive().tty()
294    }
295}
296
297#[async_trait]
298impl DockerCommand for ExecCommand {
299    type Output = ExecOutput;
300
301    fn command_name(&self) -> &'static str {
302        "exec"
303    }
304
305    fn build_args(&self) -> Vec<String> {
306        let mut args = Vec::new();
307
308        // Add flags
309        if self.detach {
310            args.push("--detach".to_string());
311        }
312
313        if let Some(ref keys) = self.detach_keys {
314            args.push("--detach-keys".to_string());
315            args.push(keys.clone());
316        }
317
318        // Add environment variables
319        for (key, value) in self.environment.as_map() {
320            args.push("--env".to_string());
321            args.push(format!("{key}={value}"));
322        }
323
324        // Add environment files
325        for env_file in &self.env_files {
326            args.push("--env-file".to_string());
327            args.push(env_file.clone());
328        }
329
330        if self.interactive {
331            args.push("--interactive".to_string());
332        }
333
334        if self.privileged {
335            args.push("--privileged".to_string());
336        }
337
338        if self.tty {
339            args.push("--tty".to_string());
340        }
341
342        if let Some(ref user) = self.user {
343            args.push("--user".to_string());
344            args.push(user.clone());
345        }
346
347        if let Some(ref workdir) = self.workdir {
348            args.push("--workdir".to_string());
349            args.push(workdir.to_string_lossy().to_string());
350        }
351
352        // Add any additional raw arguments
353        args.extend(self.executor.raw_args.clone());
354
355        // Add container
356        args.push(self.container.clone());
357
358        // Add command
359        args.extend(self.command.clone());
360
361        args
362    }
363
364    async fn execute(&self) -> Result<Self::Output> {
365        let args = self.build_args();
366        let output = self
367            .executor
368            .execute_command(self.command_name(), args)
369            .await?;
370
371        Ok(ExecOutput {
372            stdout: output.stdout,
373            stderr: output.stderr,
374            exit_code: output.exit_code,
375        })
376    }
377
378    fn arg<S: AsRef<OsStr>>(&mut self, arg: S) -> &mut Self {
379        self.executor.add_arg(arg);
380        self
381    }
382
383    fn args<I, S>(&mut self, args: I) -> &mut Self
384    where
385        I: IntoIterator<Item = S>,
386        S: AsRef<OsStr>,
387    {
388        self.executor.add_args(args);
389        self
390    }
391
392    fn flag(&mut self, flag: &str) -> &mut Self {
393        self.executor.add_flag(flag);
394        self
395    }
396
397    fn option(&mut self, key: &str, value: &str) -> &mut Self {
398        self.executor.add_option(key, value);
399        self
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn test_exec_command_builder() {
409        let cmd = ExecCommand::new("test-container", vec!["ls".to_string(), "-la".to_string()])
410            .interactive()
411            .tty()
412            .env("DEBUG", "1")
413            .user("root")
414            .workdir("/app");
415
416        let args = cmd.build_args();
417
418        assert!(args.contains(&"--interactive".to_string()));
419        assert!(args.contains(&"--tty".to_string()));
420        assert!(args.contains(&"--env".to_string()));
421        assert!(args.contains(&"DEBUG=1".to_string()));
422        assert!(args.contains(&"--user".to_string()));
423        assert!(args.contains(&"root".to_string()));
424        assert!(args.contains(&"--workdir".to_string()));
425        assert!(args.contains(&"/app".to_string()));
426        assert!(args.contains(&"test-container".to_string()));
427        assert!(args.contains(&"ls".to_string()));
428        assert!(args.contains(&"-la".to_string()));
429    }
430
431    #[test]
432    fn test_exec_command_detach() {
433        let cmd = ExecCommand::new(
434            "test-container",
435            vec!["sleep".to_string(), "10".to_string()],
436        )
437        .detach()
438        .detach_keys("ctrl-p,ctrl-q");
439
440        let args = cmd.build_args();
441
442        assert!(args.contains(&"--detach".to_string()));
443        assert!(args.contains(&"--detach-keys".to_string()));
444        assert!(args.contains(&"ctrl-p,ctrl-q".to_string()));
445    }
446
447    #[test]
448    fn test_exec_command_privileged() {
449        let cmd = ExecCommand::new("test-container", vec!["mount".to_string()]).privileged();
450
451        let args = cmd.build_args();
452
453        assert!(args.contains(&"--privileged".to_string()));
454    }
455
456    #[test]
457    fn test_exec_command_env_file() {
458        let cmd = ExecCommand::new("test-container", vec!["env".to_string()])
459            .env_file("/path/to/env.file")
460            .env_file("/another/env.file");
461
462        let args = cmd.build_args();
463
464        assert!(args.contains(&"--env-file".to_string()));
465        assert!(args.contains(&"/path/to/env.file".to_string()));
466        assert!(args.contains(&"/another/env.file".to_string()));
467    }
468
469    #[test]
470    fn test_it_convenience_method() {
471        let cmd = ExecCommand::new("test-container", vec!["bash".to_string()]).it();
472
473        let args = cmd.build_args();
474
475        assert!(args.contains(&"--interactive".to_string()));
476        assert!(args.contains(&"--tty".to_string()));
477    }
478
479    #[test]
480    fn test_exec_output_helpers() {
481        let output_success = ExecOutput {
482            stdout: "Hello World".to_string(),
483            stderr: String::new(),
484            exit_code: 0,
485        };
486
487        assert!(output_success.success());
488        assert!(!output_success.stdout_is_empty());
489        assert!(output_success.stderr_is_empty());
490        assert_eq!(output_success.combined_output(), "Hello World");
491
492        let output_error = ExecOutput {
493            stdout: String::new(),
494            stderr: "Error occurred".to_string(),
495            exit_code: 1,
496        };
497
498        assert!(!output_error.success());
499        assert!(output_error.stdout_is_empty());
500        assert!(!output_error.stderr_is_empty());
501        assert_eq!(output_error.combined_output(), "Error occurred");
502
503        let output_combined = ExecOutput {
504            stdout: "Output".to_string(),
505            stderr: "Warning".to_string(),
506            exit_code: 0,
507        };
508
509        assert_eq!(output_combined.combined_output(), "Output\nWarning");
510    }
511
512    #[test]
513    fn test_exec_command_extensibility() {
514        let mut cmd = ExecCommand::new("test-container", vec!["test".to_string()]);
515
516        // Test extensibility methods
517        cmd.flag("--some-flag");
518        cmd.option("--some-option", "value");
519        cmd.arg("extra-arg");
520
521        let args = cmd.build_args();
522
523        assert!(args.contains(&"--some-flag".to_string()));
524        assert!(args.contains(&"--some-option".to_string()));
525        assert!(args.contains(&"value".to_string()));
526        assert!(args.contains(&"extra-arg".to_string()));
527    }
528}