docker_wrapper/command/
ps.rs

1//! Docker ps command implementation.
2//!
3//! This module provides a comprehensive implementation of the `docker ps` command
4//! with support for all native options and an extensible architecture for any additional options.
5
6use super::{CommandExecutor, DockerCommand};
7use crate::error::Result;
8use async_trait::async_trait;
9use serde::{Deserialize, Serialize};
10
11use std::ffi::OsStr;
12
13/// Docker ps command builder with fluent API
14#[derive(Debug, Clone)]
15#[allow(clippy::struct_excessive_bools)]
16pub struct PsCommand {
17    /// Command executor for extensibility
18    executor: CommandExecutor,
19    /// Show all containers (default shows just running)
20    all: bool,
21    /// Filter output based on conditions provided
22    filters: Vec<String>,
23    /// Format output using a custom template
24    format: Option<String>,
25    /// Show n last created containers (includes all states)
26    last: Option<i32>,
27    /// Show the latest created container (includes all states)
28    latest: bool,
29    /// Don't truncate output
30    no_trunc: bool,
31    /// Only display container IDs
32    quiet: bool,
33    /// Display total file sizes
34    size: bool,
35}
36
37/// Container information returned by docker ps
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct ContainerInfo {
40    /// Container ID
41    pub id: String,
42    /// Container image
43    pub image: String,
44    /// Command being run
45    pub command: String,
46    /// Creation time
47    pub created: String,
48    /// Container status
49    pub status: String,
50    /// Port mappings
51    pub ports: String,
52    /// Container names
53    pub names: String,
54}
55
56/// Output format for ps command
57#[derive(Debug, Clone)]
58pub enum PsFormat {
59    /// Default table format
60    Table,
61    /// JSON format
62    Json,
63    /// Custom Go template
64    Template(String),
65    /// Raw output (when using quiet mode)
66    Raw,
67}
68
69/// Output from docker ps command
70#[derive(Debug, Clone)]
71pub struct PsOutput {
72    /// The raw stdout from the command
73    pub stdout: String,
74    /// The raw stderr from the command
75    pub stderr: String,
76    /// Exit code from the command
77    pub exit_code: i32,
78    /// Parsed container information (when possible)
79    pub containers: Vec<ContainerInfo>,
80}
81
82impl PsOutput {
83    /// Check if the command executed successfully
84    #[must_use]
85    pub fn success(&self) -> bool {
86        self.exit_code == 0
87    }
88
89    /// Get combined output (stdout + stderr)
90    #[must_use]
91    pub fn combined_output(&self) -> String {
92        if self.stderr.is_empty() {
93            self.stdout.clone()
94        } else if self.stdout.is_empty() {
95            self.stderr.clone()
96        } else {
97            format!("{}\n{}", self.stdout, self.stderr)
98        }
99    }
100
101    /// Check if stdout is empty (ignoring whitespace)
102    #[must_use]
103    pub fn stdout_is_empty(&self) -> bool {
104        self.stdout.trim().is_empty()
105    }
106
107    /// Check if stderr is empty (ignoring whitespace)
108    #[must_use]
109    pub fn stderr_is_empty(&self) -> bool {
110        self.stderr.trim().is_empty()
111    }
112
113    /// Get container IDs only (useful when using quiet mode)
114    #[must_use]
115    pub fn container_ids(&self) -> Vec<String> {
116        self.stdout
117            .lines()
118            .map(|line| line.trim().to_string())
119            .filter(|line| !line.is_empty())
120            .collect()
121    }
122
123    /// Get number of containers found
124    #[must_use]
125    pub fn container_count(&self) -> usize {
126        self.containers.len()
127    }
128}
129
130impl PsCommand {
131    /// Create a new ps command
132    ///
133    /// # Examples
134    ///
135    /// ```
136    /// use docker_wrapper::PsCommand;
137    ///
138    /// let ps_cmd = PsCommand::new();
139    /// ```
140    #[must_use]
141    pub fn new() -> Self {
142        Self {
143            executor: CommandExecutor::new(),
144            all: false,
145            filters: Vec::new(),
146            format: None,
147            last: None,
148            latest: false,
149            no_trunc: false,
150            quiet: false,
151            size: false,
152        }
153    }
154
155    /// Show all containers (default shows just running)
156    ///
157    /// # Examples
158    ///
159    /// ```
160    /// use docker_wrapper::PsCommand;
161    ///
162    /// let ps_cmd = PsCommand::new().all();
163    /// ```
164    #[must_use]
165    pub fn all(mut self) -> Self {
166        self.all = true;
167        self
168    }
169
170    /// Filter output based on conditions provided
171    ///
172    /// # Examples
173    ///
174    /// ```
175    /// use docker_wrapper::PsCommand;
176    ///
177    /// let ps_cmd = PsCommand::new()
178    ///     .filter("status=running")
179    ///     .filter("name=my-container");
180    /// ```
181    #[must_use]
182    pub fn filter(mut self, filter: impl Into<String>) -> Self {
183        self.filters.push(filter.into());
184        self
185    }
186
187    /// Add multiple filters
188    ///
189    /// # Examples
190    ///
191    /// ```
192    /// use docker_wrapper::PsCommand;
193    ///
194    /// let filters = vec!["status=running".to_string(), "name=web".to_string()];
195    /// let ps_cmd = PsCommand::new().filters(filters);
196    /// ```
197    #[must_use]
198    pub fn filters(mut self, filters: Vec<String>) -> Self {
199        self.filters.extend(filters);
200        self
201    }
202
203    /// Format output using table format
204    ///
205    /// # Examples
206    ///
207    /// ```
208    /// use docker_wrapper::PsCommand;
209    ///
210    /// let ps_cmd = PsCommand::new().format_table();
211    /// ```
212    #[must_use]
213    pub fn format_table(mut self) -> Self {
214        self.format = Some("table".to_string());
215        self
216    }
217
218    /// Format output using JSON format
219    ///
220    /// # Examples
221    ///
222    /// ```
223    /// use docker_wrapper::PsCommand;
224    ///
225    /// let ps_cmd = PsCommand::new().format_json();
226    /// ```
227    #[must_use]
228    pub fn format_json(mut self) -> Self {
229        self.format = Some("json".to_string());
230        self
231    }
232
233    /// Format output using a custom Go template
234    ///
235    /// # Examples
236    ///
237    /// ```
238    /// use docker_wrapper::PsCommand;
239    ///
240    /// let ps_cmd = PsCommand::new()
241    ///     .format_template("table {{.ID}}\\t{{.Names}}\\t{{.Status}}");
242    /// ```
243    #[must_use]
244    pub fn format_template(mut self, template: impl Into<String>) -> Self {
245        self.format = Some(template.into());
246        self
247    }
248
249    /// Show n last created containers (includes all states)
250    ///
251    /// # Examples
252    ///
253    /// ```
254    /// use docker_wrapper::PsCommand;
255    ///
256    /// let ps_cmd = PsCommand::new().last(5);
257    /// ```
258    #[must_use]
259    pub fn last(mut self, n: i32) -> Self {
260        self.last = Some(n);
261        self
262    }
263
264    /// Show the latest created container (includes all states)
265    ///
266    /// # Examples
267    ///
268    /// ```
269    /// use docker_wrapper::PsCommand;
270    ///
271    /// let ps_cmd = PsCommand::new().latest();
272    /// ```
273    #[must_use]
274    pub fn latest(mut self) -> Self {
275        self.latest = true;
276        self
277    }
278
279    /// Don't truncate output
280    ///
281    /// # Examples
282    ///
283    /// ```
284    /// use docker_wrapper::PsCommand;
285    ///
286    /// let ps_cmd = PsCommand::new().no_trunc();
287    /// ```
288    #[must_use]
289    pub fn no_trunc(mut self) -> Self {
290        self.no_trunc = true;
291        self
292    }
293
294    /// Only display container IDs
295    ///
296    /// # Examples
297    ///
298    /// ```
299    /// use docker_wrapper::PsCommand;
300    ///
301    /// let ps_cmd = PsCommand::new().quiet();
302    /// ```
303    #[must_use]
304    pub fn quiet(mut self) -> Self {
305        self.quiet = true;
306        self
307    }
308
309    /// Display total file sizes
310    ///
311    /// # Examples
312    ///
313    /// ```
314    /// use docker_wrapper::PsCommand;
315    ///
316    /// let ps_cmd = PsCommand::new().size();
317    /// ```
318    #[must_use]
319    pub fn size(mut self) -> Self {
320        self.size = true;
321        self
322    }
323
324    /// Parse container info from table output (best effort)
325    fn parse_table_output(output: &str) -> Vec<ContainerInfo> {
326        let lines: Vec<&str> = output.lines().collect();
327        if lines.len() < 2 {
328            return Vec::new(); // No header or data
329        }
330
331        let mut containers = Vec::new();
332
333        // Skip header line
334        for line in lines.iter().skip(1) {
335            if line.trim().is_empty() {
336                continue;
337            }
338
339            // Basic parsing - this is best effort since docker ps output can vary
340            let parts: Vec<&str> = line.split_whitespace().collect();
341            if parts.len() >= 6 {
342                containers.push(ContainerInfo {
343                    id: parts[0].to_string(),
344                    image: parts[1].to_string(),
345                    command: (*parts.get(2).unwrap_or(&"")).to_string(),
346                    created: (*parts.get(3).unwrap_or(&"")).to_string(),
347                    status: (*parts.get(4).unwrap_or(&"")).to_string(),
348                    ports: (*parts.get(5).unwrap_or(&"")).to_string(),
349                    names: (*parts.get(6).unwrap_or(&"")).to_string(),
350                });
351            }
352        }
353
354        containers
355    }
356
357    /// Parse container info from JSON output
358    fn parse_json_output(output: &str) -> Vec<ContainerInfo> {
359        // Try to parse as JSON array
360        if let Ok(containers) = serde_json::from_str::<Vec<serde_json::Value>>(output) {
361            return containers
362                .into_iter()
363                .filter_map(|container| {
364                    Some(ContainerInfo {
365                        id: container.get("ID")?.as_str()?.to_string(),
366                        image: container.get("Image")?.as_str()?.to_string(),
367                        command: container.get("Command")?.as_str()?.to_string(),
368                        created: container.get("CreatedAt")?.as_str()?.to_string(),
369                        status: container.get("Status")?.as_str()?.to_string(),
370                        ports: container.get("Ports")?.as_str().unwrap_or("").to_string(),
371                        names: container.get("Names")?.as_str()?.to_string(),
372                    })
373                })
374                .collect();
375        }
376
377        Vec::new()
378    }
379}
380
381impl Default for PsCommand {
382    fn default() -> Self {
383        Self::new()
384    }
385}
386
387#[async_trait]
388impl DockerCommand for PsCommand {
389    type Output = PsOutput;
390
391    fn command_name(&self) -> &'static str {
392        "ps"
393    }
394
395    fn build_args(&self) -> Vec<String> {
396        let mut args = Vec::new();
397
398        if self.all {
399            args.push("--all".to_string());
400        }
401
402        for filter in &self.filters {
403            args.push("--filter".to_string());
404            args.push(filter.clone());
405        }
406
407        if let Some(ref format) = self.format {
408            args.push("--format".to_string());
409            args.push(format.clone());
410        }
411
412        if let Some(last) = self.last {
413            args.push("--last".to_string());
414            args.push(last.to_string());
415        }
416
417        if self.latest {
418            args.push("--latest".to_string());
419        }
420
421        if self.no_trunc {
422            args.push("--no-trunc".to_string());
423        }
424
425        if self.quiet {
426            args.push("--quiet".to_string());
427        }
428
429        if self.size {
430            args.push("--size".to_string());
431        }
432
433        // Add any additional raw arguments
434        args.extend(self.executor.raw_args.clone());
435
436        args
437    }
438
439    async fn execute(&self) -> Result<Self::Output> {
440        let args = self.build_args();
441        let output = self
442            .executor
443            .execute_command(self.command_name(), args)
444            .await?;
445
446        // Parse containers based on format
447        let containers = if self.quiet {
448            // In quiet mode, we just get container IDs
449            Vec::new()
450        } else if let Some(ref format) = self.format {
451            if format == "json" {
452                Self::parse_json_output(&output.stdout)
453            } else {
454                Self::parse_table_output(&output.stdout)
455            }
456        } else {
457            // Default table format
458            Self::parse_table_output(&output.stdout)
459        };
460
461        Ok(PsOutput {
462            stdout: output.stdout,
463            stderr: output.stderr,
464            exit_code: output.exit_code,
465            containers,
466        })
467    }
468
469    fn arg<S: AsRef<OsStr>>(&mut self, arg: S) -> &mut Self {
470        self.executor.add_arg(arg);
471        self
472    }
473
474    fn args<I, S>(&mut self, args: I) -> &mut Self
475    where
476        I: IntoIterator<Item = S>,
477        S: AsRef<OsStr>,
478    {
479        self.executor.add_args(args);
480        self
481    }
482
483    fn flag(&mut self, flag: &str) -> &mut Self {
484        self.executor.add_flag(flag);
485        self
486    }
487
488    fn option(&mut self, key: &str, value: &str) -> &mut Self {
489        self.executor.add_option(key, value);
490        self
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn test_ps_command_builder() {
500        let cmd = PsCommand::new()
501            .all()
502            .filter("status=running")
503            .filter("name=web")
504            .format_json()
505            .no_trunc()
506            .size();
507
508        let args = cmd.build_args();
509
510        assert!(args.contains(&"--all".to_string()));
511        assert!(args.contains(&"--filter".to_string()));
512        assert!(args.contains(&"status=running".to_string()));
513        assert!(args.contains(&"name=web".to_string()));
514        assert!(args.contains(&"--format".to_string()));
515        assert!(args.contains(&"json".to_string()));
516        assert!(args.contains(&"--no-trunc".to_string()));
517        assert!(args.contains(&"--size".to_string()));
518    }
519
520    #[test]
521    fn test_ps_command_quiet() {
522        let cmd = PsCommand::new().quiet().all();
523
524        let args = cmd.build_args();
525
526        assert!(args.contains(&"--quiet".to_string()));
527        assert!(args.contains(&"--all".to_string()));
528    }
529
530    #[test]
531    fn test_ps_command_latest() {
532        let cmd = PsCommand::new().latest();
533
534        let args = cmd.build_args();
535
536        assert!(args.contains(&"--latest".to_string()));
537    }
538
539    #[test]
540    fn test_ps_command_last() {
541        let cmd = PsCommand::new().last(5);
542
543        let args = cmd.build_args();
544
545        assert!(args.contains(&"--last".to_string()));
546        assert!(args.contains(&"5".to_string()));
547    }
548
549    #[test]
550    fn test_ps_command_multiple_filters() {
551        let filters = vec!["status=running".to_string(), "name=web".to_string()];
552        let cmd = PsCommand::new().filters(filters);
553
554        let args = cmd.build_args();
555
556        // Should have two --filter entries
557        let filter_count = args.iter().filter(|&arg| arg == "--filter").count();
558        assert_eq!(filter_count, 2);
559        assert!(args.contains(&"status=running".to_string()));
560        assert!(args.contains(&"name=web".to_string()));
561    }
562
563    #[test]
564    fn test_ps_command_format_variants() {
565        let cmd1 = PsCommand::new().format_table();
566        assert!(cmd1.build_args().contains(&"table".to_string()));
567
568        let cmd2 = PsCommand::new().format_json();
569        assert!(cmd2.build_args().contains(&"json".to_string()));
570
571        let cmd3 = PsCommand::new().format_template("{{.ID}}");
572        assert!(cmd3.build_args().contains(&"{{.ID}}".to_string()));
573    }
574
575    #[test]
576    fn test_ps_output_helpers() {
577        let output = PsOutput {
578            stdout: "container1\ncontainer2\n".to_string(),
579            stderr: String::new(),
580            exit_code: 0,
581            containers: Vec::new(),
582        };
583
584        assert!(output.success());
585        assert!(!output.stdout_is_empty());
586        assert!(output.stderr_is_empty());
587
588        let ids = output.container_ids();
589        assert_eq!(ids.len(), 2);
590        assert_eq!(ids[0], "container1");
591        assert_eq!(ids[1], "container2");
592    }
593
594    #[test]
595    fn test_ps_command_extensibility() {
596        let mut cmd = PsCommand::new();
597
598        // Test extensibility methods
599        cmd.flag("--some-flag");
600        cmd.option("--some-option", "value");
601        cmd.arg("extra-arg");
602
603        let args = cmd.build_args();
604
605        assert!(args.contains(&"--some-flag".to_string()));
606        assert!(args.contains(&"--some-option".to_string()));
607        assert!(args.contains(&"value".to_string()));
608        assert!(args.contains(&"extra-arg".to_string()));
609    }
610
611    #[test]
612    fn test_container_info_creation() {
613        let info = ContainerInfo {
614            id: "abc123".to_string(),
615            image: "nginx:latest".to_string(),
616            command: "\"/docker-entrypoint.sh nginx -g 'daemon off;'\"".to_string(),
617            created: "2 minutes ago".to_string(),
618            status: "Up 2 minutes".to_string(),
619            ports: "0.0.0.0:8080->80/tcp".to_string(),
620            names: "web-server".to_string(),
621        };
622
623        assert_eq!(info.id, "abc123");
624        assert_eq!(info.image, "nginx:latest");
625        assert_eq!(info.names, "web-server");
626    }
627}