1use crate::core::{Artifact, ArtifactKind, ArtifactMetadata};
10use crate::error::{DevSweepError, Result};
11use std::path::PathBuf;
12use std::process::Command;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum DockerArtifactType {
17 DanglingImages,
19 UnusedImages,
21 UnusedVolumes,
23 BuildCache,
25 StoppedContainers,
27}
28
29impl DockerArtifactType {
30 pub fn list_command(&self) -> Vec<&'static str> {
32 match self {
33 Self::DanglingImages => vec!["images", "-f", "dangling=true", "-q"],
34 Self::UnusedImages => vec!["images", "-q"],
35 Self::UnusedVolumes => vec!["volume", "ls", "-f", "dangling=true", "-q"],
36 Self::BuildCache => vec!["builder", "du"],
37 Self::StoppedContainers => vec!["ps", "-a", "-f", "status=exited", "-q"],
38 }
39 }
40
41 pub fn clean_command(&self) -> Vec<&'static str> {
43 match self {
44 Self::DanglingImages => vec!["image", "prune", "-f"],
45 Self::UnusedImages => vec!["image", "prune", "-a", "-f"],
46 Self::UnusedVolumes => vec!["volume", "prune", "-f"],
47 Self::BuildCache => vec!["builder", "prune", "-f"],
48 Self::StoppedContainers => vec!["container", "prune", "-f"],
49 }
50 }
51}
52
53pub fn is_docker_available() -> bool {
55 Command::new("docker")
56 .arg("version")
57 .output()
58 .map(|o| o.status.success())
59 .unwrap_or(false)
60}
61
62pub fn get_docker_disk_usage() -> Result<DockerDiskUsage> {
64 if !is_docker_available() {
65 return Err(DevSweepError::DockerNotAvailable);
66 }
67
68 let output = Command::new("docker")
69 .args(["system", "df", "--format", "{{json .}}"])
70 .output()
71 .map_err(|e| DevSweepError::Docker(e.to_string()))?;
72
73 if !output.status.success() {
74 return Err(DevSweepError::Docker(
75 String::from_utf8_lossy(&output.stderr).to_string(),
76 ));
77 }
78
79 let stdout = String::from_utf8_lossy(&output.stdout);
81 let mut usage = DockerDiskUsage::default();
82
83 for line in stdout.lines() {
84 if let Ok(entry) = serde_json::from_str::<DockerDfEntry>(line) {
85 match entry.r#type.as_str() {
86 "Images" => {
87 usage.images_size = parse_docker_size(&entry.size);
88 usage.images_reclaimable = parse_docker_size(&entry.reclaimable);
89 }
90 "Containers" => {
91 usage.containers_size = parse_docker_size(&entry.size);
92 usage.containers_reclaimable = parse_docker_size(&entry.reclaimable);
93 }
94 "Local Volumes" => {
95 usage.volumes_size = parse_docker_size(&entry.size);
96 usage.volumes_reclaimable = parse_docker_size(&entry.reclaimable);
97 }
98 "Build Cache" => {
99 usage.build_cache_size = parse_docker_size(&entry.size);
100 usage.build_cache_reclaimable = parse_docker_size(&entry.reclaimable);
101 }
102 _ => {}
103 }
104 }
105 }
106
107 Ok(usage)
108}
109
110#[derive(Debug, Clone, Default)]
112pub struct DockerDiskUsage {
113 pub images_size: u64,
114 pub images_reclaimable: u64,
115 pub containers_size: u64,
116 pub containers_reclaimable: u64,
117 pub volumes_size: u64,
118 pub volumes_reclaimable: u64,
119 pub build_cache_size: u64,
120 pub build_cache_reclaimable: u64,
121}
122
123impl DockerDiskUsage {
124 pub fn total_size(&self) -> u64 {
126 self.images_size
127 + self.containers_size
128 + self.volumes_size
129 + self.build_cache_size
130 }
131
132 pub fn total_reclaimable(&self) -> u64 {
134 self.images_reclaimable
135 + self.containers_reclaimable
136 + self.volumes_reclaimable
137 + self.build_cache_reclaimable
138 }
139
140 pub fn to_artifacts(&self) -> Vec<Artifact> {
142 let mut artifacts = Vec::new();
143
144 if self.images_reclaimable > 0 {
145 artifacts.push(Artifact {
146 path: PathBuf::from("docker://images"),
147 kind: ArtifactKind::Docker,
148 size: self.images_reclaimable,
149 file_count: 0,
150 age: None,
151 metadata: ArtifactMetadata {
152 restorable: false,
153 restore_command: Some("docker pull".into()),
154 ..Default::default()
155 },
156 });
157 }
158
159 if self.volumes_reclaimable > 0 {
160 artifacts.push(Artifact {
161 path: PathBuf::from("docker://volumes"),
162 kind: ArtifactKind::Docker,
163 size: self.volumes_reclaimable,
164 file_count: 0,
165 age: None,
166 metadata: ArtifactMetadata {
167 restorable: false,
168 ..Default::default()
169 },
170 });
171 }
172
173 if self.build_cache_reclaimable > 0 {
174 artifacts.push(Artifact {
175 path: PathBuf::from("docker://build-cache"),
176 kind: ArtifactKind::Docker,
177 size: self.build_cache_reclaimable,
178 file_count: 0,
179 age: None,
180 metadata: ArtifactMetadata {
181 restorable: true,
182 restore_command: Some("docker build".into()),
183 ..Default::default()
184 },
185 });
186 }
187
188 artifacts
189 }
190}
191
192#[derive(serde::Deserialize)]
193struct DockerDfEntry {
194 #[serde(rename = "Type")]
195 r#type: String,
196 #[serde(rename = "Size")]
197 size: String,
198 #[serde(rename = "Reclaimable")]
199 reclaimable: String,
200}
201
202fn parse_docker_size(s: &str) -> u64 {
204 let s = s.trim();
205
206 let s = s.split('(').next().unwrap_or(s).trim();
208
209 let (num_part, unit) = if s.ends_with("GB") {
210 (&s[..s.len() - 2], 1_000_000_000u64)
211 } else if s.ends_with("MB") {
212 (&s[..s.len() - 2], 1_000_000u64)
213 } else if s.ends_with("KB") || s.ends_with("kB") {
214 (&s[..s.len() - 2], 1_000u64)
215 } else if s.ends_with('B') {
216 (&s[..s.len() - 1], 1u64)
217 } else {
218 (s, 1u64)
219 };
220
221 num_part
222 .trim()
223 .parse::<f64>()
224 .map(|n| (n * unit as f64) as u64)
225 .unwrap_or(0)
226}
227
228pub fn clean_docker(artifact_type: DockerArtifactType) -> Result<u64> {
230 if !is_docker_available() {
231 return Err(DevSweepError::DockerNotAvailable);
232 }
233
234 let args = artifact_type.clean_command();
235 let output = Command::new("docker")
236 .args(&args)
237 .output()
238 .map_err(|e| DevSweepError::Docker(e.to_string()))?;
239
240 if !output.status.success() {
241 return Err(DevSweepError::Docker(
242 String::from_utf8_lossy(&output.stderr).to_string(),
243 ));
244 }
245
246 let stdout = String::from_utf8_lossy(&output.stdout);
248 let reclaimed = parse_reclaimed_space(&stdout);
249
250 Ok(reclaimed)
251}
252
253fn parse_reclaimed_space(output: &str) -> u64 {
255 for line in output.lines() {
257 if line.contains("reclaimed space:") || line.contains("Reclaimed space:") {
258 if let Some(size_part) = line.split(':').nth(1) {
259 return parse_docker_size(size_part);
260 }
261 }
262 }
263 0
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
271 fn test_parse_docker_size() {
272 assert_eq!(parse_docker_size("1.5GB"), 1_500_000_000);
273 assert_eq!(parse_docker_size("100MB"), 100_000_000);
274 assert_eq!(parse_docker_size("500KB"), 500_000);
275 assert_eq!(parse_docker_size("1.5GB (50%)"), 1_500_000_000);
276 }
277
278 #[test]
279 fn test_docker_artifact_commands() {
280 let dangling = DockerArtifactType::DanglingImages;
281 assert!(dangling.clean_command().contains(&"prune"));
282 }
283}