Skip to main content

null_e/cleaners/
docker.rs

1//! Docker cleanup module
2//!
3//! Handles cleanup of Docker resources:
4//! - Dangling images
5//! - Stopped containers
6//! - Unused volumes
7//! - Build cache
8
9use super::{CleanableItem, SafetyLevel};
10use crate::error::Result;
11use std::path::PathBuf;
12use std::process::Command;
13
14/// Docker cleaner
15pub struct DockerCleaner;
16
17impl DockerCleaner {
18    /// Create a new Docker cleaner
19    pub fn new() -> Self {
20        Self
21    }
22
23    /// Check if Docker is available
24    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    /// Detect all Docker cleanable items
33    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        // Get disk usage summary
41        if let Ok(df) = self.get_disk_usage() {
42            items.extend(df);
43        }
44
45        // Dangling images
46        items.extend(self.detect_dangling_images()?);
47
48        // Stopped containers
49        items.extend(self.detect_stopped_containers()?);
50
51        // Unused volumes
52        items.extend(self.detect_unused_volumes()?);
53
54        // Build cache
55        items.extend(self.detect_build_cache()?);
56
57        Ok(items)
58    }
59
60    /// Get Docker disk usage summary
61    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"), // Placeholder
95                        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    /// Detect dangling images
116    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    /// Detect stopped containers
156    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                // Parse container size (format: "0B (virtual 123MB)")
176                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    /// Detect unused volumes
204    fn detect_unused_volumes(&self) -> Result<Vec<CleanableItem>> {
205        // Get dangling volumes
206        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            // Get volume size
224            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    /// Detect build cache
256    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    /// Clean all Docker resources
297    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        // Parse "Total reclaimed space: X.XXGB" from output
313        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
332/// Parse Docker size strings like "1.5GB", "234MB", "567kB"
333fn parse_docker_size(s: &str) -> u64 {
334    let s = s.trim();
335
336    // Find where the number ends (including decimal point)
337    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}