docker_wrapper/command/
image_prune.rs1use crate::command::{CommandExecutor, DockerCommand};
4use crate::error::Result;
5use async_trait::async_trait;
6use serde::Deserialize;
7use std::collections::HashMap;
8
9#[derive(Debug, Clone, Deserialize)]
11pub struct DeletedImage {
12 #[serde(default, rename = "Untagged")]
14 pub untagged: Option<String>,
15
16 #[serde(default, rename = "Deleted")]
18 pub deleted: Option<String>,
19}
20
21#[derive(Debug, Clone, Deserialize)]
23#[serde(rename_all = "PascalCase")]
24pub struct ImagePruneResult {
25 #[serde(default)]
27 pub images_deleted: Vec<DeletedImage>,
28
29 #[serde(default)]
31 pub space_reclaimed: u64,
32}
33
34#[derive(Debug, Clone)]
38pub struct ImagePruneCommand {
39 all: bool,
41
42 force: bool,
44
45 filter: HashMap<String, String>,
47
48 pub executor: CommandExecutor,
50}
51
52impl ImagePruneCommand {
53 #[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 #[must_use]
66 pub fn all(mut self) -> Self {
67 self.all = true;
68 self
69 }
70
71 #[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 #[must_use]
81 pub fn force(mut self) -> Self {
82 self.force = true;
83 self
84 }
85
86 #[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 #[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 #[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 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 let mut result = ImagePruneResult {
177 images_deleted: Vec::new(),
178 space_reclaimed: 0,
179 };
180
181 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 } else if line.starts_with("Total reclaimed space:") {
189 in_deleted_section = false;
190 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 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#[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, };
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}