docker_wrapper/command/
compose_exec.rs

1//! Docker Compose exec command implementation using unified trait pattern.
2
3use super::{CommandExecutor, ComposeCommand, ComposeConfig, DockerCommand};
4use crate::error::Result;
5use async_trait::async_trait;
6use std::collections::HashMap;
7
8/// Docker Compose exec command builder
9#[derive(Debug, Clone)]
10#[allow(clippy::struct_excessive_bools)] // Multiple boolean flags are appropriate for exec command
11pub struct ComposeExecCommand {
12    /// Base command executor
13    pub executor: CommandExecutor,
14    /// Base compose configuration
15    pub config: ComposeConfig,
16    /// Service to execute command in
17    pub service: String,
18    /// Command and arguments to execute
19    pub command: Vec<String>,
20    /// Run in detached mode
21    pub detach: bool,
22    /// Disable pseudo-TTY allocation
23    pub no_tty: bool,
24    /// Keep STDIN open even if not attached
25    pub interactive: bool,
26    /// Run as specified user
27    pub user: Option<String>,
28    /// Working directory inside the container
29    pub workdir: Option<String>,
30    /// Set environment variables
31    pub env: HashMap<String, String>,
32    /// Container index (if service has multiple instances)
33    pub index: Option<u32>,
34    /// Use privileged mode
35    pub privileged: bool,
36}
37
38/// Result from compose exec command
39#[derive(Debug, Clone)]
40pub struct ComposeExecResult {
41    /// Raw stdout output
42    pub stdout: String,
43    /// Raw stderr output
44    pub stderr: String,
45    /// Success status
46    pub success: bool,
47    /// Exit code from the command
48    pub exit_code: i32,
49    /// Service that the command was executed in
50    pub service: String,
51    /// Whether the command was run in detached mode
52    pub detached: bool,
53}
54
55impl ComposeExecCommand {
56    /// Create a new compose exec command
57    #[must_use]
58    pub fn new(service: impl Into<String>) -> Self {
59        Self {
60            executor: CommandExecutor::new(),
61            config: ComposeConfig::new(),
62            service: service.into(),
63            command: Vec::new(),
64            detach: false,
65            no_tty: false,
66            interactive: false,
67            user: None,
68            workdir: None,
69            env: HashMap::new(),
70            index: None,
71            privileged: false,
72        }
73    }
74
75    /// Set the command to execute
76    #[must_use]
77    pub fn cmd<I, S>(mut self, command: I) -> Self
78    where
79        I: IntoIterator<Item = S>,
80        S: Into<String>,
81    {
82        self.command = command.into_iter().map(Into::into).collect();
83        self
84    }
85
86    /// Add a command argument
87    #[must_use]
88    pub fn arg(mut self, arg: impl Into<String>) -> Self {
89        self.command.push(arg.into());
90        self
91    }
92
93    /// Add multiple arguments
94    #[must_use]
95    pub fn args<I, S>(mut self, args: I) -> Self
96    where
97        I: IntoIterator<Item = S>,
98        S: Into<String>,
99    {
100        self.command.extend(args.into_iter().map(Into::into));
101        self
102    }
103
104    /// Run in detached mode
105    #[must_use]
106    pub fn detach(mut self) -> Self {
107        self.detach = true;
108        self
109    }
110
111    /// Disable pseudo-TTY allocation
112    #[must_use]
113    pub fn no_tty(mut self) -> Self {
114        self.no_tty = true;
115        self
116    }
117
118    /// Keep STDIN open even if not attached
119    #[must_use]
120    pub fn interactive(mut self) -> Self {
121        self.interactive = true;
122        self
123    }
124
125    /// Run as specified user
126    #[must_use]
127    pub fn user(mut self, user: impl Into<String>) -> Self {
128        self.user = Some(user.into());
129        self
130    }
131
132    /// Set working directory inside the container
133    #[must_use]
134    pub fn workdir(mut self, workdir: impl Into<String>) -> Self {
135        self.workdir = Some(workdir.into());
136        self
137    }
138
139    /// Set an environment variable
140    #[must_use]
141    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
142        self.env.insert(key.into(), value.into());
143        self
144    }
145
146    /// Set multiple environment variables
147    #[must_use]
148    pub fn envs(mut self, env_vars: HashMap<String, String>) -> Self {
149        self.env.extend(env_vars);
150        self
151    }
152
153    /// Set container index (for services with multiple instances)
154    #[must_use]
155    pub fn index(mut self, index: u32) -> Self {
156        self.index = Some(index);
157        self
158    }
159
160    /// Use privileged mode
161    #[must_use]
162    pub fn privileged(mut self) -> Self {
163        self.privileged = true;
164        self
165    }
166}
167
168#[async_trait]
169impl DockerCommand for ComposeExecCommand {
170    type Output = ComposeExecResult;
171
172    fn get_executor(&self) -> &CommandExecutor {
173        &self.executor
174    }
175
176    fn get_executor_mut(&mut self) -> &mut CommandExecutor {
177        &mut self.executor
178    }
179
180    fn build_command_args(&self) -> Vec<String> {
181        // Use the ComposeCommand implementation explicitly
182        <Self as ComposeCommand>::build_command_args(self)
183    }
184
185    async fn execute(&self) -> Result<Self::Output> {
186        let args = <Self as ComposeCommand>::build_command_args(self);
187        let output = self.execute_command(args).await?;
188
189        Ok(ComposeExecResult {
190            stdout: output.stdout,
191            stderr: output.stderr,
192            success: output.success,
193            exit_code: output.exit_code,
194            service: self.service.clone(),
195            detached: self.detach,
196        })
197    }
198}
199
200impl ComposeCommand for ComposeExecCommand {
201    fn get_config(&self) -> &ComposeConfig {
202        &self.config
203    }
204
205    fn get_config_mut(&mut self) -> &mut ComposeConfig {
206        &mut self.config
207    }
208
209    fn subcommand(&self) -> &'static str {
210        "exec"
211    }
212
213    fn build_subcommand_args(&self) -> Vec<String> {
214        let mut args = Vec::new();
215
216        if self.detach {
217            args.push("--detach".to_string());
218        }
219
220        if self.no_tty {
221            args.push("--no-TTY".to_string());
222        }
223
224        if self.interactive {
225            args.push("--interactive".to_string());
226        }
227
228        // Add user
229        if let Some(ref user) = self.user {
230            args.push("--user".to_string());
231            args.push(user.clone());
232        }
233
234        // Add working directory
235        if let Some(ref workdir) = self.workdir {
236            args.push("--workdir".to_string());
237            args.push(workdir.clone());
238        }
239
240        // Add environment variables
241        for (key, value) in &self.env {
242            args.push("--env".to_string());
243            args.push(format!("{key}={value}"));
244        }
245
246        // Add container index
247        if let Some(index) = self.index {
248            args.push("--index".to_string());
249            args.push(index.to_string());
250        }
251
252        if self.privileged {
253            args.push("--privileged".to_string());
254        }
255
256        // Add service name
257        args.push(self.service.clone());
258
259        // Add command and arguments
260        args.extend(self.command.clone());
261
262        args
263    }
264}
265
266impl ComposeExecResult {
267    /// Check if the command was successful
268    #[must_use]
269    pub fn success(&self) -> bool {
270        self.success
271    }
272
273    /// Get the exit code from the command
274    #[must_use]
275    pub fn exit_code(&self) -> i32 {
276        self.exit_code
277    }
278
279    /// Get the service that the command was executed in
280    #[must_use]
281    pub fn service(&self) -> &str {
282        &self.service
283    }
284
285    /// Check if the command was run in detached mode
286    #[must_use]
287    pub fn is_detached(&self) -> bool {
288        self.detached
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn test_compose_exec_basic() {
298        let cmd = ComposeExecCommand::new("web");
299        let args = cmd.build_subcommand_args();
300        assert!(args.contains(&"web".to_string()));
301
302        let full_args = ComposeCommand::build_command_args(&cmd);
303        assert_eq!(full_args[0], "compose");
304        assert!(full_args.contains(&"exec".to_string()));
305        assert!(full_args.contains(&"web".to_string()));
306    }
307
308    #[test]
309    fn test_compose_exec_with_command() {
310        let cmd = ComposeExecCommand::new("db").cmd(vec!["psql", "-U", "postgres"]);
311
312        let args = cmd.build_subcommand_args();
313        assert!(args.contains(&"db".to_string()));
314        assert!(args.contains(&"psql".to_string()));
315        assert!(args.contains(&"-U".to_string()));
316        assert!(args.contains(&"postgres".to_string()));
317    }
318
319    #[test]
320    fn test_compose_exec_with_flags() {
321        let cmd = ComposeExecCommand::new("app")
322            .detach()
323            .no_tty()
324            .interactive()
325            .privileged();
326
327        let args = cmd.build_subcommand_args();
328        assert!(args.contains(&"--detach".to_string()));
329        assert!(args.contains(&"--no-TTY".to_string()));
330        assert!(args.contains(&"--interactive".to_string()));
331        assert!(args.contains(&"--privileged".to_string()));
332    }
333
334    #[test]
335    fn test_compose_exec_with_user_and_workdir() {
336        let cmd = ComposeExecCommand::new("web")
337            .user("root")
338            .workdir("/app")
339            .cmd(vec!["bash"]);
340
341        let args = cmd.build_subcommand_args();
342        assert!(args.contains(&"--user".to_string()));
343        assert!(args.contains(&"root".to_string()));
344        assert!(args.contains(&"--workdir".to_string()));
345        assert!(args.contains(&"/app".to_string()));
346        assert!(args.contains(&"web".to_string()));
347        assert!(args.contains(&"bash".to_string()));
348    }
349
350    #[test]
351    fn test_compose_exec_with_env_vars() {
352        let cmd = ComposeExecCommand::new("worker")
353            .env("DEBUG", "1")
354            .env("NODE_ENV", "development")
355            .cmd(vec!["npm", "test"]);
356
357        let args = cmd.build_subcommand_args();
358        assert!(args.contains(&"--env".to_string()));
359        assert!(args.contains(&"DEBUG=1".to_string()));
360        assert!(args.contains(&"NODE_ENV=development".to_string()));
361    }
362
363    #[test]
364    fn test_compose_exec_with_index() {
365        let cmd = ComposeExecCommand::new("web")
366            .index(2)
367            .cmd(vec!["ps", "aux"]);
368
369        let args = cmd.build_subcommand_args();
370        assert!(args.contains(&"--index".to_string()));
371        assert!(args.contains(&"2".to_string()));
372        assert!(args.contains(&"web".to_string()));
373        assert!(args.contains(&"ps".to_string()));
374        assert!(args.contains(&"aux".to_string()));
375    }
376
377    #[test]
378    fn test_compose_exec_all_options() {
379        let cmd = ComposeExecCommand::new("api")
380            .detach()
381            .user("www-data")
382            .workdir("/var/www")
383            .env("PHP_ENV", "production")
384            .index(1)
385            .privileged()
386            .cmd(vec!["php", "-v"]);
387
388        let args = cmd.build_subcommand_args();
389
390        // Check flags
391        assert!(args.contains(&"--detach".to_string()));
392        assert!(args.contains(&"--privileged".to_string()));
393
394        // Check parameters
395        assert!(args.contains(&"--user".to_string()));
396        assert!(args.contains(&"www-data".to_string()));
397        assert!(args.contains(&"--workdir".to_string()));
398        assert!(args.contains(&"/var/www".to_string()));
399        assert!(args.contains(&"--env".to_string()));
400        assert!(args.contains(&"PHP_ENV=production".to_string()));
401        assert!(args.contains(&"--index".to_string()));
402        assert!(args.contains(&"1".to_string()));
403
404        // Check service and command
405        assert!(args.contains(&"api".to_string()));
406        assert!(args.contains(&"php".to_string()));
407        assert!(args.contains(&"-v".to_string()));
408    }
409
410    #[test]
411    fn test_compose_config_integration() {
412        let cmd = ComposeExecCommand::new("database")
413            .file("docker-compose.yml")
414            .project_name("my-project")
415            .user("postgres")
416            .cmd(vec!["psql", "-c", "SELECT 1"]);
417
418        let args = ComposeCommand::build_command_args(&cmd);
419        assert!(args.contains(&"--file".to_string()));
420        assert!(args.contains(&"docker-compose.yml".to_string()));
421        assert!(args.contains(&"--project-name".to_string()));
422        assert!(args.contains(&"my-project".to_string()));
423        assert!(args.contains(&"--user".to_string()));
424        assert!(args.contains(&"postgres".to_string()));
425        assert!(args.contains(&"database".to_string()));
426        assert!(args.contains(&"psql".to_string()));
427    }
428}