1use crate::error::{Error, Result};
8use async_trait::async_trait;
9use std::collections::HashMap;
10use std::ffi::OsStr;
11use std::process::Stdio;
12use tokio::process::Command as TokioCommand;
13
14pub mod bake;
16pub mod build;
17pub mod exec;
18pub mod images;
19pub mod info;
20pub mod login;
21pub mod logout;
22pub mod ps;
23pub mod pull;
24pub mod push;
25pub mod run;
26pub mod search;
27pub mod version;
28
29#[async_trait]
31pub trait DockerCommand {
32 type Output;
34
35 fn command_name(&self) -> &'static str;
37
38 fn build_args(&self) -> Vec<String>;
40
41 async fn execute(&self) -> Result<Self::Output>;
43
44 fn arg<S: AsRef<OsStr>>(&mut self, arg: S) -> &mut Self;
46
47 fn args<I, S>(&mut self, args: I) -> &mut Self
49 where
50 I: IntoIterator<Item = S>,
51 S: AsRef<OsStr>;
52
53 fn flag(&mut self, flag: &str) -> &mut Self;
55
56 fn option(&mut self, key: &str, value: &str) -> &mut Self;
58}
59
60#[derive(Debug, Clone)]
62pub struct CommandExecutor {
63 pub raw_args: Vec<String>,
65}
66
67impl CommandExecutor {
68 #[must_use]
70 pub fn new() -> Self {
71 Self {
72 raw_args: Vec::new(),
73 }
74 }
75
76 pub async fn execute_command(
81 &self,
82 command_name: &str,
83 args: Vec<String>,
84 ) -> Result<CommandOutput> {
85 let mut all_args = self.raw_args.clone();
87 all_args.extend(args);
88
89 all_args.insert(0, command_name.to_string());
91
92 let output = TokioCommand::new("docker")
93 .args(&all_args)
94 .stdout(Stdio::piped())
95 .stderr(Stdio::piped())
96 .output()
97 .await
98 .map_err(|e| Error::custom(format!("Failed to execute docker {command_name}: {e}")))?;
99
100 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
101 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
102 let success = output.status.success();
103 let exit_code = output.status.code().unwrap_or(-1);
104
105 if !success {
106 return Err(Error::command_failed(
107 format!("docker {}", all_args.join(" ")),
108 exit_code,
109 stdout,
110 stderr,
111 ));
112 }
113
114 Ok(CommandOutput {
115 stdout,
116 stderr,
117 exit_code,
118 success,
119 })
120 }
121
122 pub fn add_arg<S: AsRef<OsStr>>(&mut self, arg: S) {
124 self.raw_args
125 .push(arg.as_ref().to_string_lossy().to_string());
126 }
127
128 pub fn add_args<I, S>(&mut self, args: I)
130 where
131 I: IntoIterator<Item = S>,
132 S: AsRef<OsStr>,
133 {
134 for arg in args {
135 self.add_arg(arg);
136 }
137 }
138
139 pub fn add_flag(&mut self, flag: &str) {
141 let flag_arg = if flag.starts_with('-') {
142 flag.to_string()
143 } else if flag.len() == 1 {
144 format!("-{flag}")
145 } else {
146 format!("--{flag}")
147 };
148 self.raw_args.push(flag_arg);
149 }
150
151 pub fn add_option(&mut self, key: &str, value: &str) {
153 let key_arg = if key.starts_with('-') {
154 key.to_string()
155 } else if key.len() == 1 {
156 format!("-{key}")
157 } else {
158 format!("--{key}")
159 };
160 self.raw_args.push(key_arg);
161 self.raw_args.push(value.to_string());
162 }
163}
164
165impl Default for CommandExecutor {
166 fn default() -> Self {
167 Self::new()
168 }
169}
170
171#[derive(Debug, Clone)]
173pub struct CommandOutput {
174 pub stdout: String,
176 pub stderr: String,
178 pub exit_code: i32,
180 pub success: bool,
182}
183
184impl CommandOutput {
185 #[must_use]
187 pub fn stdout_lines(&self) -> Vec<&str> {
188 self.stdout.lines().collect()
189 }
190
191 #[must_use]
193 pub fn stderr_lines(&self) -> Vec<&str> {
194 self.stderr.lines().collect()
195 }
196
197 #[must_use]
199 pub fn stdout_is_empty(&self) -> bool {
200 self.stdout.trim().is_empty()
201 }
202
203 #[must_use]
205 pub fn stderr_is_empty(&self) -> bool {
206 self.stderr.trim().is_empty()
207 }
208}
209
210#[derive(Debug, Clone, Default)]
212pub struct EnvironmentBuilder {
213 vars: HashMap<String, String>,
214}
215
216impl EnvironmentBuilder {
217 #[must_use]
219 pub fn new() -> Self {
220 Self::default()
221 }
222
223 #[must_use]
225 pub fn var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
226 self.vars.insert(key.into(), value.into());
227 self
228 }
229
230 #[must_use]
232 pub fn vars(mut self, vars: HashMap<String, String>) -> Self {
233 self.vars.extend(vars);
234 self
235 }
236
237 #[must_use]
239 pub fn build_args(&self) -> Vec<String> {
240 let mut args = Vec::new();
241 for (key, value) in &self.vars {
242 args.push("--env".to_string());
243 args.push(format!("{key}={value}"));
244 }
245 args
246 }
247
248 #[must_use]
250 pub fn as_map(&self) -> &HashMap<String, String> {
251 &self.vars
252 }
253}
254
255#[derive(Debug, Clone, Default)]
257pub struct PortBuilder {
258 mappings: Vec<PortMapping>,
259}
260
261impl PortBuilder {
262 #[must_use]
264 pub fn new() -> Self {
265 Self::default()
266 }
267
268 #[must_use]
270 pub fn port(mut self, host_port: u16, container_port: u16) -> Self {
271 self.mappings.push(PortMapping {
272 host_port: Some(host_port),
273 container_port,
274 protocol: Protocol::Tcp,
275 host_ip: None,
276 });
277 self
278 }
279
280 #[must_use]
282 pub fn port_with_protocol(
283 mut self,
284 host_port: u16,
285 container_port: u16,
286 protocol: Protocol,
287 ) -> Self {
288 self.mappings.push(PortMapping {
289 host_port: Some(host_port),
290 container_port,
291 protocol,
292 host_ip: None,
293 });
294 self
295 }
296
297 #[must_use]
299 pub fn dynamic_port(mut self, container_port: u16) -> Self {
300 self.mappings.push(PortMapping {
301 host_port: None,
302 container_port,
303 protocol: Protocol::Tcp,
304 host_ip: None,
305 });
306 self
307 }
308
309 #[must_use]
311 pub fn build_args(&self) -> Vec<String> {
312 let mut args = Vec::new();
313 for mapping in &self.mappings {
314 args.push("--publish".to_string());
315 args.push(mapping.to_string());
316 }
317 args
318 }
319
320 #[must_use]
322 pub fn mappings(&self) -> &[PortMapping] {
323 &self.mappings
324 }
325}
326
327#[derive(Debug, Clone)]
329pub struct PortMapping {
330 pub host_port: Option<u16>,
332 pub container_port: u16,
334 pub protocol: Protocol,
336 pub host_ip: Option<std::net::IpAddr>,
338}
339
340impl std::fmt::Display for PortMapping {
341 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342 let protocol_suffix = match self.protocol {
343 Protocol::Tcp => "",
344 Protocol::Udp => "/udp",
345 };
346
347 if let Some(host_port) = self.host_port {
348 if let Some(host_ip) = self.host_ip {
349 write!(
350 f,
351 "{}:{}:{}{}",
352 host_ip, host_port, self.container_port, protocol_suffix
353 )
354 } else {
355 write!(
356 f,
357 "{}:{}{}",
358 host_port, self.container_port, protocol_suffix
359 )
360 }
361 } else {
362 write!(f, "{}{}", self.container_port, protocol_suffix)
363 }
364 }
365}
366
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
369pub enum Protocol {
370 Tcp,
372 Udp,
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 #[test]
381 fn test_command_executor_args() {
382 let mut executor = CommandExecutor::new();
383 executor.add_arg("test");
384 executor.add_args(vec!["arg1", "arg2"]);
385 executor.add_flag("detach");
386 executor.add_flag("d");
387 executor.add_option("name", "test-container");
388
389 assert_eq!(
390 executor.raw_args,
391 vec![
392 "test",
393 "arg1",
394 "arg2",
395 "--detach",
396 "-d",
397 "--name",
398 "test-container"
399 ]
400 );
401 }
402
403 #[test]
404 fn test_environment_builder() {
405 let env = EnvironmentBuilder::new()
406 .var("KEY1", "value1")
407 .var("KEY2", "value2");
408
409 let args = env.build_args();
410 assert!(args.contains(&"--env".to_string()));
411 assert!(args.contains(&"KEY1=value1".to_string()));
412 assert!(args.contains(&"KEY2=value2".to_string()));
413 }
414
415 #[test]
416 fn test_port_builder() {
417 let ports = PortBuilder::new()
418 .port(8080, 80)
419 .dynamic_port(443)
420 .port_with_protocol(8081, 81, Protocol::Udp);
421
422 let args = ports.build_args();
423 assert!(args.contains(&"--publish".to_string()));
424 assert!(args.contains(&"8080:80".to_string()));
425 assert!(args.contains(&"443".to_string()));
426 assert!(args.contains(&"8081:81/udp".to_string()));
427 }
428
429 #[test]
430 fn test_port_mapping_display() {
431 let tcp_mapping = PortMapping {
432 host_port: Some(8080),
433 container_port: 80,
434 protocol: Protocol::Tcp,
435 host_ip: None,
436 };
437 assert_eq!(tcp_mapping.to_string(), "8080:80");
438
439 let udp_mapping = PortMapping {
440 host_port: Some(8081),
441 container_port: 81,
442 protocol: Protocol::Udp,
443 host_ip: None,
444 };
445 assert_eq!(udp_mapping.to_string(), "8081:81/udp");
446
447 let dynamic_mapping = PortMapping {
448 host_port: None,
449 container_port: 443,
450 protocol: Protocol::Tcp,
451 host_ip: None,
452 };
453 assert_eq!(dynamic_mapping.to_string(), "443");
454 }
455
456 #[test]
457 fn test_command_output_helpers() {
458 let output = CommandOutput {
459 stdout: "line1\nline2".to_string(),
460 stderr: "error1\nerror2".to_string(),
461 exit_code: 0,
462 success: true,
463 };
464
465 assert_eq!(output.stdout_lines(), vec!["line1", "line2"]);
466 assert_eq!(output.stderr_lines(), vec!["error1", "error2"]);
467 assert!(!output.stdout_is_empty());
468 assert!(!output.stderr_is_empty());
469
470 let empty_output = CommandOutput {
471 stdout: " ".to_string(),
472 stderr: String::new(),
473 exit_code: 0,
474 success: true,
475 };
476
477 assert!(empty_output.stdout_is_empty());
478 assert!(empty_output.stderr_is_empty());
479 }
480}