Skip to main content

diskforge_core/rules/
docker.rs

1use std::path::PathBuf;
2
3use crate::types::{Category, CleanableItem, Risk};
4
5/// Scan Docker for reclaimable disk space using `docker system df`.
6/// Returns one item per reclaimable resource type (images, containers, volumes, build cache).
7pub fn scan_docker(home: &str) -> Vec<CleanableItem> {
8    let mut items = Vec::new();
9
10    let Ok(output) = std::process::Command::new("docker")
11        .args(["system", "df"])
12        .output()
13    else {
14        return items; // Docker not installed or not running
15    };
16    if !output.status.success() {
17        return items;
18    }
19
20    let text = String::from_utf8_lossy(&output.stdout);
21    for line in text.lines().skip(1) {
22        if let Some(item) = parse_line(line, home) {
23            items.push(item);
24        }
25    }
26
27    items
28}
29
30fn parse_line(line: &str, home: &str) -> Option<CleanableItem> {
31    // docker system df output columns: TYPE  TOTAL  ACTIVE  SIZE  RECLAIMABLE
32    // "Local Volumes" and "Build Cache" are two-word types, shifting columns by 1.
33    let cols: Vec<&str> = line.split_whitespace().collect();
34
35    if line.starts_with("Images") && cols.len() >= 5 {
36        let reclaimable = parse_size(cols[4])?;
37        if reclaimable == 0 {
38            return None;
39        }
40        return Some(docker_item(
41            home,
42            "docker/images",
43            "Docker Images (unused)",
44            reclaimable,
45            Risk::Low,
46            true,
47            "docker image prune -af",
48            "docker image prune -af",
49        ));
50    }
51
52    if line.starts_with("Containers") && cols.len() >= 5 {
53        let reclaimable = parse_size(cols[4])?;
54        if reclaimable == 0 {
55            return None;
56        }
57        return Some(docker_item(
58            home,
59            "docker/containers",
60            "Docker Containers (stopped)",
61            reclaimable,
62            Risk::Low,
63            true,
64            "docker container prune -f",
65            "docker container prune -f",
66        ));
67    }
68
69    if line.starts_with("Local Volumes") && cols.len() >= 6 {
70        let reclaimable = parse_size(cols[5])?;
71        if reclaimable == 0 {
72            return None;
73        }
74        return Some(docker_item(
75            home,
76            "docker/volumes",
77            "Docker Volumes (unused)",
78            reclaimable,
79            Risk::Medium,
80            false,
81            "docker volume prune -f",
82            "docker volume prune -f",
83        ));
84    }
85
86    if line.starts_with("Build Cache") && cols.len() >= 6 {
87        let reclaimable = parse_size(cols[5])?;
88        if reclaimable == 0 {
89            return None;
90        }
91        return Some(docker_item(
92            home,
93            "docker/build-cache",
94            "Docker Build Cache",
95            reclaimable,
96            Risk::None,
97            true,
98            "docker builder prune -af",
99            "docker builder prune -af",
100        ));
101    }
102
103    None
104}
105
106#[allow(clippy::too_many_arguments)]
107fn docker_item(
108    home: &str,
109    pseudo_path: &str,
110    description: &str,
111    size: u64,
112    risk: Risk,
113    regenerates: bool,
114    regeneration_hint: &str,
115    cleanup_command: &str,
116) -> CleanableItem {
117    // Use the Docker Desktop data dir for display; cleanup goes through the command.
118    let path = PathBuf::from(format!("{home}/Library/Containers/com.docker.docker/Data"));
119    let path = if path.exists() {
120        path
121    } else {
122        PathBuf::from(pseudo_path)
123    };
124
125    CleanableItem {
126        category: Category::Docker,
127        path,
128        size,
129        risk,
130        regenerates,
131        regeneration_hint: Some(regeneration_hint.into()),
132        last_modified: None,
133        description: description.into(),
134        cleanup_command: Some(cleanup_command.into()),
135    }
136}
137
138/// Parse Docker's size strings: "4.211GB", "234.5MB", "12.3kB", "0B"
139/// Docker uses decimal (SI) units.
140fn parse_size(s: &str) -> Option<u64> {
141    // Strip trailing percentage group like "(55%)"
142    let s = s.split('(').next().unwrap_or(s).trim();
143    if s == "0B" || s == "0" {
144        return Some(0);
145    }
146    let (num_str, unit) = if let Some(s) = s.strip_suffix("GB") {
147        (s, "GB")
148    } else if let Some(s) = s.strip_suffix("MB") {
149        (s, "MB")
150    } else if let Some(s) = s.strip_suffix("kB").or_else(|| s.strip_suffix("KB")) {
151        (s, "kB")
152    } else if let Some(s) = s.strip_suffix('B') {
153        (s, "B")
154    } else {
155        return None;
156    };
157    let n: f64 = num_str.parse().ok()?;
158    let bytes = match unit {
159        "GB" => (n * 1_000_000_000.0) as u64,
160        "MB" => (n * 1_000_000.0) as u64,
161        "kB" => (n * 1_000.0) as u64,
162        "B" => n as u64,
163        _ => return None,
164    };
165    Some(bytes)
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn parse_size_gb() {
174        assert_eq!(parse_size("4.211GB"), Some(4_211_000_000));
175    }
176
177    #[test]
178    fn parse_size_mb() {
179        assert_eq!(parse_size("234.5MB"), Some(234_500_000));
180    }
181
182    #[test]
183    fn parse_size_kb() {
184        assert_eq!(parse_size("12.3kB"), Some(12_300));
185    }
186
187    #[test]
188    fn parse_size_zero() {
189        assert_eq!(parse_size("0B"), Some(0));
190        assert_eq!(parse_size("0"), Some(0));
191    }
192
193    #[test]
194    fn parse_size_bytes() {
195        assert_eq!(parse_size("512B"), Some(512));
196    }
197
198    #[test]
199    fn parse_size_with_paren_suffix() {
200        // docker system df sometimes shows "2.344GB (55%)" in fields
201        assert_eq!(parse_size("2.344GB(55%)"), Some(2_344_000_000));
202    }
203
204    #[test]
205    fn parse_size_invalid() {
206        assert_eq!(parse_size("abc"), None);
207        assert_eq!(parse_size(""), None);
208    }
209
210    #[test]
211    fn parse_images_line() {
212        let line = "Images          10        3         4.211GB   2.344GB (55%)";
213        let item = parse_line(line, "/tmp").unwrap();
214        assert_eq!(item.size, 2_344_000_000);
215        assert_eq!(item.risk, Risk::Low);
216        assert!(item.cleanup_command.is_some());
217    }
218
219    #[test]
220    fn parse_containers_line() {
221        let line = "Containers      4         2         12.3kB    0B (0%)";
222        // Reclaimable is 0 → should return None
223        assert!(parse_line(line, "/tmp").is_none());
224    }
225
226    #[test]
227    fn parse_build_cache_line() {
228        let line = "Build Cache     89        0         2.134GB   2.134GB";
229        let item = parse_line(line, "/tmp").unwrap();
230        assert_eq!(item.size, 2_134_000_000);
231        assert_eq!(item.risk, Risk::None);
232    }
233
234    #[test]
235    fn parse_local_volumes_line() {
236        let line = "Local Volumes   5         2         1.234GB   456.7MB (37%)";
237        let item = parse_line(line, "/tmp").unwrap();
238        assert_eq!(item.size, 456_700_000);
239        assert_eq!(item.risk, Risk::Medium);
240    }
241
242    #[test]
243    fn parse_unknown_line() {
244        assert!(parse_line("SomethingElse   1 2 3 4", "/tmp").is_none());
245    }
246}