docker_wrapper/command/
compose_run.rs

1//! Docker Compose run 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 run command builder
9#[derive(Debug, Clone)]
10#[allow(clippy::struct_excessive_bools)] // Multiple boolean flags are appropriate for run command
11pub struct ComposeRunCommand {
12    /// Base command executor
13    pub executor: CommandExecutor,
14    /// Base compose configuration
15    pub config: ComposeConfig,
16    /// Service to run
17    pub service: String,
18    /// Command and arguments to run
19    pub command: Vec<String>,
20    /// Run container in background
21    pub detach: bool,
22    /// Automatically remove the container when it exits
23    pub rm: bool,
24    /// Don't start linked services
25    pub no_deps: bool,
26    /// Disable pseudo-TTY allocation
27    pub no_tty: bool,
28    /// Keep STDIN open even if not attached
29    pub interactive: bool,
30    /// Override the entrypoint
31    pub entrypoint: Option<String>,
32    /// Set environment variables
33    pub env: HashMap<String, String>,
34    /// Add or override labels
35    pub labels: HashMap<String, String>,
36    /// Container name
37    pub name: Option<String>,
38    /// Publish container ports to host
39    pub publish: Vec<String>,
40    /// Run as specified user
41    pub user: Option<String>,
42    /// Working directory inside the container
43    pub workdir: Option<String>,
44    /// Bind mount volumes
45    pub volumes: Vec<String>,
46    /// Remove associated volumes when container is removed
47    pub volume_rm: bool,
48}
49
50/// Result from compose run command
51#[derive(Debug, Clone)]
52pub struct ComposeRunResult {
53    /// Raw stdout output
54    pub stdout: String,
55    /// Raw stderr output
56    pub stderr: String,
57    /// Success status
58    pub success: bool,
59    /// Exit code from the container
60    pub exit_code: i32,
61    /// Service that was run
62    pub service: String,
63    /// Whether the container was run in detached mode
64    pub detached: bool,
65}
66
67impl ComposeRunCommand {
68    /// Create a new compose run command
69    #[must_use]
70    pub fn new(service: impl Into<String>) -> Self {
71        Self {
72            executor: CommandExecutor::new(),
73            config: ComposeConfig::new(),
74            service: service.into(),
75            command: Vec::new(),
76            detach: false,
77            rm: false,
78            no_deps: false,
79            no_tty: false,
80            interactive: false,
81            entrypoint: None,
82            env: HashMap::new(),
83            labels: HashMap::new(),
84            name: None,
85            publish: Vec::new(),
86            user: None,
87            workdir: None,
88            volumes: Vec::new(),
89            volume_rm: false,
90        }
91    }
92
93    /// Set the command to run
94    #[must_use]
95    pub fn cmd<I, S>(mut self, command: I) -> Self
96    where
97        I: IntoIterator<Item = S>,
98        S: Into<String>,
99    {
100        self.command = command.into_iter().map(Into::into).collect();
101        self
102    }
103
104    /// Add command arguments
105    #[must_use]
106    pub fn arg(mut self, arg: impl Into<String>) -> Self {
107        self.command.push(arg.into());
108        self
109    }
110
111    /// Add multiple arguments
112    #[must_use]
113    pub fn args<I, S>(mut self, args: I) -> Self
114    where
115        I: IntoIterator<Item = S>,
116        S: Into<String>,
117    {
118        self.command.extend(args.into_iter().map(Into::into));
119        self
120    }
121
122    /// Run container in background
123    #[must_use]
124    pub fn detach(mut self) -> Self {
125        self.detach = true;
126        self
127    }
128
129    /// Automatically remove the container when it exits
130    #[must_use]
131    pub fn rm(mut self) -> Self {
132        self.rm = true;
133        self
134    }
135
136    /// Don't start linked services
137    #[must_use]
138    pub fn no_deps(mut self) -> Self {
139        self.no_deps = true;
140        self
141    }
142
143    /// Disable pseudo-TTY allocation
144    #[must_use]
145    pub fn no_tty(mut self) -> Self {
146        self.no_tty = true;
147        self
148    }
149
150    /// Keep STDIN open even if not attached
151    #[must_use]
152    pub fn interactive(mut self) -> Self {
153        self.interactive = true;
154        self
155    }
156
157    /// Override the entrypoint
158    #[must_use]
159    pub fn entrypoint(mut self, entrypoint: impl Into<String>) -> Self {
160        self.entrypoint = Some(entrypoint.into());
161        self
162    }
163
164    /// Set an environment variable
165    #[must_use]
166    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
167        self.env.insert(key.into(), value.into());
168        self
169    }
170
171    /// Set multiple environment variables
172    #[must_use]
173    pub fn envs(mut self, env_vars: HashMap<String, String>) -> Self {
174        self.env.extend(env_vars);
175        self
176    }
177
178    /// Add or override a label
179    #[must_use]
180    pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
181        self.labels.insert(key.into(), value.into());
182        self
183    }
184
185    /// Set multiple labels
186    #[must_use]
187    pub fn labels(mut self, labels: HashMap<String, String>) -> Self {
188        self.labels.extend(labels);
189        self
190    }
191
192    /// Set container name
193    #[must_use]
194    pub fn name(mut self, name: impl Into<String>) -> Self {
195        self.name = Some(name.into());
196        self
197    }
198
199    /// Publish a port to the host
200    #[must_use]
201    pub fn publish(mut self, publish: impl Into<String>) -> Self {
202        self.publish.push(publish.into());
203        self
204    }
205
206    /// Run as specified user
207    #[must_use]
208    pub fn user(mut self, user: impl Into<String>) -> Self {
209        self.user = Some(user.into());
210        self
211    }
212
213    /// Set working directory inside the container
214    #[must_use]
215    pub fn workdir(mut self, workdir: impl Into<String>) -> Self {
216        self.workdir = Some(workdir.into());
217        self
218    }
219
220    /// Bind mount a volume
221    #[must_use]
222    pub fn volume(mut self, volume: impl Into<String>) -> Self {
223        self.volumes.push(volume.into());
224        self
225    }
226
227    /// Remove associated volumes when container is removed
228    #[must_use]
229    pub fn volume_rm(mut self) -> Self {
230        self.volume_rm = true;
231        self
232    }
233}
234
235#[async_trait]
236impl DockerCommand for ComposeRunCommand {
237    type Output = ComposeRunResult;
238
239    fn get_executor(&self) -> &CommandExecutor {
240        &self.executor
241    }
242
243    fn get_executor_mut(&mut self) -> &mut CommandExecutor {
244        &mut self.executor
245    }
246
247    fn build_command_args(&self) -> Vec<String> {
248        // Use the ComposeCommand implementation explicitly
249        <Self as ComposeCommand>::build_command_args(self)
250    }
251
252    async fn execute(&self) -> Result<Self::Output> {
253        let args = <Self as ComposeCommand>::build_command_args(self);
254        let output = self.execute_command(args).await?;
255
256        Ok(ComposeRunResult {
257            stdout: output.stdout,
258            stderr: output.stderr,
259            success: output.success,
260            exit_code: output.exit_code,
261            service: self.service.clone(),
262            detached: self.detach,
263        })
264    }
265}
266
267impl ComposeCommand for ComposeRunCommand {
268    fn get_config(&self) -> &ComposeConfig {
269        &self.config
270    }
271
272    fn get_config_mut(&mut self) -> &mut ComposeConfig {
273        &mut self.config
274    }
275
276    fn subcommand(&self) -> &'static str {
277        "run"
278    }
279
280    fn build_subcommand_args(&self) -> Vec<String> {
281        let mut args = Vec::new();
282
283        if self.detach {
284            args.push("--detach".to_string());
285        }
286
287        if self.rm {
288            args.push("--rm".to_string());
289        }
290
291        if self.no_deps {
292            args.push("--no-deps".to_string());
293        }
294
295        if self.no_tty {
296            args.push("--no-TTY".to_string());
297        }
298
299        if self.interactive {
300            args.push("--interactive".to_string());
301        }
302
303        // Add entrypoint
304        if let Some(ref entrypoint) = self.entrypoint {
305            args.push("--entrypoint".to_string());
306            args.push(entrypoint.clone());
307        }
308
309        // Add environment variables
310        for (key, value) in &self.env {
311            args.push("--env".to_string());
312            args.push(format!("{key}={value}"));
313        }
314
315        // Add labels
316        for (key, value) in &self.labels {
317            args.push("--label".to_string());
318            args.push(format!("{key}={value}"));
319        }
320
321        // Add container name
322        if let Some(ref name) = self.name {
323            args.push("--name".to_string());
324            args.push(name.clone());
325        }
326
327        // Add published ports
328        for publish in &self.publish {
329            args.push("--publish".to_string());
330            args.push(publish.clone());
331        }
332
333        // Add user
334        if let Some(ref user) = self.user {
335            args.push("--user".to_string());
336            args.push(user.clone());
337        }
338
339        // Add working directory
340        if let Some(ref workdir) = self.workdir {
341            args.push("--workdir".to_string());
342            args.push(workdir.clone());
343        }
344
345        // Add volumes
346        for volume in &self.volumes {
347            args.push("--volume".to_string());
348            args.push(volume.clone());
349        }
350
351        if self.volume_rm {
352            args.push("--volume".to_string());
353            args.push("rm".to_string());
354        }
355
356        // Add service name
357        args.push(self.service.clone());
358
359        // Add command and arguments
360        args.extend(self.command.clone());
361
362        args
363    }
364}
365
366impl ComposeRunResult {
367    /// Check if the command was successful
368    #[must_use]
369    pub fn success(&self) -> bool {
370        self.success
371    }
372
373    /// Get the exit code from the container
374    #[must_use]
375    pub fn exit_code(&self) -> i32 {
376        self.exit_code
377    }
378
379    /// Get the service that was run
380    #[must_use]
381    pub fn service(&self) -> &str {
382        &self.service
383    }
384
385    /// Check if the container was run in detached mode
386    #[must_use]
387    pub fn is_detached(&self) -> bool {
388        self.detached
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn test_compose_run_basic() {
398        let cmd = ComposeRunCommand::new("web");
399        let args = cmd.build_subcommand_args();
400        assert!(args.contains(&"web".to_string()));
401
402        let full_args = ComposeCommand::build_command_args(&cmd);
403        assert_eq!(full_args[0], "compose");
404        assert!(full_args.contains(&"run".to_string()));
405        assert!(full_args.contains(&"web".to_string()));
406    }
407
408    #[test]
409    fn test_compose_run_with_command() {
410        let cmd = ComposeRunCommand::new("worker").cmd(vec!["python", "script.py"]);
411
412        let args = cmd.build_subcommand_args();
413        assert!(args.contains(&"worker".to_string()));
414        assert!(args.contains(&"python".to_string()));
415        assert!(args.contains(&"script.py".to_string()));
416    }
417
418    #[test]
419    fn test_compose_run_with_flags() {
420        let cmd = ComposeRunCommand::new("app")
421            .detach()
422            .rm()
423            .no_deps()
424            .interactive();
425
426        let args = cmd.build_subcommand_args();
427        assert!(args.contains(&"--detach".to_string()));
428        assert!(args.contains(&"--rm".to_string()));
429        assert!(args.contains(&"--no-deps".to_string()));
430        assert!(args.contains(&"--interactive".to_string()));
431    }
432
433    #[test]
434    fn test_compose_run_with_env_and_labels() {
435        let cmd = ComposeRunCommand::new("test")
436            .env("NODE_ENV", "development")
437            .env("DEBUG", "true")
438            .label("version", "1.0")
439            .label("component", "api");
440
441        let args = cmd.build_subcommand_args();
442        assert!(args.contains(&"--env".to_string()));
443        assert!(args.contains(&"NODE_ENV=development".to_string()));
444        assert!(args.contains(&"DEBUG=true".to_string()));
445        assert!(args.contains(&"--label".to_string()));
446        assert!(args.contains(&"version=1.0".to_string()));
447        assert!(args.contains(&"component=api".to_string()));
448    }
449
450    #[test]
451    fn test_compose_run_all_options() {
452        let cmd = ComposeRunCommand::new("database")
453            .detach()
454            .rm()
455            .name("test-db")
456            .user("postgres")
457            .workdir("/app")
458            .volume("/data:/var/lib/postgresql/data")
459            .publish("5432:5432")
460            .entrypoint("docker-entrypoint.sh")
461            .cmd(vec!["postgres"])
462            .env("POSTGRES_DB", "testdb")
463            .label("env", "test");
464
465        let args = cmd.build_subcommand_args();
466
467        // Check flags
468        assert!(args.contains(&"--detach".to_string()));
469        assert!(args.contains(&"--rm".to_string()));
470
471        // Check named parameters
472        assert!(args.contains(&"--name".to_string()));
473        assert!(args.contains(&"test-db".to_string()));
474        assert!(args.contains(&"--user".to_string()));
475        assert!(args.contains(&"postgres".to_string()));
476        assert!(args.contains(&"--workdir".to_string()));
477        assert!(args.contains(&"/app".to_string()));
478        assert!(args.contains(&"--volume".to_string()));
479        assert!(args.contains(&"/data:/var/lib/postgresql/data".to_string()));
480        assert!(args.contains(&"--publish".to_string()));
481        assert!(args.contains(&"5432:5432".to_string()));
482        assert!(args.contains(&"--entrypoint".to_string()));
483        assert!(args.contains(&"docker-entrypoint.sh".to_string()));
484
485        // Check service and command
486        assert!(args.contains(&"database".to_string()));
487        assert!(args.contains(&"postgres".to_string()));
488
489        // Check env and labels
490        assert!(args.contains(&"POSTGRES_DB=testdb".to_string()));
491        assert!(args.contains(&"env=test".to_string()));
492    }
493
494    #[test]
495    fn test_compose_config_integration() {
496        let cmd = ComposeRunCommand::new("worker")
497            .file("docker-compose.yml")
498            .project_name("my-project")
499            .rm()
500            .cmd(vec!["python", "worker.py"]);
501
502        let args = ComposeCommand::build_command_args(&cmd);
503        assert!(args.contains(&"--file".to_string()));
504        assert!(args.contains(&"docker-compose.yml".to_string()));
505        assert!(args.contains(&"--project-name".to_string()));
506        assert!(args.contains(&"my-project".to_string()));
507        assert!(args.contains(&"--rm".to_string()));
508        assert!(args.contains(&"worker".to_string()));
509        assert!(args.contains(&"python".to_string()));
510        assert!(args.contains(&"worker.py".to_string()));
511    }
512}