1use crate::error::Result;
7use async_trait::async_trait;
8use std::path::PathBuf;
9use std::process::Stdio;
10use tokio::process::Command as TokioCommand;
11
12#[derive(Debug, Clone, Default)]
14pub struct ComposeConfig {
15 pub files: Vec<PathBuf>,
17 pub project_name: Option<String>,
19 pub project_directory: Option<PathBuf>,
21 pub profiles: Vec<String>,
23 pub env_file: Option<PathBuf>,
25 pub compatibility: bool,
27 pub dry_run: bool,
29 pub progress: Option<ProgressType>,
31 pub ansi: Option<AnsiMode>,
33 pub parallel: Option<i32>,
35}
36
37#[derive(Debug, Clone, Copy)]
39pub enum ProgressType {
40 Auto,
42 Tty,
44 Plain,
46 Json,
48 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#[derive(Debug, Clone, Copy)]
66pub enum AnsiMode {
67 Never,
69 Always,
71 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 #[must_use]
88 pub fn new() -> Self {
89 Self::default()
90 }
91
92 #[must_use]
94 pub fn file(mut self, path: impl Into<PathBuf>) -> Self {
95 self.files.push(path.into());
96 self
97 }
98
99 #[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 #[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 #[must_use]
115 pub fn profile(mut self, profile: impl Into<String>) -> Self {
116 self.profiles.push(profile.into());
117 self
118 }
119
120 #[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 #[must_use]
129 pub fn compatibility(mut self, enabled: bool) -> Self {
130 self.compatibility = enabled;
131 self
132 }
133
134 #[must_use]
136 pub fn dry_run(mut self, enabled: bool) -> Self {
137 self.dry_run = enabled;
138 self
139 }
140
141 #[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
198async 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 cmd.arg("compose");
208
209 for arg in config.build_global_args() {
211 cmd.arg(arg);
212 }
213
214 cmd.arg(subcommand);
216
217 for arg in args {
219 cmd.arg(arg);
220 }
221
222 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#[async_trait]
256pub trait ComposeCommand {
257 type Output;
259
260 fn subcommand(&self) -> &'static str;
262
263 fn build_args(&self) -> Vec<String>;
265
266 async fn execute(&self) -> Result<Self::Output>;
268
269 fn config(&self) -> &ComposeConfig;
271}
272
273#[async_trait]
275pub trait ComposeCommandV2 {
276 type Output;
278
279 fn get_config(&self) -> &ComposeConfig;
281
282 fn get_config_mut(&mut self) -> &mut ComposeConfig;
284
285 async fn execute_compose(&self, args: Vec<String>) -> Result<Self::Output>;
287
288 async fn execute(&self) -> Result<Self::Output>;
290
291 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 cmd.arg("compose");
298
299 for arg in config.build_global_args() {
301 cmd.arg(arg);
302 }
303
304 for arg in args {
306 cmd.arg(arg);
307 }
308
309 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#[derive(Debug, Clone)]
342pub struct ComposeOutput {
343 pub stdout: String,
345 pub stderr: String,
347 pub exit_code: i32,
349 pub success: bool,
351}
352
353impl ComposeOutput {
354 #[must_use]
356 pub fn stdout_lines(&self) -> Vec<&str> {
357 self.stdout.lines().collect()
358 }
359
360 #[must_use]
362 pub fn stderr_lines(&self) -> Vec<&str> {
363 self.stderr.lines().collect()
364 }
365}
366
367pub 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 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}