docker_wrapper/command/
container_prune.rs

1//! Docker container prune command implementation.
2
3use crate::command::{CommandExecutor, DockerCommand};
4use crate::error::Result;
5use async_trait::async_trait;
6use serde::Deserialize;
7use std::collections::HashMap;
8
9/// Result from container prune operation
10#[derive(Debug, Clone, Deserialize)]
11#[serde(rename_all = "PascalCase")]
12pub struct ContainerPruneResult {
13    /// List of deleted container IDs
14    #[serde(default)]
15    pub containers_deleted: Vec<String>,
16
17    /// Total space reclaimed in bytes
18    #[serde(default)]
19    pub space_reclaimed: u64,
20}
21
22/// Docker container prune command
23///
24/// Remove all stopped containers
25#[derive(Debug, Clone)]
26pub struct ContainerPruneCommand {
27    /// Do not prompt for confirmation
28    force: bool,
29
30    /// Provide filter values
31    filter: HashMap<String, String>,
32
33    /// Command executor
34    pub executor: CommandExecutor,
35}
36
37impl ContainerPruneCommand {
38    /// Create a new container prune command
39    #[must_use]
40    pub fn new() -> Self {
41        Self {
42            force: false,
43            filter: HashMap::new(),
44            executor: CommandExecutor::new(),
45        }
46    }
47
48    /// Do not prompt for confirmation
49    #[must_use]
50    pub fn force(mut self) -> Self {
51        self.force = true;
52        self
53    }
54
55    /// Add a filter (e.g., "until=24h", "label=foo=bar")
56    #[must_use]
57    pub fn filter(mut self, key: &str, value: &str) -> Self {
58        self.filter.insert(key.to_string(), value.to_string());
59        self
60    }
61
62    /// Prune containers older than the specified duration
63    #[must_use]
64    pub fn until(mut self, duration: &str) -> Self {
65        self.filter
66            .insert("until".to_string(), duration.to_string());
67        self
68    }
69
70    /// Prune containers with the specified label
71    #[must_use]
72    pub fn with_label(mut self, key: &str, value: Option<&str>) -> Self {
73        let label_filter = if let Some(val) = value {
74            format!("{key}={val}")
75        } else {
76            key.to_string()
77        };
78        self.filter.insert("label".to_string(), label_filter);
79        self
80    }
81
82    /// Execute the container prune command
83    ///
84    /// # Errors
85    ///
86    /// Returns an error if the command fails to execute or if Docker is not available.
87    pub async fn run(&self) -> Result<ContainerPruneResult> {
88        self.execute().await
89    }
90}
91
92impl Default for ContainerPruneCommand {
93    fn default() -> Self {
94        Self::new()
95    }
96}
97
98#[async_trait]
99impl DockerCommand for ContainerPruneCommand {
100    type Output = ContainerPruneResult;
101
102    fn build_command_args(&self) -> Vec<String> {
103        let mut args = vec!["container".to_string(), "prune".to_string()];
104
105        if self.force {
106            args.push("--force".to_string());
107        }
108
109        for (key, value) in &self.filter {
110            args.push("--filter".to_string());
111            if key == "label" {
112                args.push(value.clone());
113            } else {
114                args.push(format!("{key}={value}"));
115            }
116        }
117
118        args.extend(self.executor.raw_args.clone());
119        args
120    }
121
122    fn get_executor(&self) -> &CommandExecutor {
123        &self.executor
124    }
125
126    fn get_executor_mut(&mut self) -> &mut CommandExecutor {
127        &mut self.executor
128    }
129
130    async fn execute(&self) -> Result<Self::Output> {
131        let args = self.build_command_args();
132        let command_name = args[0].clone();
133        let command_args = args[1..].to_vec();
134        let output = self
135            .executor
136            .execute_command(&command_name, command_args)
137            .await?;
138        let stdout = &output.stdout;
139
140        // Parse the output to extract deleted containers and space reclaimed
141        let mut result = ContainerPruneResult {
142            containers_deleted: Vec::new(),
143            space_reclaimed: 0,
144        };
145
146        // Docker returns text output, we need to parse it
147        for line in stdout.lines() {
148            if line.starts_with("Deleted Containers:") {
149                // Next lines contain container IDs
150                continue;
151            }
152            if line.starts_with("Total reclaimed space:") {
153                // Extract the space value
154                if let Some(space_str) = line.split(':').nth(1) {
155                    result.space_reclaimed = parse_size(space_str.trim());
156                }
157            } else if !line.is_empty() && !line.contains("will be removed") {
158                // This might be a container ID
159                let id = line.trim();
160                if id.len() == 12 || id.len() == 64 {
161                    result.containers_deleted.push(id.to_string());
162                }
163            }
164        }
165
166        Ok(result)
167    }
168}
169
170/// Parse size string (e.g., "1.5GB", "100MB") to bytes
171#[allow(clippy::cast_possible_truncation)]
172#[allow(clippy::cast_sign_loss)]
173#[allow(clippy::cast_precision_loss)]
174fn parse_size(size_str: &str) -> u64 {
175    let size_str = size_str.trim();
176    let mut numeric_part = String::new();
177    let mut unit_part = String::new();
178    let mut found_dot = false;
179
180    for ch in size_str.chars() {
181        if ch.is_ascii_digit() || (ch == '.' && !found_dot) {
182            numeric_part.push(ch);
183            if ch == '.' {
184                found_dot = true;
185            }
186        } else if ch.is_ascii_alphabetic() {
187            unit_part.push(ch);
188        }
189    }
190
191    let value: f64 = numeric_part.parse().unwrap_or(0.0);
192
193    let multiplier = match unit_part.to_uppercase().as_str() {
194        "KB" | "K" => 1_024,
195        "MB" | "M" => 1_024 * 1_024,
196        "GB" | "G" => 1_024 * 1_024 * 1_024,
197        "TB" | "T" => 1_024_u64.pow(4),
198        _ => 1, // Includes "B" and empty string
199    };
200
201    (value * multiplier as f64) as u64
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_container_prune_builder() {
210        let cmd = ContainerPruneCommand::new()
211            .force()
212            .until("24h")
213            .with_label("temp", Some("true"));
214
215        let args = cmd.build_command_args();
216        assert_eq!(args[0], "container");
217        assert!(args.contains(&"prune".to_string()));
218        assert!(args.contains(&"--force".to_string()));
219        assert!(args.contains(&"--filter".to_string()));
220    }
221}