docker_wrapper/command/
stats.rs

1//! Docker stats command implementation.
2//!
3//! This module provides the `docker stats` command for displaying real-time
4//! resource usage statistics of containers.
5
6use super::{CommandExecutor, CommandOutput, DockerCommand};
7use crate::error::Result;
8use async_trait::async_trait;
9use serde::{Deserialize, Serialize};
10
11/// Docker stats command builder
12///
13/// Display a live stream of container(s) resource usage statistics.
14///
15/// # Example
16///
17/// ```no_run
18/// use docker_wrapper::StatsCommand;
19///
20/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
21/// // Get stats for all running containers
22/// let stats = StatsCommand::new()
23///     .run()
24///     .await?;
25///
26/// // Get stats for specific containers
27/// let stats = StatsCommand::new()
28///     .container("my-container")
29///     .container("another-container")
30///     .no_stream()
31///     .run()
32///     .await?;
33///
34/// // Parse as JSON for programmatic use
35/// let json_stats = StatsCommand::new()
36///     .format("json")
37///     .no_stream()
38///     .run()
39///     .await?;
40/// # Ok(())
41/// # }
42/// ```
43#[derive(Debug, Clone)]
44pub struct StatsCommand {
45    /// Container names or IDs to monitor (empty = all running containers)
46    containers: Vec<String>,
47    /// Show all containers (default shows only running)
48    all: bool,
49    /// Pretty print images (default true)
50    format: Option<String>,
51    /// Disable streaming stats and only pull the first result
52    no_stream: bool,
53    /// Only display numeric IDs
54    no_trunc: bool,
55    /// Command executor
56    pub executor: CommandExecutor,
57}
58
59impl StatsCommand {
60    /// Create a new stats command
61    ///
62    /// # Example
63    ///
64    /// ```
65    /// use docker_wrapper::StatsCommand;
66    ///
67    /// let cmd = StatsCommand::new();
68    /// ```
69    #[must_use]
70    pub fn new() -> Self {
71        Self {
72            containers: Vec::new(),
73            all: false,
74            format: None,
75            no_stream: false,
76            no_trunc: false,
77            executor: CommandExecutor::new(),
78        }
79    }
80
81    /// Add a container to monitor
82    ///
83    /// # Example
84    ///
85    /// ```
86    /// use docker_wrapper::StatsCommand;
87    ///
88    /// let cmd = StatsCommand::new()
89    ///     .container("web-server")
90    ///     .container("database");
91    /// ```
92    #[must_use]
93    pub fn container(mut self, container: impl Into<String>) -> Self {
94        self.containers.push(container.into());
95        self
96    }
97
98    /// Add multiple containers to monitor
99    #[must_use]
100    pub fn containers(mut self, containers: Vec<impl Into<String>>) -> Self {
101        self.containers
102            .extend(containers.into_iter().map(Into::into));
103        self
104    }
105
106    /// Show stats for all containers (default shows only running)
107    ///
108    /// # Example
109    ///
110    /// ```
111    /// use docker_wrapper::StatsCommand;
112    ///
113    /// let cmd = StatsCommand::new().all();
114    /// ```
115    #[must_use]
116    pub fn all(mut self) -> Self {
117        self.all = true;
118        self
119    }
120
121    /// Set output format
122    ///
123    /// # Example
124    ///
125    /// ```
126    /// use docker_wrapper::StatsCommand;
127    ///
128    /// // JSON format for programmatic parsing
129    /// let cmd = StatsCommand::new().format("json");
130    ///
131    /// // Table format (default)
132    /// let cmd = StatsCommand::new().format("table");
133    /// ```
134    #[must_use]
135    pub fn format(mut self, format: impl Into<String>) -> Self {
136        self.format = Some(format.into());
137        self
138    }
139
140    /// Disable streaming stats and only pull the first result
141    ///
142    /// # Example
143    ///
144    /// ```
145    /// use docker_wrapper::StatsCommand;
146    ///
147    /// let cmd = StatsCommand::new().no_stream();
148    /// ```
149    #[must_use]
150    pub fn no_stream(mut self) -> Self {
151        self.no_stream = true;
152        self
153    }
154
155    /// Do not truncate output (show full container IDs)
156    #[must_use]
157    pub fn no_trunc(mut self) -> Self {
158        self.no_trunc = true;
159        self
160    }
161
162    /// Execute the stats command
163    ///
164    /// # Errors
165    /// Returns an error if:
166    /// - The Docker daemon is not running
167    /// - Any specified container doesn't exist
168    /// - No containers are running (when no specific containers are specified)
169    ///
170    /// # Example
171    ///
172    /// ```no_run
173    /// use docker_wrapper::StatsCommand;
174    ///
175    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
176    /// let result = StatsCommand::new()
177    ///     .container("my-container")
178    ///     .no_stream()
179    ///     .run()
180    ///     .await?;
181    ///
182    /// if result.success() {
183    ///     println!("Container stats:\n{}", result.output.stdout);
184    /// }
185    /// # Ok(())
186    /// # }
187    /// ```
188    pub async fn run(&self) -> Result<StatsResult> {
189        let output = self.execute().await?;
190
191        // Parse stats if JSON format was used
192        let parsed_stats = if self.format.as_deref() == Some("json") {
193            Self::parse_json_stats(&output.stdout)
194        } else {
195            Vec::new()
196        };
197
198        Ok(StatsResult {
199            output,
200            containers: self.containers.clone(),
201            parsed_stats,
202        })
203    }
204
205    /// Parse JSON stats output into structured data
206    fn parse_json_stats(stdout: &str) -> Vec<ContainerStats> {
207        let mut stats = Vec::new();
208
209        // Docker stats JSON output can be either a single object or multiple lines of JSON
210        for line in stdout.lines() {
211            let line = line.trim();
212            if line.is_empty() {
213                continue;
214            }
215
216            if let Ok(stat) = serde_json::from_str::<ContainerStats>(line) {
217                stats.push(stat);
218            }
219        }
220
221        stats
222    }
223}
224
225impl Default for StatsCommand {
226    fn default() -> Self {
227        Self::new()
228    }
229}
230
231#[async_trait]
232impl DockerCommand for StatsCommand {
233    type Output = CommandOutput;
234
235    fn get_executor(&self) -> &CommandExecutor {
236        &self.executor
237    }
238
239    fn get_executor_mut(&mut self) -> &mut CommandExecutor {
240        &mut self.executor
241    }
242
243    fn build_command_args(&self) -> Vec<String> {
244        let mut args = vec!["stats".to_string()];
245
246        if self.all {
247            args.push("--all".to_string());
248        }
249
250        if let Some(ref format) = self.format {
251            args.push("--format".to_string());
252            args.push(format.clone());
253        }
254
255        if self.no_stream {
256            args.push("--no-stream".to_string());
257        }
258
259        if self.no_trunc {
260            args.push("--no-trunc".to_string());
261        }
262
263        // Add container names/IDs
264        args.extend(self.containers.clone());
265
266        // Add raw arguments from executor
267        args.extend(self.executor.raw_args.clone());
268
269        args
270    }
271
272    async fn execute(&self) -> Result<Self::Output> {
273        let args = self.build_command_args();
274        self.execute_command(args).await
275    }
276}
277
278/// Result from the stats command
279#[derive(Debug, Clone)]
280pub struct StatsResult {
281    /// Raw command output
282    pub output: CommandOutput,
283    /// Containers that were monitored
284    pub containers: Vec<String>,
285    /// Parsed stats (when JSON format is used)
286    pub parsed_stats: Vec<ContainerStats>,
287}
288
289impl StatsResult {
290    /// Check if the stats command was successful
291    #[must_use]
292    pub fn success(&self) -> bool {
293        self.output.success
294    }
295
296    /// Get the monitored container names
297    #[must_use]
298    pub fn containers(&self) -> &[String] {
299        &self.containers
300    }
301
302    /// Get parsed stats (available when JSON format is used)
303    #[must_use]
304    pub fn parsed_stats(&self) -> &[ContainerStats] {
305        &self.parsed_stats
306    }
307
308    /// Get the raw stats output
309    #[must_use]
310    pub fn raw_output(&self) -> &str {
311        &self.output.stdout
312    }
313}
314
315/// Container resource usage statistics
316#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct ContainerStats {
318    /// Container ID
319    #[serde(alias = "Container")]
320    pub container_id: String,
321
322    /// Container name
323    #[serde(alias = "Name")]
324    pub name: String,
325
326    /// CPU usage percentage
327    #[serde(alias = "CPUPerc")]
328    pub cpu_percent: String,
329
330    /// Memory usage
331    #[serde(alias = "MemUsage")]
332    pub memory_usage: String,
333
334    /// Memory usage percentage
335    #[serde(alias = "MemPerc")]
336    pub memory_percent: String,
337
338    /// Network I/O
339    #[serde(alias = "NetIO")]
340    pub network_io: String,
341
342    /// Block I/O
343    #[serde(alias = "BlockIO")]
344    pub block_io: String,
345
346    /// Number of process IDs (PIDs)
347    #[serde(alias = "PIDs")]
348    pub pids: String,
349}
350
351impl ContainerStats {
352    /// Get CPU percentage as a float (removes % sign)
353    #[must_use]
354    pub fn cpu_percentage(&self) -> Option<f64> {
355        self.cpu_percent.trim_end_matches('%').parse().ok()
356    }
357
358    /// Get memory percentage as a float (removes % sign)
359    #[must_use]
360    pub fn memory_percentage(&self) -> Option<f64> {
361        self.memory_percent.trim_end_matches('%').parse().ok()
362    }
363
364    /// Get number of PIDs as integer
365    #[must_use]
366    pub fn pid_count(&self) -> Option<u32> {
367        self.pids.parse().ok()
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn test_stats_basic() {
377        let cmd = StatsCommand::new();
378        let args = cmd.build_command_args();
379        assert_eq!(args, vec!["stats"]);
380    }
381
382    #[test]
383    fn test_stats_with_containers() {
384        let cmd = StatsCommand::new().container("web").container("db");
385        let args = cmd.build_command_args();
386        assert_eq!(args, vec!["stats", "web", "db"]);
387    }
388
389    #[test]
390    fn test_stats_with_all_flag() {
391        let cmd = StatsCommand::new().all();
392        let args = cmd.build_command_args();
393        assert_eq!(args, vec!["stats", "--all"]);
394    }
395
396    #[test]
397    fn test_stats_with_format() {
398        let cmd = StatsCommand::new().format("json");
399        let args = cmd.build_command_args();
400        assert_eq!(args, vec!["stats", "--format", "json"]);
401    }
402
403    #[test]
404    fn test_stats_no_stream() {
405        let cmd = StatsCommand::new().no_stream();
406        let args = cmd.build_command_args();
407        assert_eq!(args, vec!["stats", "--no-stream"]);
408    }
409
410    #[test]
411    fn test_stats_no_trunc() {
412        let cmd = StatsCommand::new().no_trunc();
413        let args = cmd.build_command_args();
414        assert_eq!(args, vec!["stats", "--no-trunc"]);
415    }
416
417    #[test]
418    fn test_stats_all_options() {
419        let cmd = StatsCommand::new()
420            .all()
421            .format("table")
422            .no_stream()
423            .no_trunc()
424            .container("test-container");
425        let args = cmd.build_command_args();
426        assert_eq!(
427            args,
428            vec![
429                "stats",
430                "--all",
431                "--format",
432                "table",
433                "--no-stream",
434                "--no-trunc",
435                "test-container"
436            ]
437        );
438    }
439
440    #[test]
441    fn test_container_stats_parsing() {
442        let stats = ContainerStats {
443            container_id: "abc123".to_string(),
444            name: "test-container".to_string(),
445            cpu_percent: "1.23%".to_string(),
446            memory_usage: "512MiB / 2GiB".to_string(),
447            memory_percent: "25.00%".to_string(),
448            network_io: "1.2kB / 3.4kB".to_string(),
449            block_io: "4.5MB / 6.7MB".to_string(),
450            pids: "42".to_string(),
451        };
452
453        assert_eq!(stats.cpu_percentage(), Some(1.23));
454        assert_eq!(stats.memory_percentage(), Some(25.0));
455        assert_eq!(stats.pid_count(), Some(42));
456    }
457
458    #[test]
459    fn test_parse_json_stats() {
460        let json_output = r#"{"Container":"abc123","Name":"test","CPUPerc":"1.23%","MemUsage":"512MiB / 2GiB","MemPerc":"25.00%","NetIO":"1.2kB / 3.4kB","BlockIO":"4.5MB / 6.7MB","PIDs":"42"}"#;
461
462        let stats = StatsCommand::parse_json_stats(json_output);
463        assert_eq!(stats.len(), 1);
464        assert_eq!(stats[0].name, "test");
465        assert_eq!(stats[0].container_id, "abc123");
466    }
467
468    #[test]
469    fn test_parse_json_stats_multiple_lines() {
470        let json_output = r#"{"Container":"abc123","Name":"test1","CPUPerc":"1.23%","MemUsage":"512MiB / 2GiB","MemPerc":"25.00%","NetIO":"1.2kB / 3.4kB","BlockIO":"4.5MB / 6.7MB","PIDs":"42"}
471{"Container":"def456","Name":"test2","CPUPerc":"2.34%","MemUsage":"1GiB / 4GiB","MemPerc":"25.00%","NetIO":"2.3kB / 4.5kB","BlockIO":"5.6MB / 7.8MB","PIDs":"24"}"#;
472
473        let stats = StatsCommand::parse_json_stats(json_output);
474        assert_eq!(stats.len(), 2);
475        assert_eq!(stats[0].name, "test1");
476        assert_eq!(stats[1].name, "test2");
477    }
478
479    #[test]
480    fn test_parse_json_stats_empty() {
481        let stats = StatsCommand::parse_json_stats("");
482        assert!(stats.is_empty());
483    }
484}