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