diskforge_core/rules/
docker.rs1use std::path::PathBuf;
2
3use crate::types::{Category, CleanableItem, Risk};
4
5pub 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; };
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 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 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
138fn parse_size(s: &str) -> Option<u64> {
141 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 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 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}