1use crate::error::{Error, Result};
48use crate::platform::PlatformInfo;
49use crate::tracing_compat::{debug, error, info, trace, warn};
50use async_trait::async_trait;
51use std::collections::HashMap;
52use std::ffi::OsStr;
53use std::path::PathBuf;
54use std::process::Stdio;
55use std::time::Duration;
56use tokio::process::Command as TokioCommand;
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_files: Vec<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_files.push(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 for env_file in &self.env_files {
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
476const STDERR_LOG_SNIPPET_BYTES: usize = 512;
479
480#[cfg_attr(not(feature = "tracing"), allow(dead_code))]
483fn truncate_for_log(s: &str) -> String {
484 if s.len() <= STDERR_LOG_SNIPPET_BYTES {
485 return s.to_string();
486 }
487 let mut end = STDERR_LOG_SNIPPET_BYTES;
489 while end > 0 && !s.is_char_boundary(end) {
490 end -= 1;
491 }
492 let mut out = String::with_capacity(end + 4);
493 out.push_str(&s[..end]);
494 out.push_str("...");
495 out
496}
497
498#[derive(Debug, Clone)]
500pub struct CommandExecutor {
501 pub raw_args: Vec<String>,
503 pub platform_info: Option<PlatformInfo>,
505 pub timeout: Option<Duration>,
507}
508
509impl CommandExecutor {
510 #[must_use]
512 pub fn new() -> Self {
513 Self {
514 raw_args: Vec::new(),
515 platform_info: None,
516 timeout: None,
517 }
518 }
519
520 pub fn with_platform() -> Result<Self> {
526 let platform_info = PlatformInfo::detect()?;
527 Ok(Self {
528 raw_args: Vec::new(),
529 platform_info: Some(platform_info),
530 timeout: None,
531 })
532 }
533
534 #[must_use]
536 pub fn platform(mut self, platform_info: PlatformInfo) -> Self {
537 self.platform_info = Some(platform_info);
538 self
539 }
540
541 #[must_use]
546 pub fn timeout(mut self, timeout: Duration) -> Self {
547 self.timeout = Some(timeout);
548 self
549 }
550
551 #[must_use]
553 pub fn timeout_secs(mut self, seconds: u64) -> Self {
554 self.timeout = Some(Duration::from_secs(seconds));
555 self
556 }
557
558 fn get_runtime_command(&self) -> String {
560 if let Some(ref platform_info) = self.platform_info {
561 platform_info.runtime.command().to_string()
562 } else {
563 "docker".to_string()
564 }
565 }
566
567 #[cfg_attr(not(feature = "tracing"), allow(dead_code))]
570 fn tracing_platform(&self) -> Option<&'static str> {
571 use crate::platform::Runtime;
572 let runtime = self.platform_info.as_ref().map(|p| &p.runtime)?;
573 Some(match runtime {
574 Runtime::Docker | Runtime::DockerDesktop => "docker",
575 Runtime::Podman => "podman",
576 Runtime::Colima => "colima",
577 Runtime::RancherDesktop => "rancher-desktop",
578 Runtime::OrbStack => "orbstack",
579 })
580 }
581
582 #[cfg_attr(
588 feature = "tracing",
589 tracing::instrument(
590 name = "docker.command",
591 skip(self, args),
592 fields(
593 command = %command_name,
594 args_count = args.len(),
595 platform = self.tracing_platform(),
596 runtime = %self.get_runtime_command(),
597 timeout_secs = self.timeout.map(|t| t.as_secs()),
598 )
599 )
600 )]
601 #[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
602 pub async fn execute_command(
603 &self,
604 command_name: &str,
605 args: Vec<String>,
606 ) -> Result<CommandOutput> {
607 let mut all_args = self.raw_args.clone();
609 all_args.extend(args);
610
611 all_args.insert(0, command_name.to_string());
613
614 let runtime_command = self.get_runtime_command();
615
616 trace!(args = ?all_args, "executing docker command");
617
618 let started_at = std::time::Instant::now();
619
620 let result = if let Some(timeout_duration) = self.timeout {
622 self.execute_with_timeout(&runtime_command, &all_args, timeout_duration)
623 .await
624 } else {
625 self.execute_internal(&runtime_command, &all_args).await
626 };
627
628 let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
629
630 match &result {
631 Ok(output) => {
632 info!(
633 exit_code = output.exit_code,
634 duration_ms = duration_ms,
635 stdout_len = output.stdout.len(),
636 stderr_len = output.stderr.len(),
637 "command completed"
638 );
639 trace!(stdout = %output.stdout, "command stdout");
640 if !output.stderr.is_empty() {
641 trace!(stderr = %output.stderr, "command stderr");
642 }
643 }
644 Err(e) => {
645 let (exit_code, stderr_snippet) = match e {
646 Error::CommandFailed {
647 exit_code, stderr, ..
648 } => (Some(*exit_code), Some(truncate_for_log(stderr))),
649 _ => (None, None),
650 };
651 warn!(
652 command = %command_name,
653 exit_code = exit_code,
654 duration_ms = duration_ms,
655 stderr_snippet = stderr_snippet.as_deref(),
656 error = %e,
657 "command failed"
658 );
659 }
660 }
661
662 result
663 }
664
665 #[cfg_attr(
667 feature = "tracing",
668 tracing::instrument(
669 name = "docker.process",
670 skip(self, all_args),
671 fields(
672 full_command = %format!("{} {}", runtime_command, all_args.join(" ")),
673 )
674 )
675 )]
676 async fn execute_internal(
677 &self,
678 runtime_command: &str,
679 all_args: &[String],
680 ) -> Result<CommandOutput> {
681 let mut command = TokioCommand::new(runtime_command);
682
683 if let Some(ref platform_info) = self.platform_info {
685 let env_count = platform_info.environment_vars().len();
686 if env_count > 0 {
687 trace!(
688 env_vars = env_count,
689 "setting platform environment variables"
690 );
691 }
692 for (key, value) in platform_info.environment_vars() {
693 command.env(key, value);
694 }
695 }
696
697 trace!("spawning process");
698
699 let output = command
700 .args(all_args)
701 .stdout(Stdio::piped())
702 .stderr(Stdio::piped())
703 .output()
704 .await
705 .map_err(|e| {
706 error!(error = %e, "failed to spawn process");
707 Error::custom(format!(
708 "Failed to execute {runtime_command} {}: {e}",
709 all_args.first().unwrap_or(&String::new())
710 ))
711 })?;
712
713 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
714 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
715 let success = output.status.success();
716 let exit_code = output.status.code().unwrap_or(-1);
717
718 trace!(
719 exit_code = exit_code,
720 success = success,
721 stdout_bytes = output.stdout.len(),
722 stderr_bytes = output.stderr.len(),
723 "process completed"
724 );
725
726 if !success {
727 return Err(Error::command_failed(
728 format!("{} {}", runtime_command, all_args.join(" ")),
729 exit_code,
730 stdout,
731 stderr,
732 ));
733 }
734
735 Ok(CommandOutput {
736 stdout,
737 stderr,
738 exit_code,
739 success,
740 })
741 }
742
743 #[cfg_attr(
745 feature = "tracing",
746 tracing::instrument(
747 name = "docker.timeout",
748 skip(self, all_args),
749 fields(timeout_secs = timeout_duration.as_secs())
750 )
751 )]
752 async fn execute_with_timeout(
753 &self,
754 runtime_command: &str,
755 all_args: &[String],
756 timeout_duration: Duration,
757 ) -> Result<CommandOutput> {
758 use tokio::time::timeout;
759
760 debug!("executing with timeout");
761
762 if let Ok(result) = timeout(
763 timeout_duration,
764 self.execute_internal(runtime_command, all_args),
765 )
766 .await
767 {
768 result
769 } else {
770 warn!(
771 timeout_secs = timeout_duration.as_secs(),
772 "command timed out"
773 );
774 Err(Error::timeout(timeout_duration.as_secs()))
775 }
776 }
777
778 pub fn add_arg<S: AsRef<OsStr>>(&mut self, arg: S) {
780 self.raw_args
781 .push(arg.as_ref().to_string_lossy().to_string());
782 }
783
784 pub fn add_args<I, S>(&mut self, args: I)
786 where
787 I: IntoIterator<Item = S>,
788 S: AsRef<OsStr>,
789 {
790 for arg in args {
791 self.add_arg(arg);
792 }
793 }
794
795 pub fn add_flag(&mut self, flag: &str) {
797 let flag_arg = if flag.starts_with('-') {
798 flag.to_string()
799 } else if flag.len() == 1 {
800 format!("-{flag}")
801 } else {
802 format!("--{flag}")
803 };
804 self.raw_args.push(flag_arg);
805 }
806
807 pub fn add_option(&mut self, key: &str, value: &str) {
809 let key_arg = if key.starts_with('-') {
810 key.to_string()
811 } else if key.len() == 1 {
812 format!("-{key}")
813 } else {
814 format!("--{key}")
815 };
816 self.raw_args.push(key_arg);
817 self.raw_args.push(value.to_string());
818 }
819}
820
821impl Default for CommandExecutor {
822 fn default() -> Self {
823 Self::new()
824 }
825}
826
827#[derive(Debug, Clone)]
829pub struct CommandOutput {
830 pub stdout: String,
832 pub stderr: String,
834 pub exit_code: i32,
836 pub success: bool,
838}
839
840impl CommandOutput {
841 #[must_use]
843 pub fn stdout_lines(&self) -> Vec<&str> {
844 self.stdout.lines().collect()
845 }
846
847 #[must_use]
849 pub fn stderr_lines(&self) -> Vec<&str> {
850 self.stderr.lines().collect()
851 }
852
853 #[must_use]
855 pub fn stdout_is_empty(&self) -> bool {
856 self.stdout.trim().is_empty()
857 }
858
859 #[must_use]
861 pub fn stderr_is_empty(&self) -> bool {
862 self.stderr.trim().is_empty()
863 }
864}
865
866#[derive(Debug, Clone, Default)]
868pub struct EnvironmentBuilder {
869 vars: HashMap<String, String>,
870}
871
872impl EnvironmentBuilder {
873 #[must_use]
875 pub fn new() -> Self {
876 Self::default()
877 }
878
879 #[must_use]
881 pub fn var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
882 self.vars.insert(key.into(), value.into());
883 self
884 }
885
886 #[must_use]
888 pub fn vars(mut self, vars: HashMap<String, String>) -> Self {
889 self.vars.extend(vars);
890 self
891 }
892
893 #[must_use]
895 pub fn build_args(&self) -> Vec<String> {
896 let mut args = Vec::new();
897 for (key, value) in &self.vars {
898 args.push("--env".to_string());
899 args.push(format!("{key}={value}"));
900 }
901 args
902 }
903
904 #[must_use]
906 pub fn as_map(&self) -> &HashMap<String, String> {
907 &self.vars
908 }
909}
910
911#[derive(Debug, Clone, Default)]
913pub struct PortBuilder {
914 mappings: Vec<PortMapping>,
915}
916
917impl PortBuilder {
918 #[must_use]
920 pub fn new() -> Self {
921 Self::default()
922 }
923
924 #[must_use]
926 pub fn port(mut self, host_port: u16, container_port: u16) -> Self {
927 self.mappings.push(PortMapping {
928 host_port: Some(host_port),
929 container_port,
930 protocol: Protocol::Tcp,
931 host_ip: None,
932 });
933 self
934 }
935
936 #[must_use]
938 pub fn port_with_protocol(
939 mut self,
940 host_port: u16,
941 container_port: u16,
942 protocol: Protocol,
943 ) -> Self {
944 self.mappings.push(PortMapping {
945 host_port: Some(host_port),
946 container_port,
947 protocol,
948 host_ip: None,
949 });
950 self
951 }
952
953 #[must_use]
955 pub fn dynamic_port(mut self, container_port: u16) -> Self {
956 self.mappings.push(PortMapping {
957 host_port: None,
958 container_port,
959 protocol: Protocol::Tcp,
960 host_ip: None,
961 });
962 self
963 }
964
965 #[must_use]
967 pub fn build_args(&self) -> Vec<String> {
968 let mut args = Vec::new();
969 for mapping in &self.mappings {
970 args.push("--publish".to_string());
971 args.push(mapping.to_string());
972 }
973 args
974 }
975
976 #[must_use]
978 pub fn mappings(&self) -> &[PortMapping] {
979 &self.mappings
980 }
981}
982
983#[derive(Debug, Clone)]
985pub struct PortMapping {
986 pub host_port: Option<u16>,
988 pub container_port: u16,
990 pub protocol: Protocol,
992 pub host_ip: Option<std::net::IpAddr>,
994}
995
996impl std::fmt::Display for PortMapping {
997 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
998 let protocol_suffix = match self.protocol {
999 Protocol::Tcp => "",
1000 Protocol::Udp => "/udp",
1001 };
1002
1003 if let Some(host_port) = self.host_port {
1004 if let Some(host_ip) = self.host_ip {
1005 write!(
1006 f,
1007 "{}:{}:{}{}",
1008 host_ip, host_port, self.container_port, protocol_suffix
1009 )
1010 } else {
1011 write!(
1012 f,
1013 "{}:{}{}",
1014 host_port, self.container_port, protocol_suffix
1015 )
1016 }
1017 } else {
1018 write!(f, "{}{}", self.container_port, protocol_suffix)
1019 }
1020 }
1021}
1022
1023#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1025pub enum Protocol {
1026 Tcp,
1028 Udp,
1030}
1031
1032#[cfg(test)]
1033mod tests {
1034 use super::*;
1035
1036 #[test]
1037 fn test_command_executor_args() {
1038 let mut executor = CommandExecutor::new();
1039 executor.add_arg("test");
1040 executor.add_args(vec!["arg1", "arg2"]);
1041 executor.add_flag("detach");
1042 executor.add_flag("d");
1043 executor.add_option("name", "test-container");
1044
1045 assert_eq!(
1046 executor.raw_args,
1047 vec![
1048 "test",
1049 "arg1",
1050 "arg2",
1051 "--detach",
1052 "-d",
1053 "--name",
1054 "test-container"
1055 ]
1056 );
1057 }
1058
1059 #[test]
1060 fn test_command_executor_timeout() {
1061 let executor = CommandExecutor::new();
1062 assert!(executor.timeout.is_none());
1063
1064 let executor_with_timeout = CommandExecutor::new().timeout(Duration::from_secs(10));
1065 assert_eq!(executor_with_timeout.timeout, Some(Duration::from_secs(10)));
1066
1067 let executor_with_secs = CommandExecutor::new().timeout_secs(30);
1068 assert_eq!(executor_with_secs.timeout, Some(Duration::from_secs(30)));
1069 }
1070
1071 #[test]
1072 fn test_environment_builder() {
1073 let env = EnvironmentBuilder::new()
1074 .var("KEY1", "value1")
1075 .var("KEY2", "value2");
1076
1077 let args = env.build_args();
1078 assert!(args.contains(&"--env".to_string()));
1079 assert!(args.contains(&"KEY1=value1".to_string()));
1080 assert!(args.contains(&"KEY2=value2".to_string()));
1081 }
1082
1083 #[test]
1084 fn test_port_builder() {
1085 let ports = PortBuilder::new()
1086 .port(8080, 80)
1087 .dynamic_port(443)
1088 .port_with_protocol(8081, 81, Protocol::Udp);
1089
1090 let args = ports.build_args();
1091 assert!(args.contains(&"--publish".to_string()));
1092 assert!(args.contains(&"8080:80".to_string()));
1093 assert!(args.contains(&"443".to_string()));
1094 assert!(args.contains(&"8081:81/udp".to_string()));
1095 }
1096
1097 #[test]
1098 fn test_port_mapping_display() {
1099 let tcp_mapping = PortMapping {
1100 host_port: Some(8080),
1101 container_port: 80,
1102 protocol: Protocol::Tcp,
1103 host_ip: None,
1104 };
1105 assert_eq!(tcp_mapping.to_string(), "8080:80");
1106
1107 let udp_mapping = PortMapping {
1108 host_port: Some(8081),
1109 container_port: 81,
1110 protocol: Protocol::Udp,
1111 host_ip: None,
1112 };
1113 assert_eq!(udp_mapping.to_string(), "8081:81/udp");
1114
1115 let dynamic_mapping = PortMapping {
1116 host_port: None,
1117 container_port: 443,
1118 protocol: Protocol::Tcp,
1119 host_ip: None,
1120 };
1121 assert_eq!(dynamic_mapping.to_string(), "443");
1122 }
1123
1124 #[test]
1125 fn test_command_output_helpers() {
1126 let output = CommandOutput {
1127 stdout: "line1\nline2".to_string(),
1128 stderr: "error1\nerror2".to_string(),
1129 exit_code: 0,
1130 success: true,
1131 };
1132
1133 assert_eq!(output.stdout_lines(), vec!["line1", "line2"]);
1134 assert_eq!(output.stderr_lines(), vec!["error1", "error2"]);
1135 assert!(!output.stdout_is_empty());
1136 assert!(!output.stderr_is_empty());
1137
1138 let empty_output = CommandOutput {
1139 stdout: " ".to_string(),
1140 stderr: String::new(),
1141 exit_code: 0,
1142 success: true,
1143 };
1144
1145 assert!(empty_output.stdout_is_empty());
1146 assert!(empty_output.stderr_is_empty());
1147 }
1148
1149 #[test]
1150 fn test_compose_config_single_env_file() {
1151 let config = ComposeConfig::new().env_file("/path/to/.env");
1152 let args = config.build_global_args();
1153
1154 let env_file_count = args.iter().filter(|a| a.as_str() == "--env-file").count();
1155 assert_eq!(env_file_count, 1);
1156 assert!(args.contains(&"/path/to/.env".to_string()));
1157 }
1158
1159 #[test]
1160 fn test_compose_config_multiple_env_files() {
1161 let config = ComposeConfig::new()
1162 .env_file("/path/to/.env")
1163 .env_file("/path/to/.env.local")
1164 .env_file("/path/to/.env.production");
1165 let args = config.build_global_args();
1166
1167 let env_file_count = args.iter().filter(|a| a.as_str() == "--env-file").count();
1168 assert_eq!(env_file_count, 3);
1169 assert!(args.contains(&"/path/to/.env".to_string()));
1170 assert!(args.contains(&"/path/to/.env.local".to_string()));
1171 assert!(args.contains(&"/path/to/.env.production".to_string()));
1172 }
1173
1174 #[test]
1175 fn test_compose_config_no_env_file() {
1176 let config = ComposeConfig::new();
1177 let args = config.build_global_args();
1178
1179 assert!(!args.contains(&"--env-file".to_string()));
1180 }
1181
1182 #[cfg(feature = "compose")]
1187 #[test]
1188 fn test_compose_command_args_structure() {
1189 use crate::compose::ComposeUpCommand;
1190
1191 let cmd = ComposeUpCommand::new()
1192 .file("docker-compose.yml")
1193 .detach()
1194 .service("web");
1195
1196 let args = ComposeCommand::build_command_args(&cmd);
1197
1198 assert_eq!(args[0], "compose", "compose args must start with 'compose'");
1200
1201 assert!(
1204 !args.iter().any(|arg| arg == "docker"),
1205 "compose args should not contain 'docker': {args:?}"
1206 );
1207
1208 assert!(args.contains(&"up".to_string()), "must contain subcommand");
1210 assert!(args.contains(&"--file".to_string()), "must contain --file");
1211 assert!(
1212 args.contains(&"--detach".to_string()),
1213 "must contain --detach"
1214 );
1215 assert!(
1216 args.contains(&"web".to_string()),
1217 "must contain service name"
1218 );
1219 }
1220}