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