docker_wrapper/
compose.rs

1//! Docker Compose command implementations.
2//!
3//! This module provides support for Docker Compose commands, enabling
4//! multi-container application management.
5
6use crate::error::Result;
7use async_trait::async_trait;
8use std::path::PathBuf;
9use std::process::Stdio;
10use tokio::process::Command as TokioCommand;
11
12/// Base configuration for all compose commands
13#[derive(Debug, Clone, Default)]
14pub struct ComposeConfig {
15    /// Compose file paths (-f, --file)
16    pub files: Vec<PathBuf>,
17    /// Project name (-p, --project-name)
18    pub project_name: Option<String>,
19    /// Project directory (--project-directory)
20    pub project_directory: Option<PathBuf>,
21    /// Profiles to enable (--profile)
22    pub profiles: Vec<String>,
23    /// Environment file (--env-file)
24    pub env_file: Option<PathBuf>,
25    /// Run in compatibility mode
26    pub compatibility: bool,
27    /// Execute in dry run mode
28    pub dry_run: bool,
29    /// Progress output type
30    pub progress: Option<ProgressType>,
31    /// ANSI control characters
32    pub ansi: Option<AnsiMode>,
33    /// Max parallelism (-1 for unlimited)
34    pub parallel: Option<i32>,
35}
36
37/// Progress output type for compose commands
38#[derive(Debug, Clone, Copy)]
39pub enum ProgressType {
40    /// Auto-detect
41    Auto,
42    /// TTY output
43    Tty,
44    /// Plain text output
45    Plain,
46    /// JSON output
47    Json,
48    /// Quiet mode
49    Quiet,
50}
51
52impl std::fmt::Display for ProgressType {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            Self::Auto => write!(f, "auto"),
56            Self::Tty => write!(f, "tty"),
57            Self::Plain => write!(f, "plain"),
58            Self::Json => write!(f, "json"),
59            Self::Quiet => write!(f, "quiet"),
60        }
61    }
62}
63
64/// ANSI control character mode
65#[derive(Debug, Clone, Copy)]
66pub enum AnsiMode {
67    /// Never print ANSI
68    Never,
69    /// Always print ANSI
70    Always,
71    /// Auto-detect
72    Auto,
73}
74
75impl std::fmt::Display for AnsiMode {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        match self {
78            Self::Never => write!(f, "never"),
79            Self::Always => write!(f, "always"),
80            Self::Auto => write!(f, "auto"),
81        }
82    }
83}
84
85impl ComposeConfig {
86    /// Create a new compose configuration
87    #[must_use]
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    /// Add a compose file
93    #[must_use]
94    pub fn file(mut self, path: impl Into<PathBuf>) -> Self {
95        self.files.push(path.into());
96        self
97    }
98
99    /// Set project name
100    #[must_use]
101    pub fn project_name(mut self, name: impl Into<String>) -> Self {
102        self.project_name = Some(name.into());
103        self
104    }
105
106    /// Set project directory
107    #[must_use]
108    pub fn project_directory(mut self, dir: impl Into<PathBuf>) -> Self {
109        self.project_directory = Some(dir.into());
110        self
111    }
112
113    /// Add a profile
114    #[must_use]
115    pub fn profile(mut self, profile: impl Into<String>) -> Self {
116        self.profiles.push(profile.into());
117        self
118    }
119
120    /// Set environment file
121    #[must_use]
122    pub fn env_file(mut self, path: impl Into<PathBuf>) -> Self {
123        self.env_file = Some(path.into());
124        self
125    }
126
127    /// Enable compatibility mode
128    #[must_use]
129    pub fn compatibility(mut self, enabled: bool) -> Self {
130        self.compatibility = enabled;
131        self
132    }
133
134    /// Enable dry run mode
135    #[must_use]
136    pub fn dry_run(mut self, enabled: bool) -> Self {
137        self.dry_run = enabled;
138        self
139    }
140
141    /// Build global arguments for compose commands
142    #[must_use]
143    pub fn build_global_args(&self) -> Vec<String> {
144        let mut args = Vec::new();
145
146        for file in &self.files {
147            args.push("--file".to_string());
148            args.push(file.display().to_string());
149        }
150
151        if let Some(ref name) = self.project_name {
152            args.push("--project-name".to_string());
153            args.push(name.clone());
154        }
155
156        if let Some(ref dir) = self.project_directory {
157            args.push("--project-directory".to_string());
158            args.push(dir.display().to_string());
159        }
160
161        for profile in &self.profiles {
162            args.push("--profile".to_string());
163            args.push(profile.clone());
164        }
165
166        if let Some(ref env_file) = self.env_file {
167            args.push("--env-file".to_string());
168            args.push(env_file.display().to_string());
169        }
170
171        if self.compatibility {
172            args.push("--compatibility".to_string());
173        }
174
175        if self.dry_run {
176            args.push("--dry-run".to_string());
177        }
178
179        if let Some(ref progress) = self.progress {
180            args.push("--progress".to_string());
181            args.push(progress.to_string());
182        }
183
184        if let Some(ref ansi) = self.ansi {
185            args.push("--ansi".to_string());
186            args.push(ansi.to_string());
187        }
188
189        if let Some(parallel) = self.parallel {
190            args.push("--parallel".to_string());
191            args.push(parallel.to_string());
192        }
193
194        args
195    }
196}
197
198/// Execute a compose command with the given configuration and arguments
199async fn execute_compose_command(
200    config: &ComposeConfig,
201    subcommand: &str,
202    args: Vec<String>,
203) -> Result<ComposeOutput> {
204    let mut cmd = TokioCommand::new("docker");
205
206    // Add "compose" as the first argument
207    cmd.arg("compose");
208
209    // Add global compose arguments
210    for arg in config.build_global_args() {
211        cmd.arg(arg);
212    }
213
214    // Add the subcommand
215    cmd.arg(subcommand);
216
217    // Add command-specific arguments
218    for arg in args {
219        cmd.arg(arg);
220    }
221
222    // Set up output pipes
223    cmd.stdout(Stdio::piped());
224    cmd.stderr(Stdio::piped());
225
226    let output = cmd.output().await.map_err(|e| {
227        crate::error::Error::custom(format!(
228            "Failed to execute docker compose {subcommand}: {e}"
229        ))
230    })?;
231
232    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
233    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
234    let success = output.status.success();
235    let exit_code = output.status.code().unwrap_or(-1);
236
237    if !success && !stderr.contains("Gracefully stopping...") {
238        return Err(crate::error::Error::command_failed(
239            format!("docker compose {subcommand}"),
240            exit_code,
241            stdout.clone(),
242            stderr.clone(),
243        ));
244    }
245
246    Ok(ComposeOutput {
247        stdout,
248        stderr,
249        exit_code,
250        success,
251    })
252}
253
254/// Common trait for all compose commands (existing pattern)
255#[async_trait]
256pub trait ComposeCommand {
257    /// The output type this command produces
258    type Output;
259
260    /// Get the compose subcommand name (e.g., "up", "down", "ps")
261    fn subcommand(&self) -> &'static str;
262
263    /// Build command-specific arguments
264    fn build_args(&self) -> Vec<String>;
265
266    /// Execute the command
267    async fn execute(&self) -> Result<Self::Output>;
268
269    /// Get the compose configuration
270    fn config(&self) -> &ComposeConfig;
271}
272
273/// Common trait for new compose commands
274#[async_trait]
275pub trait ComposeCommandV2 {
276    /// The output type this command produces
277    type Output;
278
279    /// Get the compose configuration
280    fn get_config(&self) -> &ComposeConfig;
281
282    /// Get mutable compose configuration
283    fn get_config_mut(&mut self) -> &mut ComposeConfig;
284
285    /// Execute compose command with given arguments
286    async fn execute_compose(&self, args: Vec<String>) -> Result<Self::Output>;
287
288    /// Execute the command
289    async fn execute(&self) -> Result<Self::Output>;
290
291    /// Helper to execute compose command
292    async fn execute_compose_command(&self, args: Vec<String>) -> Result<ComposeOutput> {
293        let config = self.get_config();
294        let mut cmd = TokioCommand::new("docker");
295
296        // Add "compose" as the first argument
297        cmd.arg("compose");
298
299        // Add global compose arguments
300        for arg in config.build_global_args() {
301            cmd.arg(arg);
302        }
303
304        // Add command-specific arguments
305        for arg in args {
306            cmd.arg(arg);
307        }
308
309        // Set up output pipes
310        cmd.stdout(Stdio::piped());
311        cmd.stderr(Stdio::piped());
312
313        let output = cmd.output().await.map_err(|e| {
314            crate::error::Error::custom(format!("Failed to execute docker compose: {e}"))
315        })?;
316
317        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
318        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
319        let success = output.status.success();
320        let exit_code = output.status.code().unwrap_or(-1);
321
322        if !success && !stderr.contains("Gracefully stopping...") {
323            return Err(crate::error::Error::command_failed(
324                "docker compose".to_string(),
325                exit_code,
326                stdout.clone(),
327                stderr.clone(),
328            ));
329        }
330
331        Ok(ComposeOutput {
332            stdout,
333            stderr,
334            exit_code,
335            success,
336        })
337    }
338}
339
340/// Output from a compose command
341#[derive(Debug, Clone)]
342pub struct ComposeOutput {
343    /// Standard output
344    pub stdout: String,
345    /// Standard error
346    pub stderr: String,
347    /// Exit code
348    pub exit_code: i32,
349    /// Whether the command succeeded
350    pub success: bool,
351}
352
353impl ComposeOutput {
354    /// Get stdout lines
355    #[must_use]
356    pub fn stdout_lines(&self) -> Vec<&str> {
357        self.stdout.lines().collect()
358    }
359
360    /// Get stderr lines
361    #[must_use]
362    pub fn stderr_lines(&self) -> Vec<&str> {
363        self.stderr.lines().collect()
364    }
365}
366
367// Re-export submodules
368pub mod attach;
369pub mod build;
370pub mod config;
371pub mod convert;
372pub mod cp;
373pub mod create;
374pub mod down;
375pub mod events;
376pub mod exec;
377pub mod images;
378pub mod kill;
379pub mod logs;
380pub mod ls;
381pub mod pause;
382pub mod port;
383pub mod ps;
384pub mod push;
385pub mod restart;
386pub mod rm;
387pub mod run;
388pub mod scale;
389pub mod start;
390pub mod stop;
391pub mod top;
392pub mod unpause;
393pub mod up;
394pub mod version;
395pub mod wait;
396pub mod watch;
397
398pub use attach::{AttachResult, ComposeAttachCommand};
399pub use build::ComposeBuildCommand;
400pub use config::{ComposeConfigCommand, ConfigFormat, ConfigResult};
401pub use convert::{ComposeConvertCommand, ConvertFormat, ConvertResult};
402pub use cp::{ComposeCpCommand, CpResult};
403pub use create::{ComposeCreateCommand, CreateResult, PullPolicy};
404pub use down::ComposeDownCommand;
405pub use events::{ComposeEvent, ComposeEventsCommand, EventsResult};
406pub use exec::ComposeExecCommand;
407pub use images::{ComposeImagesCommand, ImageInfo, ImagesFormat, ImagesResult};
408pub use kill::{ComposeKillCommand, KillResult};
409pub use logs::ComposeLogsCommand;
410pub use ls::{ComposeLsCommand, ComposeProject, LsFormat, LsResult};
411pub use pause::{ComposePauseCommand, PauseResult};
412pub use port::{ComposePortCommand, PortResult};
413pub use ps::ComposePsCommand;
414pub use push::{ComposePushCommand, PushResult};
415pub use restart::ComposeRestartCommand;
416pub use rm::{ComposeRmCommand, RmResult};
417pub use run::ComposeRunCommand;
418pub use scale::{ComposeScaleCommand, ScaleResult};
419pub use start::ComposeStartCommand;
420pub use stop::ComposeStopCommand;
421pub use top::{ComposeTopCommand, TopResult};
422pub use unpause::{ComposeUnpauseCommand, UnpauseResult};
423pub use up::ComposeUpCommand;
424pub use version::{ComposeVersionCommand, VersionFormat, VersionInfo, VersionResult};
425pub use wait::{ComposeWaitCommand, WaitResult};
426pub use watch::{ComposeWatchCommand, WatchResult};
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn test_compose_config_new() {
434        let config = ComposeConfig::new();
435        assert!(config.files.is_empty());
436        assert!(config.project_name.is_none());
437        assert!(config.project_directory.is_none());
438        assert!(config.profiles.is_empty());
439        assert!(config.env_file.is_none());
440        assert!(!config.compatibility);
441        assert!(!config.dry_run);
442    }
443
444    #[test]
445    fn test_compose_config_builder() {
446        let config = ComposeConfig::new()
447            .file("docker-compose.yml")
448            .file("docker-compose.override.yml")
449            .project_name("myproject")
450            .project_directory("/path/to/project")
451            .profile("dev")
452            .profile("debug")
453            .env_file(".env")
454            .compatibility(true)
455            .dry_run(true);
456
457        assert_eq!(config.files.len(), 2);
458        assert_eq!(config.files[0].to_str().unwrap(), "docker-compose.yml");
459        assert_eq!(
460            config.files[1].to_str().unwrap(),
461            "docker-compose.override.yml"
462        );
463        assert_eq!(config.project_name, Some("myproject".to_string()));
464        assert_eq!(
465            config.project_directory.unwrap().to_str().unwrap(),
466            "/path/to/project"
467        );
468        assert_eq!(config.profiles, vec!["dev", "debug"]);
469        assert_eq!(config.env_file.unwrap().to_str().unwrap(), ".env");
470        assert!(config.compatibility);
471        assert!(config.dry_run);
472    }
473
474    #[test]
475    fn test_compose_config_build_global_args() {
476        let config = ComposeConfig::new();
477        let args = config.build_global_args();
478        assert!(args.is_empty());
479    }
480
481    #[test]
482    fn test_compose_config_build_global_args_with_files() {
483        let config = ComposeConfig::new()
484            .file("compose.yml")
485            .file("compose.override.yml");
486
487        let args = config.build_global_args();
488        assert_eq!(
489            args,
490            vec!["--file", "compose.yml", "--file", "compose.override.yml"]
491        );
492    }
493
494    #[test]
495    fn test_compose_config_build_global_args_complete() {
496        let config = ComposeConfig::new()
497            .file("docker-compose.yml")
498            .project_name("test")
499            .project_directory("/app")
500            .profile("prod")
501            .env_file(".env.prod")
502            .compatibility(true)
503            .dry_run(true);
504
505        let args = config.build_global_args();
506        assert!(args.contains(&"--file".to_string()));
507        assert!(args.contains(&"docker-compose.yml".to_string()));
508        assert!(args.contains(&"--project-name".to_string()));
509        assert!(args.contains(&"test".to_string()));
510        assert!(args.contains(&"--project-directory".to_string()));
511        assert!(args.contains(&"/app".to_string()));
512        assert!(args.contains(&"--profile".to_string()));
513        assert!(args.contains(&"prod".to_string()));
514        assert!(args.contains(&"--env-file".to_string()));
515        assert!(args.contains(&".env.prod".to_string()));
516        assert!(args.contains(&"--compatibility".to_string()));
517        assert!(args.contains(&"--dry-run".to_string()));
518    }
519
520    #[test]
521    fn test_compose_config_with_progress() {
522        let mut config = ComposeConfig::new();
523        config.progress = Some(ProgressType::Plain);
524
525        let args = config.build_global_args();
526        assert!(args.contains(&"--progress".to_string()));
527        assert!(args.contains(&"plain".to_string()));
528    }
529
530    #[test]
531    fn test_compose_config_with_ansi() {
532        let mut config = ComposeConfig::new();
533        config.ansi = Some(AnsiMode::Never);
534
535        let args = config.build_global_args();
536        assert!(args.contains(&"--ansi".to_string()));
537        assert!(args.contains(&"never".to_string()));
538    }
539
540    #[test]
541    fn test_compose_config_with_parallel() {
542        let mut config = ComposeConfig::new();
543        config.parallel = Some(4);
544
545        let args = config.build_global_args();
546        assert!(args.contains(&"--parallel".to_string()));
547        assert!(args.contains(&"4".to_string()));
548    }
549
550    #[test]
551    fn test_progress_type_display() {
552        assert_eq!(ProgressType::Auto.to_string(), "auto");
553        assert_eq!(ProgressType::Tty.to_string(), "tty");
554        assert_eq!(ProgressType::Plain.to_string(), "plain");
555        assert_eq!(ProgressType::Json.to_string(), "json");
556        assert_eq!(ProgressType::Quiet.to_string(), "quiet");
557    }
558
559    #[test]
560    fn test_ansi_mode_display() {
561        assert_eq!(AnsiMode::Never.to_string(), "never");
562        assert_eq!(AnsiMode::Always.to_string(), "always");
563        assert_eq!(AnsiMode::Auto.to_string(), "auto");
564    }
565
566    #[test]
567    fn test_compose_output_stdout_lines() {
568        let output = ComposeOutput {
569            stdout: "line1\nline2\nline3".to_string(),
570            stderr: String::new(),
571            exit_code: 0,
572            success: true,
573        };
574
575        let lines = output.stdout_lines();
576        assert_eq!(lines, vec!["line1", "line2", "line3"]);
577    }
578
579    #[test]
580    fn test_compose_output_stderr_lines() {
581        let output = ComposeOutput {
582            stdout: String::new(),
583            stderr: "error1\nerror2".to_string(),
584            exit_code: 1,
585            success: false,
586        };
587
588        let lines = output.stderr_lines();
589        assert_eq!(lines, vec!["error1", "error2"]);
590    }
591
592    #[test]
593    fn test_compose_output_empty_lines() {
594        let output = ComposeOutput {
595            stdout: String::new(),
596            stderr: String::new(),
597            exit_code: 0,
598            success: true,
599        };
600
601        // Empty string produces no lines when split
602        assert!(output.stdout_lines().is_empty());
603        assert!(output.stderr_lines().is_empty());
604    }
605
606    #[test]
607    fn test_compose_output_single_line() {
608        let output = ComposeOutput {
609            stdout: "single line".to_string(),
610            stderr: "error line".to_string(),
611            exit_code: 0,
612            success: true,
613        };
614
615        assert_eq!(output.stdout_lines(), vec!["single line"]);
616        assert_eq!(output.stderr_lines(), vec!["error line"]);
617    }
618}