1use crate::error::{Error, Result};
48use crate::platform::PlatformInfo;
49use async_trait::async_trait;
50use std::collections::HashMap;
51use std::ffi::OsStr;
52use std::path::PathBuf;
53use std::process::Stdio;
54use std::time::Duration;
55use tokio::process::Command as TokioCommand;
56use tracing::{debug, error, instrument, trace, warn};
57
58pub mod attach;
60pub mod bake;
61pub mod build;
62pub mod builder;
63pub mod commit;
64#[cfg(feature = "compose")]
65pub mod compose;
66pub mod container_prune;
67pub mod context;
68pub mod cp;
69pub mod create;
70pub mod diff;
71pub mod events;
72pub mod exec;
73pub mod export;
74pub mod generic;
75pub mod history;
76pub mod image_prune;
77pub mod images;
78pub mod import;
79pub mod info;
80pub mod init;
81pub mod inspect;
82pub mod kill;
83pub mod load;
84pub mod login;
85pub mod logout;
86pub mod logs;
87#[cfg(feature = "manifest")]
88pub mod manifest;
89pub mod network;
90pub mod pause;
91pub mod port;
92pub mod ps;
93pub mod pull;
94pub mod push;
95pub mod rename;
96pub mod restart;
97pub mod rm;
98pub mod rmi;
99pub mod run;
100pub mod save;
101pub mod search;
102pub mod start;
103pub mod stats;
104pub mod stop;
105#[cfg(feature = "swarm")]
106pub mod swarm;
107pub mod system;
108pub mod tag;
109pub mod top;
110pub mod unpause;
111pub mod update;
112pub mod version;
113pub mod volume;
114pub mod wait;
115
116#[async_trait]
118pub trait DockerCommand {
119 type Output;
121
122 fn get_executor(&self) -> &CommandExecutor;
124
125 fn get_executor_mut(&mut self) -> &mut CommandExecutor;
127
128 fn build_command_args(&self) -> Vec<String>;
130
131 async fn execute(&self) -> Result<Self::Output>;
133
134 async fn execute_command(&self, command_args: Vec<String>) -> Result<CommandOutput> {
136 let executor = self.get_executor();
137
138 if command_args.first() == Some(&"compose".to_string()) {
141 let remaining_args = command_args.into_iter().skip(1).collect();
144 executor.execute_command("compose", remaining_args).await
145 } else {
146 let command_name = command_args
148 .first()
149 .unwrap_or(&"docker".to_string())
150 .clone();
151 let remaining_args = command_args.iter().skip(1).cloned().collect();
152 executor
153 .execute_command(&command_name, remaining_args)
154 .await
155 }
156 }
157
158 fn arg<S: AsRef<OsStr>>(&mut self, arg: S) -> &mut Self {
160 self.get_executor_mut().add_arg(arg);
161 self
162 }
163
164 fn args<I, S>(&mut self, args: I) -> &mut Self
166 where
167 I: IntoIterator<Item = S>,
168 S: AsRef<OsStr>,
169 {
170 self.get_executor_mut().add_args(args);
171 self
172 }
173
174 fn flag(&mut self, flag: &str) -> &mut Self {
176 self.get_executor_mut().add_flag(flag);
177 self
178 }
179
180 fn option(&mut self, key: &str, value: &str) -> &mut Self {
182 self.get_executor_mut().add_option(key, value);
183 self
184 }
185
186 fn with_timeout(&mut self, timeout: std::time::Duration) -> &mut Self {
191 self.get_executor_mut().timeout = Some(timeout);
192 self
193 }
194
195 fn with_timeout_secs(&mut self, seconds: u64) -> &mut Self {
197 self.get_executor_mut().timeout = Some(std::time::Duration::from_secs(seconds));
198 self
199 }
200}
201
202#[derive(Debug, Clone, Default)]
204pub struct ComposeConfig {
205 pub files: Vec<PathBuf>,
207 pub project_name: Option<String>,
209 pub project_directory: Option<PathBuf>,
211 pub profiles: Vec<String>,
213 pub env_file: Option<PathBuf>,
215 pub compatibility: bool,
217 pub dry_run: bool,
219 pub progress: Option<ProgressType>,
221 pub ansi: Option<AnsiMode>,
223 pub parallel: Option<i32>,
225}
226
227#[derive(Debug, Clone, Copy)]
229pub enum ProgressType {
230 Auto,
232 Tty,
234 Plain,
236 Json,
238 Quiet,
240}
241
242impl std::fmt::Display for ProgressType {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 match self {
245 Self::Auto => write!(f, "auto"),
246 Self::Tty => write!(f, "tty"),
247 Self::Plain => write!(f, "plain"),
248 Self::Json => write!(f, "json"),
249 Self::Quiet => write!(f, "quiet"),
250 }
251 }
252}
253
254#[derive(Debug, Clone, Copy)]
256pub enum AnsiMode {
257 Never,
259 Always,
261 Auto,
263}
264
265impl std::fmt::Display for AnsiMode {
266 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267 match self {
268 Self::Never => write!(f, "never"),
269 Self::Always => write!(f, "always"),
270 Self::Auto => write!(f, "auto"),
271 }
272 }
273}
274
275impl ComposeConfig {
276 #[must_use]
278 pub fn new() -> Self {
279 Self::default()
280 }
281
282 #[must_use]
284 pub fn file(mut self, path: impl Into<PathBuf>) -> Self {
285 self.files.push(path.into());
286 self
287 }
288
289 #[must_use]
291 pub fn project_name(mut self, name: impl Into<String>) -> Self {
292 self.project_name = Some(name.into());
293 self
294 }
295
296 #[must_use]
298 pub fn project_directory(mut self, dir: impl Into<PathBuf>) -> Self {
299 self.project_directory = Some(dir.into());
300 self
301 }
302
303 #[must_use]
305 pub fn profile(mut self, profile: impl Into<String>) -> Self {
306 self.profiles.push(profile.into());
307 self
308 }
309
310 #[must_use]
312 pub fn env_file(mut self, path: impl Into<PathBuf>) -> Self {
313 self.env_file = Some(path.into());
314 self
315 }
316
317 #[must_use]
319 pub fn compatibility(mut self) -> Self {
320 self.compatibility = true;
321 self
322 }
323
324 #[must_use]
326 pub fn dry_run(mut self) -> Self {
327 self.dry_run = true;
328 self
329 }
330
331 #[must_use]
333 pub fn progress(mut self, progress: ProgressType) -> Self {
334 self.progress = Some(progress);
335 self
336 }
337
338 #[must_use]
340 pub fn ansi(mut self, ansi: AnsiMode) -> Self {
341 self.ansi = Some(ansi);
342 self
343 }
344
345 #[must_use]
347 pub fn parallel(mut self, parallel: i32) -> Self {
348 self.parallel = Some(parallel);
349 self
350 }
351
352 #[must_use]
354 pub fn build_global_args(&self) -> Vec<String> {
355 let mut args = Vec::new();
356
357 for file in &self.files {
359 args.push("--file".to_string());
360 args.push(file.to_string_lossy().to_string());
361 }
362
363 if let Some(ref name) = self.project_name {
365 args.push("--project-name".to_string());
366 args.push(name.clone());
367 }
368
369 if let Some(ref dir) = self.project_directory {
371 args.push("--project-directory".to_string());
372 args.push(dir.to_string_lossy().to_string());
373 }
374
375 for profile in &self.profiles {
377 args.push("--profile".to_string());
378 args.push(profile.clone());
379 }
380
381 if let Some(ref env_file) = self.env_file {
383 args.push("--env-file".to_string());
384 args.push(env_file.to_string_lossy().to_string());
385 }
386
387 if self.compatibility {
389 args.push("--compatibility".to_string());
390 }
391
392 if self.dry_run {
393 args.push("--dry-run".to_string());
394 }
395
396 if let Some(progress) = self.progress {
398 args.push("--progress".to_string());
399 args.push(progress.to_string());
400 }
401
402 if let Some(ansi) = self.ansi {
404 args.push("--ansi".to_string());
405 args.push(ansi.to_string());
406 }
407
408 if let Some(parallel) = self.parallel {
410 args.push("--parallel".to_string());
411 args.push(parallel.to_string());
412 }
413
414 args
415 }
416}
417
418pub trait ComposeCommand: DockerCommand {
420 fn get_config(&self) -> &ComposeConfig;
422
423 fn get_config_mut(&mut self) -> &mut ComposeConfig;
425
426 fn subcommand(&self) -> &'static str;
428
429 fn build_subcommand_args(&self) -> Vec<String>;
431
432 fn build_command_args(&self) -> Vec<String> {
435 let mut args = vec!["compose".to_string()];
436
437 args.extend(self.get_config().build_global_args());
439
440 args.push(self.subcommand().to_string());
442
443 args.extend(self.build_subcommand_args());
445
446 args.extend(self.get_executor().raw_args.clone());
448
449 args
450 }
451
452 #[must_use]
454 fn file<P: Into<PathBuf>>(mut self, file: P) -> Self
455 where
456 Self: Sized,
457 {
458 self.get_config_mut().files.push(file.into());
459 self
460 }
461
462 #[must_use]
464 fn project_name(mut self, name: impl Into<String>) -> Self
465 where
466 Self: Sized,
467 {
468 self.get_config_mut().project_name = Some(name.into());
469 self
470 }
471}
472
473pub const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
475
476#[derive(Debug, Clone)]
478pub struct CommandExecutor {
479 pub raw_args: Vec<String>,
481 pub platform_info: Option<PlatformInfo>,
483 pub timeout: Option<Duration>,
485}
486
487impl CommandExecutor {
488 #[must_use]
490 pub fn new() -> Self {
491 Self {
492 raw_args: Vec::new(),
493 platform_info: None,
494 timeout: None,
495 }
496 }
497
498 pub fn with_platform() -> Result<Self> {
504 let platform_info = PlatformInfo::detect()?;
505 Ok(Self {
506 raw_args: Vec::new(),
507 platform_info: Some(platform_info),
508 timeout: None,
509 })
510 }
511
512 #[must_use]
514 pub fn platform(mut self, platform_info: PlatformInfo) -> Self {
515 self.platform_info = Some(platform_info);
516 self
517 }
518
519 #[must_use]
524 pub fn timeout(mut self, timeout: Duration) -> Self {
525 self.timeout = Some(timeout);
526 self
527 }
528
529 #[must_use]
531 pub fn timeout_secs(mut self, seconds: u64) -> Self {
532 self.timeout = Some(Duration::from_secs(seconds));
533 self
534 }
535
536 fn get_runtime_command(&self) -> String {
538 if let Some(ref platform_info) = self.platform_info {
539 platform_info.runtime.command().to_string()
540 } else {
541 "docker".to_string()
542 }
543 }
544
545 #[instrument(
551 name = "docker.command",
552 skip(self, args),
553 fields(
554 command = %command_name,
555 runtime = %self.get_runtime_command(),
556 timeout_secs = self.timeout.map(|t| t.as_secs()),
557 )
558 )]
559 pub async fn execute_command(
560 &self,
561 command_name: &str,
562 args: Vec<String>,
563 ) -> Result<CommandOutput> {
564 let mut all_args = self.raw_args.clone();
566 all_args.extend(args);
567
568 all_args.insert(0, command_name.to_string());
570
571 let runtime_command = self.get_runtime_command();
572
573 trace!(args = ?all_args, "executing docker command");
574
575 let result = if let Some(timeout_duration) = self.timeout {
577 self.execute_with_timeout(&runtime_command, &all_args, timeout_duration)
578 .await
579 } else {
580 self.execute_internal(&runtime_command, &all_args).await
581 };
582
583 match &result {
584 Ok(output) => {
585 debug!(
586 exit_code = output.exit_code,
587 stdout_len = output.stdout.len(),
588 stderr_len = output.stderr.len(),
589 "command completed successfully"
590 );
591 trace!(stdout = %output.stdout, "command stdout");
592 if !output.stderr.is_empty() {
593 trace!(stderr = %output.stderr, "command stderr");
594 }
595 }
596 Err(e) => {
597 error!(error = %e, "command failed");
598 }
599 }
600
601 result
602 }
603
604 #[instrument(
606 name = "docker.process",
607 skip(self, all_args),
608 fields(
609 full_command = %format!("{} {}", runtime_command, all_args.join(" ")),
610 )
611 )]
612 async fn execute_internal(
613 &self,
614 runtime_command: &str,
615 all_args: &[String],
616 ) -> Result<CommandOutput> {
617 let mut command = TokioCommand::new(runtime_command);
618
619 if let Some(ref platform_info) = self.platform_info {
621 let env_count = platform_info.environment_vars().len();
622 if env_count > 0 {
623 trace!(
624 env_vars = env_count,
625 "setting platform environment variables"
626 );
627 }
628 for (key, value) in platform_info.environment_vars() {
629 command.env(key, value);
630 }
631 }
632
633 trace!("spawning process");
634
635 let output = command
636 .args(all_args)
637 .stdout(Stdio::piped())
638 .stderr(Stdio::piped())
639 .output()
640 .await
641 .map_err(|e| {
642 error!(error = %e, "failed to spawn process");
643 Error::custom(format!(
644 "Failed to execute {runtime_command} {}: {e}",
645 all_args.first().unwrap_or(&String::new())
646 ))
647 })?;
648
649 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
650 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
651 let success = output.status.success();
652 let exit_code = output.status.code().unwrap_or(-1);
653
654 trace!(
655 exit_code = exit_code,
656 success = success,
657 stdout_bytes = output.stdout.len(),
658 stderr_bytes = output.stderr.len(),
659 "process completed"
660 );
661
662 if !success {
663 return Err(Error::command_failed(
664 format!("{} {}", runtime_command, all_args.join(" ")),
665 exit_code,
666 stdout,
667 stderr,
668 ));
669 }
670
671 Ok(CommandOutput {
672 stdout,
673 stderr,
674 exit_code,
675 success,
676 })
677 }
678
679 #[instrument(
681 name = "docker.timeout",
682 skip(self, all_args),
683 fields(timeout_secs = timeout_duration.as_secs())
684 )]
685 async fn execute_with_timeout(
686 &self,
687 runtime_command: &str,
688 all_args: &[String],
689 timeout_duration: Duration,
690 ) -> Result<CommandOutput> {
691 use tokio::time::timeout;
692
693 debug!("executing with timeout");
694
695 if let Ok(result) = timeout(
696 timeout_duration,
697 self.execute_internal(runtime_command, all_args),
698 )
699 .await
700 {
701 result
702 } else {
703 warn!(
704 timeout_secs = timeout_duration.as_secs(),
705 "command timed out"
706 );
707 Err(Error::timeout(timeout_duration.as_secs()))
708 }
709 }
710
711 pub fn add_arg<S: AsRef<OsStr>>(&mut self, arg: S) {
713 self.raw_args
714 .push(arg.as_ref().to_string_lossy().to_string());
715 }
716
717 pub fn add_args<I, S>(&mut self, args: I)
719 where
720 I: IntoIterator<Item = S>,
721 S: AsRef<OsStr>,
722 {
723 for arg in args {
724 self.add_arg(arg);
725 }
726 }
727
728 pub fn add_flag(&mut self, flag: &str) {
730 let flag_arg = if flag.starts_with('-') {
731 flag.to_string()
732 } else if flag.len() == 1 {
733 format!("-{flag}")
734 } else {
735 format!("--{flag}")
736 };
737 self.raw_args.push(flag_arg);
738 }
739
740 pub fn add_option(&mut self, key: &str, value: &str) {
742 let key_arg = if key.starts_with('-') {
743 key.to_string()
744 } else if key.len() == 1 {
745 format!("-{key}")
746 } else {
747 format!("--{key}")
748 };
749 self.raw_args.push(key_arg);
750 self.raw_args.push(value.to_string());
751 }
752}
753
754impl Default for CommandExecutor {
755 fn default() -> Self {
756 Self::new()
757 }
758}
759
760#[derive(Debug, Clone)]
762pub struct CommandOutput {
763 pub stdout: String,
765 pub stderr: String,
767 pub exit_code: i32,
769 pub success: bool,
771}
772
773impl CommandOutput {
774 #[must_use]
776 pub fn stdout_lines(&self) -> Vec<&str> {
777 self.stdout.lines().collect()
778 }
779
780 #[must_use]
782 pub fn stderr_lines(&self) -> Vec<&str> {
783 self.stderr.lines().collect()
784 }
785
786 #[must_use]
788 pub fn stdout_is_empty(&self) -> bool {
789 self.stdout.trim().is_empty()
790 }
791
792 #[must_use]
794 pub fn stderr_is_empty(&self) -> bool {
795 self.stderr.trim().is_empty()
796 }
797}
798
799#[derive(Debug, Clone, Default)]
801pub struct EnvironmentBuilder {
802 vars: HashMap<String, String>,
803}
804
805impl EnvironmentBuilder {
806 #[must_use]
808 pub fn new() -> Self {
809 Self::default()
810 }
811
812 #[must_use]
814 pub fn var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
815 self.vars.insert(key.into(), value.into());
816 self
817 }
818
819 #[must_use]
821 pub fn vars(mut self, vars: HashMap<String, String>) -> Self {
822 self.vars.extend(vars);
823 self
824 }
825
826 #[must_use]
828 pub fn build_args(&self) -> Vec<String> {
829 let mut args = Vec::new();
830 for (key, value) in &self.vars {
831 args.push("--env".to_string());
832 args.push(format!("{key}={value}"));
833 }
834 args
835 }
836
837 #[must_use]
839 pub fn as_map(&self) -> &HashMap<String, String> {
840 &self.vars
841 }
842}
843
844#[derive(Debug, Clone, Default)]
846pub struct PortBuilder {
847 mappings: Vec<PortMapping>,
848}
849
850impl PortBuilder {
851 #[must_use]
853 pub fn new() -> Self {
854 Self::default()
855 }
856
857 #[must_use]
859 pub fn port(mut self, host_port: u16, container_port: u16) -> Self {
860 self.mappings.push(PortMapping {
861 host_port: Some(host_port),
862 container_port,
863 protocol: Protocol::Tcp,
864 host_ip: None,
865 });
866 self
867 }
868
869 #[must_use]
871 pub fn port_with_protocol(
872 mut self,
873 host_port: u16,
874 container_port: u16,
875 protocol: Protocol,
876 ) -> Self {
877 self.mappings.push(PortMapping {
878 host_port: Some(host_port),
879 container_port,
880 protocol,
881 host_ip: None,
882 });
883 self
884 }
885
886 #[must_use]
888 pub fn dynamic_port(mut self, container_port: u16) -> Self {
889 self.mappings.push(PortMapping {
890 host_port: None,
891 container_port,
892 protocol: Protocol::Tcp,
893 host_ip: None,
894 });
895 self
896 }
897
898 #[must_use]
900 pub fn build_args(&self) -> Vec<String> {
901 let mut args = Vec::new();
902 for mapping in &self.mappings {
903 args.push("--publish".to_string());
904 args.push(mapping.to_string());
905 }
906 args
907 }
908
909 #[must_use]
911 pub fn mappings(&self) -> &[PortMapping] {
912 &self.mappings
913 }
914}
915
916#[derive(Debug, Clone)]
918pub struct PortMapping {
919 pub host_port: Option<u16>,
921 pub container_port: u16,
923 pub protocol: Protocol,
925 pub host_ip: Option<std::net::IpAddr>,
927}
928
929impl std::fmt::Display for PortMapping {
930 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
931 let protocol_suffix = match self.protocol {
932 Protocol::Tcp => "",
933 Protocol::Udp => "/udp",
934 };
935
936 if let Some(host_port) = self.host_port {
937 if let Some(host_ip) = self.host_ip {
938 write!(
939 f,
940 "{}:{}:{}{}",
941 host_ip, host_port, self.container_port, protocol_suffix
942 )
943 } else {
944 write!(
945 f,
946 "{}:{}{}",
947 host_port, self.container_port, protocol_suffix
948 )
949 }
950 } else {
951 write!(f, "{}{}", self.container_port, protocol_suffix)
952 }
953 }
954}
955
956#[derive(Debug, Clone, Copy, PartialEq, Eq)]
958pub enum Protocol {
959 Tcp,
961 Udp,
963}
964
965#[cfg(test)]
966mod tests {
967 use super::*;
968
969 #[test]
970 fn test_command_executor_args() {
971 let mut executor = CommandExecutor::new();
972 executor.add_arg("test");
973 executor.add_args(vec!["arg1", "arg2"]);
974 executor.add_flag("detach");
975 executor.add_flag("d");
976 executor.add_option("name", "test-container");
977
978 assert_eq!(
979 executor.raw_args,
980 vec![
981 "test",
982 "arg1",
983 "arg2",
984 "--detach",
985 "-d",
986 "--name",
987 "test-container"
988 ]
989 );
990 }
991
992 #[test]
993 fn test_command_executor_timeout() {
994 let executor = CommandExecutor::new();
995 assert!(executor.timeout.is_none());
996
997 let executor_with_timeout = CommandExecutor::new().timeout(Duration::from_secs(10));
998 assert_eq!(executor_with_timeout.timeout, Some(Duration::from_secs(10)));
999
1000 let executor_with_secs = CommandExecutor::new().timeout_secs(30);
1001 assert_eq!(executor_with_secs.timeout, Some(Duration::from_secs(30)));
1002 }
1003
1004 #[test]
1005 fn test_environment_builder() {
1006 let env = EnvironmentBuilder::new()
1007 .var("KEY1", "value1")
1008 .var("KEY2", "value2");
1009
1010 let args = env.build_args();
1011 assert!(args.contains(&"--env".to_string()));
1012 assert!(args.contains(&"KEY1=value1".to_string()));
1013 assert!(args.contains(&"KEY2=value2".to_string()));
1014 }
1015
1016 #[test]
1017 fn test_port_builder() {
1018 let ports = PortBuilder::new()
1019 .port(8080, 80)
1020 .dynamic_port(443)
1021 .port_with_protocol(8081, 81, Protocol::Udp);
1022
1023 let args = ports.build_args();
1024 assert!(args.contains(&"--publish".to_string()));
1025 assert!(args.contains(&"8080:80".to_string()));
1026 assert!(args.contains(&"443".to_string()));
1027 assert!(args.contains(&"8081:81/udp".to_string()));
1028 }
1029
1030 #[test]
1031 fn test_port_mapping_display() {
1032 let tcp_mapping = PortMapping {
1033 host_port: Some(8080),
1034 container_port: 80,
1035 protocol: Protocol::Tcp,
1036 host_ip: None,
1037 };
1038 assert_eq!(tcp_mapping.to_string(), "8080:80");
1039
1040 let udp_mapping = PortMapping {
1041 host_port: Some(8081),
1042 container_port: 81,
1043 protocol: Protocol::Udp,
1044 host_ip: None,
1045 };
1046 assert_eq!(udp_mapping.to_string(), "8081:81/udp");
1047
1048 let dynamic_mapping = PortMapping {
1049 host_port: None,
1050 container_port: 443,
1051 protocol: Protocol::Tcp,
1052 host_ip: None,
1053 };
1054 assert_eq!(dynamic_mapping.to_string(), "443");
1055 }
1056
1057 #[test]
1058 fn test_command_output_helpers() {
1059 let output = CommandOutput {
1060 stdout: "line1\nline2".to_string(),
1061 stderr: "error1\nerror2".to_string(),
1062 exit_code: 0,
1063 success: true,
1064 };
1065
1066 assert_eq!(output.stdout_lines(), vec!["line1", "line2"]);
1067 assert_eq!(output.stderr_lines(), vec!["error1", "error2"]);
1068 assert!(!output.stdout_is_empty());
1069 assert!(!output.stderr_is_empty());
1070
1071 let empty_output = CommandOutput {
1072 stdout: " ".to_string(),
1073 stderr: String::new(),
1074 exit_code: 0,
1075 success: true,
1076 };
1077
1078 assert!(empty_output.stdout_is_empty());
1079 assert!(empty_output.stderr_is_empty());
1080 }
1081
1082 #[cfg(feature = "compose")]
1087 #[test]
1088 fn test_compose_command_args_structure() {
1089 use crate::compose::ComposeUpCommand;
1090
1091 let cmd = ComposeUpCommand::new()
1092 .file("docker-compose.yml")
1093 .detach()
1094 .service("web");
1095
1096 let args = ComposeCommand::build_command_args(&cmd);
1097
1098 assert_eq!(args[0], "compose", "compose args must start with 'compose'");
1100
1101 assert!(
1104 !args.iter().any(|arg| arg == "docker"),
1105 "compose args should not contain 'docker': {args:?}"
1106 );
1107
1108 assert!(args.contains(&"up".to_string()), "must contain subcommand");
1110 assert!(args.contains(&"--file".to_string()), "must contain --file");
1111 assert!(
1112 args.contains(&"--detach".to_string()),
1113 "must contain --detach"
1114 );
1115 assert!(
1116 args.contains(&"web".to_string()),
1117 "must contain service name"
1118 );
1119 }
1120}