1use super::{CleanableItem, SafetyLevel};
10use crate::error::Result;
11use std::path::PathBuf;
12use std::process::Command;
13
14pub struct DockerCleaner;
16
17impl DockerCleaner {
18 pub fn new() -> Self {
20 Self
21 }
22
23 pub fn is_available(&self) -> bool {
25 Command::new("docker")
26 .arg("info")
27 .output()
28 .map(|o| o.status.success())
29 .unwrap_or(false)
30 }
31
32 pub fn detect(&self) -> Result<Vec<CleanableItem>> {
34 if !self.is_available() {
35 return Ok(vec![]);
36 }
37
38 let mut items = Vec::new();
39
40 if let Ok(df) = self.get_disk_usage() {
42 items.extend(df);
43 }
44
45 items.extend(self.detect_dangling_images()?);
47
48 items.extend(self.detect_stopped_containers()?);
50
51 items.extend(self.detect_unused_volumes()?);
53
54 items.extend(self.detect_build_cache()?);
56
57 Ok(items)
58 }
59
60 fn get_disk_usage(&self) -> Result<Vec<CleanableItem>> {
62 let output = Command::new("docker")
63 .args(["system", "df", "--format", "{{.Type}}\t{{.Size}}\t{{.Reclaimable}}"])
64 .output()?;
65
66 if !output.status.success() {
67 return Ok(vec![]);
68 }
69
70 let stdout = String::from_utf8_lossy(&output.stdout);
71 let mut items = Vec::new();
72
73 for line in stdout.lines() {
74 let parts: Vec<&str> = line.split('\t').collect();
75 if parts.len() >= 3 {
76 let type_name = parts[0];
77 let _total_size = parse_docker_size(parts[1]);
78 let reclaimable = parse_docker_size(parts[2].trim_end_matches(|c| c == ')' || c == '%' || c == '(').split('(').next().unwrap_or("0"));
79
80 if reclaimable > 0 {
81 let (icon, desc, safety) = match type_name {
82 "Images" => ("🐳", "Docker images not used by any container", SafetyLevel::SafeWithCost),
83 "Containers" => ("📦", "Stopped Docker containers", SafetyLevel::Safe),
84 "Local Volumes" => ("💾", "Docker volumes not used by any container", SafetyLevel::Caution),
85 "Build Cache" => ("🔨", "Docker build cache layers", SafetyLevel::Safe),
86 _ => ("🐳", "Docker resources", SafetyLevel::SafeWithCost),
87 };
88
89 items.push(CleanableItem {
90 name: format!("Docker {}", type_name),
91 category: "Docker".to_string(),
92 subcategory: type_name.to_string(),
93 icon,
94 path: PathBuf::from("/var/lib/docker"), size: reclaimable,
96 file_count: None,
97 last_modified: None,
98 description: desc,
99 safe_to_delete: safety,
100 clean_command: Some(match type_name {
101 "Images" => "docker image prune -af".to_string(),
102 "Containers" => "docker container prune -f".to_string(),
103 "Local Volumes" => "docker volume prune -f".to_string(),
104 "Build Cache" => "docker builder prune -f".to_string(),
105 _ => "docker system prune -f".to_string(),
106 }),
107 });
108 }
109 }
110 }
111
112 Ok(items)
113 }
114
115 fn detect_dangling_images(&self) -> Result<Vec<CleanableItem>> {
117 let output = Command::new("docker")
118 .args(["images", "-f", "dangling=true", "--format", "{{.ID}}\t{{.Size}}\t{{.CreatedAt}}"])
119 .output()?;
120
121 if !output.status.success() {
122 return Ok(vec![]);
123 }
124
125 let stdout = String::from_utf8_lossy(&output.stdout);
126 let mut items = Vec::new();
127
128 for line in stdout.lines() {
129 let parts: Vec<&str> = line.split('\t').collect();
130 if parts.len() >= 2 {
131 let id = parts[0];
132 let size = parse_docker_size(parts[1]);
133
134 if size > 0 {
135 items.push(CleanableItem {
136 name: format!("Dangling Image: {}", &id[..12.min(id.len())]),
137 category: "Docker".to_string(),
138 subcategory: "Dangling Images".to_string(),
139 icon: "👻",
140 path: PathBuf::from(format!("/var/lib/docker/image/{}", id)),
141 size,
142 file_count: None,
143 last_modified: None,
144 description: "Untagged image not used by any container.",
145 safe_to_delete: SafetyLevel::Safe,
146 clean_command: Some(format!("docker rmi -f {}", id)),
147 });
148 }
149 }
150 }
151
152 Ok(items)
153 }
154
155 fn detect_stopped_containers(&self) -> Result<Vec<CleanableItem>> {
157 let output = Command::new("docker")
158 .args(["ps", "-a", "-f", "status=exited", "--format", "{{.ID}}\t{{.Names}}\t{{.Size}}\t{{.CreatedAt}}"])
159 .output()?;
160
161 if !output.status.success() {
162 return Ok(vec![]);
163 }
164
165 let stdout = String::from_utf8_lossy(&output.stdout);
166 let mut items = Vec::new();
167
168 for line in stdout.lines() {
169 let parts: Vec<&str> = line.split('\t').collect();
170 if parts.len() >= 3 {
171 let id = parts[0];
172 let name = parts[1];
173 let size_str = parts[2];
174
175 let size = if let Some(virtual_start) = size_str.find("virtual ") {
177 let virtual_size = &size_str[virtual_start + 8..];
178 let end = virtual_size.find(')').unwrap_or(virtual_size.len());
179 parse_docker_size(&virtual_size[..end])
180 } else {
181 parse_docker_size(size_str)
182 };
183
184 items.push(CleanableItem {
185 name: format!("Container: {}", name),
186 category: "Docker".to_string(),
187 subcategory: "Stopped Containers".to_string(),
188 icon: "📦",
189 path: PathBuf::from(format!("/var/lib/docker/containers/{}", id)),
190 size,
191 file_count: None,
192 last_modified: None,
193 description: "Stopped container that can be removed.",
194 safe_to_delete: SafetyLevel::Safe,
195 clean_command: Some(format!("docker rm -f {}", id)),
196 });
197 }
198 }
199
200 Ok(items)
201 }
202
203 fn detect_unused_volumes(&self) -> Result<Vec<CleanableItem>> {
205 let output = Command::new("docker")
207 .args(["volume", "ls", "-f", "dangling=true", "--format", "{{.Name}}"])
208 .output()?;
209
210 if !output.status.success() {
211 return Ok(vec![]);
212 }
213
214 let stdout = String::from_utf8_lossy(&output.stdout);
215 let mut items = Vec::new();
216
217 for line in stdout.lines() {
218 let name = line.trim();
219 if name.is_empty() {
220 continue;
221 }
222
223 let inspect = Command::new("docker")
225 .args(["system", "df", "-v", "--format", "{{.Name}}\t{{.Size}}"])
226 .output()
227 .ok();
228
229 let size = inspect.and_then(|o| {
230 let out = String::from_utf8_lossy(&o.stdout);
231 out.lines()
232 .find(|l| l.starts_with(name))
233 .and_then(|l| l.split('\t').nth(1))
234 .map(parse_docker_size)
235 }).unwrap_or(0);
236
237 items.push(CleanableItem {
238 name: format!("Volume: {}", if name.len() > 20 { &name[..20] } else { name }),
239 category: "Docker".to_string(),
240 subcategory: "Volumes".to_string(),
241 icon: "💾",
242 path: PathBuf::from(format!("/var/lib/docker/volumes/{}", name)),
243 size,
244 file_count: None,
245 last_modified: None,
246 description: "Docker volume not used by any container.",
247 safe_to_delete: SafetyLevel::Caution,
248 clean_command: Some(format!("docker volume rm {}", name)),
249 });
250 }
251
252 Ok(items)
253 }
254
255 fn detect_build_cache(&self) -> Result<Vec<CleanableItem>> {
257 let output = Command::new("docker")
258 .args(["builder", "du", "--format", "{{.ID}}\t{{.Size}}\t{{.LastUsedAt}}"])
259 .output()?;
260
261 if !output.status.success() {
262 return Ok(vec![]);
263 }
264
265 let stdout = String::from_utf8_lossy(&output.stdout);
266 let mut total_size = 0u64;
267 let mut count = 0usize;
268
269 for line in stdout.lines() {
270 let parts: Vec<&str> = line.split('\t').collect();
271 if parts.len() >= 2 {
272 total_size += parse_docker_size(parts[1]);
273 count += 1;
274 }
275 }
276
277 if total_size > 0 {
278 Ok(vec![CleanableItem {
279 name: format!("Build Cache ({} layers)", count),
280 category: "Docker".to_string(),
281 subcategory: "Build Cache".to_string(),
282 icon: "🔨",
283 path: PathBuf::from("/var/lib/docker/buildkit"),
284 size: total_size,
285 file_count: Some(count as u64),
286 last_modified: None,
287 description: "Docker build cache layers. Speeds up rebuilds.",
288 safe_to_delete: SafetyLevel::SafeWithCost,
289 clean_command: Some("docker builder prune -a".to_string()),
290 }])
291 } else {
292 Ok(vec![])
293 }
294 }
295
296 pub fn clean_all(&self, include_volumes: bool) -> Result<u64> {
298 let args = if include_volumes {
299 vec!["system", "prune", "-a", "--volumes", "-f"]
300 } else {
301 vec!["system", "prune", "-a", "-f"]
302 };
303
304 let output = Command::new("docker")
305 .args(&args)
306 .output()?;
307
308 if !output.status.success() {
309 return Ok(0);
310 }
311
312 let stdout = String::from_utf8_lossy(&output.stdout);
314 for line in stdout.lines() {
315 if line.contains("reclaimed space") {
316 if let Some(size_str) = line.split(':').nth(1) {
317 return Ok(parse_docker_size(size_str.trim()));
318 }
319 }
320 }
321
322 Ok(0)
323 }
324}
325
326impl Default for DockerCleaner {
327 fn default() -> Self {
328 Self::new()
329 }
330}
331
332fn parse_docker_size(s: &str) -> u64 {
334 let s = s.trim();
335
336 let num_end = s.find(|c: char| !c.is_ascii_digit() && c != '.').unwrap_or(s.len());
338 let (num_str, unit) = s.split_at(num_end);
339
340 let num: f64 = num_str.parse().unwrap_or(0.0);
341 let unit = unit.to_uppercase();
342
343 let multiplier = match unit.as_str() {
344 "B" | "" => 1.0,
345 "KB" | "K" => 1024.0,
346 "MB" | "M" => 1024.0 * 1024.0,
347 "GB" | "G" => 1024.0 * 1024.0 * 1024.0,
348 "TB" | "T" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
349 _ => 1.0,
350 };
351
352 (num * multiplier) as u64
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn test_parse_docker_size() {
361 assert_eq!(parse_docker_size("1.5GB"), 1610612736);
362 assert_eq!(parse_docker_size("234MB"), 245366784);
363 assert_eq!(parse_docker_size("567kB"), 580608);
364 assert_eq!(parse_docker_size("100B"), 100);
365 }
366
367 #[test]
368 fn test_docker_cleaner() {
369 let cleaner = DockerCleaner::new();
370 if cleaner.is_available() {
371 let items = cleaner.detect().unwrap();
372 println!("Found {} Docker items", items.len());
373 for item in &items {
374 println!(" {} {} ({} bytes)", item.icon, item.name, item.size);
375 }
376 } else {
377 println!("Docker not available");
378 }
379 }
380}