Skip to main content

null_e/docker/
mod.rs

1//! Docker integration for cleaning containers, images, and volumes
2//!
3//! This module provides functionality to clean up Docker artifacts:
4//! - Dangling images
5//! - Unused volumes
6//! - Build cache
7//! - Stopped containers
8
9use crate::core::{Artifact, ArtifactKind, ArtifactMetadata};
10use crate::error::{DevSweepError, Result};
11use std::path::PathBuf;
12use std::process::Command;
13
14/// Docker artifact types
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum DockerArtifactType {
17    /// Dangling images (untagged)
18    DanglingImages,
19    /// All unused images
20    UnusedImages,
21    /// Unused volumes
22    UnusedVolumes,
23    /// Build cache
24    BuildCache,
25    /// Stopped containers
26    StoppedContainers,
27}
28
29impl DockerArtifactType {
30    /// Get the docker command to list this artifact type
31    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    /// Get the docker command to clean this artifact type
42    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
53/// Check if Docker is available
54pub 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
62/// Get Docker disk usage summary
63pub 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    // Parse the output
80    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/// Docker disk usage summary
111#[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    /// Get total size
125    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    /// Get total reclaimable
133    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    /// Convert to artifacts
141    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
202/// Parse Docker size string (e.g., "1.5GB", "100MB")
203fn parse_docker_size(s: &str) -> u64 {
204    let s = s.trim();
205
206    // Remove percentage in parentheses
207    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
228/// Clean Docker artifacts
229pub 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    // Try to parse reclaimed space from output
247    let stdout = String::from_utf8_lossy(&output.stdout);
248    let reclaimed = parse_reclaimed_space(&stdout);
249
250    Ok(reclaimed)
251}
252
253/// Parse reclaimed space from docker prune output
254fn parse_reclaimed_space(output: &str) -> u64 {
255    // Docker outputs like "Total reclaimed space: 1.5GB"
256    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}