docker_wrapper/compose/
ps.rs

1//! Docker Compose ps command implementation.
2
3use super::{execute_compose_command, ComposeCommand, ComposeConfig, ComposeOutput};
4use crate::error::Result;
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7
8/// Docker Compose ps command builder
9#[derive(Debug, Clone)]
10pub struct ComposePsCommand {
11    /// Base compose configuration
12    config: ComposeConfig,
13    /// Services to list (empty for all)
14    services: Vec<String>,
15    /// Show all containers (including stopped)
16    all: bool,
17    /// Only display container IDs
18    quiet: bool,
19    /// Show services
20    show_services: bool,
21    /// Filter containers
22    filter: Vec<String>,
23    /// Output format
24    format: Option<String>,
25    /// Only show running containers
26    status: Option<Vec<ContainerStatus>>,
27}
28
29/// Container status filter
30#[derive(Debug, Clone, Copy)]
31pub enum ContainerStatus {
32    /// Paused containers
33    Paused,
34    /// Restarting containers
35    Restarting,
36    /// Running containers
37    Running,
38    /// Stopped containers
39    Stopped,
40}
41
42impl std::fmt::Display for ContainerStatus {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Self::Paused => write!(f, "paused"),
46            Self::Restarting => write!(f, "restarting"),
47            Self::Running => write!(f, "running"),
48            Self::Stopped => write!(f, "stopped"),
49        }
50    }
51}
52
53impl ComposePsCommand {
54    /// Create a new compose ps command
55    #[must_use]
56    pub fn new() -> Self {
57        Self {
58            config: ComposeConfig::new(),
59            services: Vec::new(),
60            all: false,
61            quiet: false,
62            show_services: false,
63            filter: Vec::new(),
64            format: None,
65            status: None,
66        }
67    }
68
69    /// Create with a specific compose configuration
70    #[must_use]
71    pub fn with_config(config: ComposeConfig) -> Self {
72        Self {
73            config,
74            ..Self::new()
75        }
76    }
77
78    /// Add a service to list
79    #[must_use]
80    pub fn service(mut self, service: impl Into<String>) -> Self {
81        self.services.push(service.into());
82        self
83    }
84
85    /// Show all containers (default shows only running)
86    #[must_use]
87    pub fn all(mut self) -> Self {
88        self.all = true;
89        self
90    }
91
92    /// Only display container IDs
93    #[must_use]
94    pub fn quiet(mut self) -> Self {
95        self.quiet = true;
96        self
97    }
98
99    /// Display services
100    #[must_use]
101    pub fn services(mut self) -> Self {
102        self.show_services = true;
103        self
104    }
105
106    /// Add a filter
107    #[must_use]
108    pub fn filter(mut self, filter: impl Into<String>) -> Self {
109        self.filter.push(filter.into());
110        self
111    }
112
113    /// Set output format
114    #[must_use]
115    pub fn format(mut self, format: impl Into<String>) -> Self {
116        self.format = Some(format.into());
117        self
118    }
119
120    /// Filter by status
121    #[must_use]
122    pub fn status(mut self, status: ContainerStatus) -> Self {
123        self.status.get_or_insert_with(Vec::new).push(status);
124        self
125    }
126
127    /// Use JSON output format
128    #[must_use]
129    pub fn json(mut self) -> Self {
130        self.format = Some("json".to_string());
131        self
132    }
133
134    /// Set compose file
135    #[must_use]
136    pub fn file(mut self, path: impl Into<std::path::PathBuf>) -> Self {
137        self.config = self.config.file(path);
138        self
139    }
140
141    /// Set project name
142    #[must_use]
143    pub fn project_name(mut self, name: impl Into<String>) -> Self {
144        self.config = self.config.project_name(name);
145        self
146    }
147
148    /// Execute the compose ps command
149    ///
150    /// # Errors
151    /// Returns an error if:
152    /// - Docker Compose is not installed
153    /// - Compose file is not found
154    /// - Service lookup fails
155    pub async fn run(&self) -> Result<ComposePsResult> {
156        let output = self.execute().await?;
157
158        // Parse JSON output if format is json
159        let containers = if self.format.as_deref() == Some("json") {
160            Self::parse_json_output(&output.stdout)
161        } else {
162            Vec::new()
163        };
164
165        Ok(ComposePsResult { output, containers })
166    }
167
168    /// Parse JSON output into container info
169    fn parse_json_output(stdout: &str) -> Vec<ComposeContainerInfo> {
170        stdout
171            .lines()
172            .filter(|line| !line.trim().is_empty())
173            .filter_map(|line| serde_json::from_str(line).ok())
174            .collect()
175    }
176}
177
178impl Default for ComposePsCommand {
179    fn default() -> Self {
180        Self::new()
181    }
182}
183
184#[async_trait]
185impl ComposeCommand for ComposePsCommand {
186    type Output = ComposeOutput;
187
188    fn subcommand(&self) -> &'static str {
189        "ps"
190    }
191
192    fn build_args(&self) -> Vec<String> {
193        let mut args = Vec::new();
194
195        if self.all {
196            args.push("--all".to_string());
197        }
198
199        if self.quiet {
200            args.push("--quiet".to_string());
201        }
202
203        if self.show_services {
204            args.push("--services".to_string());
205        }
206
207        for filter in &self.filter {
208            args.push("--filter".to_string());
209            args.push(filter.clone());
210        }
211
212        if let Some(ref format) = self.format {
213            args.push("--format".to_string());
214            args.push(format.clone());
215        }
216
217        if let Some(ref statuses) = self.status {
218            for status in statuses {
219                args.push("--status".to_string());
220                args.push(status.to_string());
221            }
222        }
223
224        // Add service names at the end
225        args.extend(self.services.clone());
226
227        args
228    }
229
230    async fn execute(&self) -> Result<Self::Output> {
231        execute_compose_command(&self.config, self.subcommand(), self.build_args()).await
232    }
233
234    fn config(&self) -> &ComposeConfig {
235        &self.config
236    }
237}
238
239/// Container information from compose ps
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct ComposeContainerInfo {
242    /// Container ID
243    #[serde(rename = "ID")]
244    pub id: String,
245    /// Container name
246    #[serde(rename = "Name")]
247    pub name: String,
248    /// Service name
249    #[serde(rename = "Service")]
250    pub service: String,
251    /// Container state
252    #[serde(rename = "State")]
253    pub state: String,
254    /// Health status
255    #[serde(rename = "Health")]
256    pub health: Option<String>,
257    /// Exit code
258    #[serde(rename = "ExitCode")]
259    pub exit_code: Option<i32>,
260    /// Published ports
261    #[serde(rename = "Publishers")]
262    pub publishers: Option<Vec<PortPublisher>>,
263}
264
265/// Port publishing information
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct PortPublisher {
268    /// Target port
269    #[serde(rename = "TargetPort")]
270    pub target_port: u16,
271    /// Published port
272    #[serde(rename = "PublishedPort")]
273    pub published_port: Option<u16>,
274    /// Protocol
275    #[serde(rename = "Protocol")]
276    pub protocol: String,
277}
278
279/// Result from compose ps command
280#[derive(Debug, Clone)]
281pub struct ComposePsResult {
282    /// Raw command output
283    pub output: ComposeOutput,
284    /// Parsed container information (if JSON format)
285    pub containers: Vec<ComposeContainerInfo>,
286}
287
288impl ComposePsResult {
289    /// Check if the command was successful
290    #[must_use]
291    pub fn success(&self) -> bool {
292        self.output.success
293    }
294
295    /// Get container information
296    #[must_use]
297    pub fn containers(&self) -> &[ComposeContainerInfo] {
298        &self.containers
299    }
300
301    /// Get container IDs from output
302    #[must_use]
303    pub fn container_ids(&self) -> Vec<String> {
304        if self.containers.is_empty() {
305            // Parse from text output if not JSON
306            self.output
307                .stdout_lines()
308                .into_iter()
309                .skip(1) // Skip header
310                .filter_map(|line| line.split_whitespace().next())
311                .map(String::from)
312                .collect()
313        } else {
314            self.containers.iter().map(|c| c.id.clone()).collect()
315        }
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn test_compose_ps_basic() {
325        let cmd = ComposePsCommand::new();
326        let args = cmd.build_args();
327        assert!(args.is_empty());
328    }
329
330    #[test]
331    fn test_compose_ps_all() {
332        let cmd = ComposePsCommand::new().all();
333        let args = cmd.build_args();
334        assert_eq!(args, vec!["--all"]);
335    }
336
337    #[test]
338    fn test_compose_ps_with_format() {
339        let cmd = ComposePsCommand::new().format("json").all();
340        let args = cmd.build_args();
341        assert_eq!(args, vec!["--all", "--format", "json"]);
342    }
343
344    #[test]
345    fn test_compose_ps_with_filters() {
346        let cmd = ComposePsCommand::new()
347            .filter("status=running")
348            .quiet()
349            .service("web");
350        let args = cmd.build_args();
351        assert_eq!(args, vec!["--quiet", "--filter", "status=running", "web"]);
352    }
353
354    #[test]
355    fn test_container_status_display() {
356        assert_eq!(ContainerStatus::Running.to_string(), "running");
357        assert_eq!(ContainerStatus::Stopped.to_string(), "stopped");
358        assert_eq!(ContainerStatus::Paused.to_string(), "paused");
359        assert_eq!(ContainerStatus::Restarting.to_string(), "restarting");
360    }
361}