docker_wrapper/command/
image_prune.rs

1//! Docker image 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/// Deleted image information
10#[derive(Debug, Clone, Deserialize)]
11pub struct DeletedImage {
12    /// Untagged image references
13    #[serde(default, rename = "Untagged")]
14    pub untagged: Option<String>,
15
16    /// Deleted image layers
17    #[serde(default, rename = "Deleted")]
18    pub deleted: Option<String>,
19}
20
21/// Result from image prune operation
22#[derive(Debug, Clone, Deserialize)]
23#[serde(rename_all = "PascalCase")]
24pub struct ImagePruneResult {
25    /// List of deleted images
26    #[serde(default)]
27    pub images_deleted: Vec<DeletedImage>,
28
29    /// Total space reclaimed in bytes
30    #[serde(default)]
31    pub space_reclaimed: u64,
32}
33
34/// Docker image prune command
35///
36/// Remove unused images
37#[derive(Debug, Clone)]
38pub struct ImagePruneCommand {
39    /// Remove all unused images, not just dangling ones
40    all: bool,
41
42    /// Do not prompt for confirmation
43    force: bool,
44
45    /// Provide filter values
46    filter: HashMap<String, String>,
47
48    /// Command executor
49    pub executor: CommandExecutor,
50}
51
52impl ImagePruneCommand {
53    /// Create a new image prune command
54    #[must_use]
55    pub fn new() -> Self {
56        Self {
57            all: false,
58            force: false,
59            filter: HashMap::new(),
60            executor: CommandExecutor::new(),
61        }
62    }
63
64    /// Remove all unused images, not just dangling ones
65    #[must_use]
66    pub fn all(mut self) -> Self {
67        self.all = true;
68        self
69    }
70
71    /// Only remove dangling images (default behavior)
72    #[must_use]
73    pub fn dangling_only(mut self) -> Self {
74        self.filter
75            .insert("dangling".to_string(), "true".to_string());
76        self
77    }
78
79    /// Do not prompt for confirmation
80    #[must_use]
81    pub fn force(mut self) -> Self {
82        self.force = true;
83        self
84    }
85
86    /// Add a filter (e.g., "until=24h", "label=foo=bar")
87    #[must_use]
88    pub fn filter(mut self, key: &str, value: &str) -> Self {
89        self.filter.insert(key.to_string(), value.to_string());
90        self
91    }
92
93    /// Prune images older than the specified duration
94    #[must_use]
95    pub fn until(mut self, duration: &str) -> Self {
96        self.filter
97            .insert("until".to_string(), duration.to_string());
98        self
99    }
100
101    /// Prune images with the specified label
102    #[must_use]
103    pub fn with_label(mut self, key: &str, value: Option<&str>) -> Self {
104        let label_filter = if let Some(val) = value {
105            format!("{key}={val}")
106        } else {
107            key.to_string()
108        };
109        self.filter.insert("label".to_string(), label_filter);
110        self
111    }
112
113    /// Execute the image prune command
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if the command fails to execute or if Docker is not available.
118    pub async fn run(&self) -> Result<ImagePruneResult> {
119        self.execute().await
120    }
121}
122
123impl Default for ImagePruneCommand {
124    fn default() -> Self {
125        Self::new()
126    }
127}
128
129#[async_trait]
130impl DockerCommand for ImagePruneCommand {
131    type Output = ImagePruneResult;
132
133    fn build_command_args(&self) -> Vec<String> {
134        let mut args = vec!["image".to_string(), "prune".to_string()];
135
136        if self.all {
137            args.push("--all".to_string());
138        }
139
140        if self.force {
141            args.push("--force".to_string());
142        }
143
144        for (key, value) in &self.filter {
145            args.push("--filter".to_string());
146            if key == "label" {
147                args.push(value.clone());
148            } else {
149                args.push(format!("{key}={value}"));
150            }
151        }
152
153        args.extend(self.executor.raw_args.clone());
154        args
155    }
156
157    fn get_executor(&self) -> &CommandExecutor {
158        &self.executor
159    }
160
161    fn get_executor_mut(&mut self) -> &mut CommandExecutor {
162        &mut self.executor
163    }
164
165    async fn execute(&self) -> Result<Self::Output> {
166        let args = self.build_command_args();
167        let command_name = args[0].clone();
168        let command_args = args[1..].to_vec();
169        let output = self
170            .executor
171            .execute_command(&command_name, command_args)
172            .await?;
173        let stdout = &output.stdout;
174
175        // Parse the output to extract deleted images and space reclaimed
176        let mut result = ImagePruneResult {
177            images_deleted: Vec::new(),
178            space_reclaimed: 0,
179        };
180
181        // Docker returns text output, we need to parse it
182        let mut in_deleted_section = false;
183
184        for line in stdout.lines() {
185            if line.starts_with("Deleted Images:") {
186                in_deleted_section = true;
187                // Found deleted section header
188            } else if line.starts_with("Total reclaimed space:") {
189                in_deleted_section = false;
190                // Extract the space value
191                if let Some(space_str) = line.split(':').nth(1) {
192                    result.space_reclaimed = parse_size(space_str.trim());
193                }
194            } else if in_deleted_section && !line.is_empty() {
195                // Parse deleted or untagged entries
196                if line.starts_with("deleted:") || line.starts_with("untagged:") {
197                    let parts: Vec<&str> = line.splitn(2, ':').collect();
198                    if parts.len() == 2 {
199                        let entry_type = parts[0].trim();
200                        let value = parts[1].trim().to_string();
201
202                        if entry_type == "deleted" {
203                            result.images_deleted.push(DeletedImage {
204                                deleted: Some(value),
205                                untagged: None,
206                            });
207                        } else if entry_type == "untagged" {
208                            result.images_deleted.push(DeletedImage {
209                                untagged: Some(value),
210                                deleted: None,
211                            });
212                        }
213                    }
214                }
215            }
216        }
217
218        Ok(result)
219    }
220}
221
222/// Parse size string (e.g., "1.5GB", "100MB") to bytes
223#[allow(clippy::cast_possible_truncation)]
224#[allow(clippy::cast_sign_loss)]
225#[allow(clippy::cast_precision_loss)]
226fn parse_size(size_str: &str) -> u64 {
227    let size_str = size_str.trim();
228    let mut numeric_part = String::new();
229    let mut unit_part = String::new();
230    let mut found_dot = false;
231
232    for ch in size_str.chars() {
233        if ch.is_ascii_digit() || (ch == '.' && !found_dot) {
234            numeric_part.push(ch);
235            if ch == '.' {
236                found_dot = true;
237            }
238        } else if ch.is_ascii_alphabetic() {
239            unit_part.push(ch);
240        }
241    }
242
243    let value: f64 = numeric_part.parse().unwrap_or(0.0);
244
245    let multiplier = match unit_part.to_uppercase().as_str() {
246        "KB" | "K" => 1_024,
247        "MB" | "M" => 1_024 * 1_024,
248        "GB" | "G" => 1_024 * 1_024 * 1_024,
249        "TB" | "T" => 1_024_u64.pow(4),
250        _ => 1, // Includes "B" and empty string
251    };
252
253    (value * multiplier as f64) as u64
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn test_image_prune_builder() {
262        let cmd = ImagePruneCommand::new()
263            .all()
264            .force()
265            .until("7d")
266            .with_label("deprecated", None);
267
268        let args = cmd.build_command_args();
269        assert_eq!(args[0], "image");
270        assert!(args.contains(&"prune".to_string()));
271        assert!(args.contains(&"--all".to_string()));
272        assert!(args.contains(&"--force".to_string()));
273        assert!(args.contains(&"--filter".to_string()));
274    }
275
276    #[test]
277    fn test_dangling_only() {
278        let cmd = ImagePruneCommand::new().dangling_only().force();
279
280        let args = cmd.build_command_args();
281        assert_eq!(args[0], "image");
282        assert!(args.contains(&"prune".to_string()));
283        assert!(args.contains(&"--force".to_string()));
284        assert!(args.contains(&"dangling=true".to_string()));
285    }
286}