docker_wrapper/command/builder/
prune.rs

1//! Docker builder prune command
2//!
3//! Remove build cache
4
5use crate::command::{CommandExecutor, DockerCommand};
6use crate::error::Result;
7use async_trait::async_trait;
8use std::collections::HashMap;
9
10/// `docker builder prune` command to remove build cache
11///
12/// # Example
13/// ```no_run
14/// use docker_wrapper::command::builder::BuilderPruneCommand;
15/// use docker_wrapper::DockerCommand;
16///
17/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
18/// // Remove all build cache
19/// let result = BuilderPruneCommand::new()
20///     .all()
21///     .force()
22///     .execute()
23///     .await?;
24///
25/// println!("Reclaimed {} bytes", result.space_reclaimed.unwrap_or(0));
26/// # Ok(())
27/// # }
28/// ```
29#[derive(Debug, Clone)]
30pub struct BuilderPruneCommand {
31    /// Remove all unused build cache, not just dangling ones
32    all: bool,
33    /// Provide filter values
34    filters: HashMap<String, String>,
35    /// Do not prompt for confirmation
36    force: bool,
37    /// Amount of disk storage to keep for cache
38    keep_storage: Option<String>,
39    /// Command executor
40    pub executor: CommandExecutor,
41}
42
43/// Result of builder prune operation
44#[derive(Debug)]
45pub struct BuilderPruneResult {
46    /// IDs of deleted build cache entries
47    pub deleted_cache_ids: Vec<String>,
48    /// Amount of disk space reclaimed in bytes
49    pub space_reclaimed: Option<u64>,
50    /// Human-readable space reclaimed (e.g., "2.5GB")
51    pub space_reclaimed_str: Option<String>,
52    /// Standard output from the command
53    pub stdout: String,
54    /// Standard error from the command
55    pub stderr: String,
56    /// Exit code
57    pub exit_code: i32,
58}
59
60impl BuilderPruneCommand {
61    /// Create a new builder prune command
62    #[must_use]
63    pub fn new() -> Self {
64        Self {
65            all: false,
66            filters: HashMap::new(),
67            force: false,
68            keep_storage: None,
69            executor: CommandExecutor::new(),
70        }
71    }
72
73    /// Remove all unused build cache, not just dangling ones
74    #[must_use]
75    pub fn all(mut self) -> Self {
76        self.all = true;
77        self
78    }
79
80    /// Add a filter to the prune operation
81    ///
82    /// Common filters:
83    /// - `until=<timestamp>` - only remove cache created before given timestamp
84    /// - `until=24h` - only remove cache older than 24 hours
85    #[must_use]
86    pub fn filter(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
87        self.filters.insert(key.into(), value.into());
88        self
89    }
90
91    /// Do not prompt for confirmation
92    #[must_use]
93    pub fn force(mut self) -> Self {
94        self.force = true;
95        self
96    }
97
98    /// Amount of disk storage to keep for cache
99    ///
100    /// # Example
101    /// ```no_run
102    /// # use docker_wrapper::command::builder::BuilderPruneCommand;
103    /// # use docker_wrapper::DockerCommand;
104    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
105    /// BuilderPruneCommand::new()
106    ///     .keep_storage("5GB")
107    ///     .execute()
108    ///     .await?;
109    /// # Ok(())
110    /// # }
111    /// ```
112    #[must_use]
113    pub fn keep_storage(mut self, size: impl Into<String>) -> Self {
114        self.keep_storage = Some(size.into());
115        self
116    }
117
118    /// Parse the prune output to extract cache IDs and space reclaimed
119    fn parse_output(output: &str) -> (Vec<String>, Option<u64>, Option<String>) {
120        let mut cache_ids = Vec::new();
121        let mut space_reclaimed = None;
122        let mut space_reclaimed_str = None;
123
124        for line in output.lines() {
125            // Parse deleted cache entries (format: "Deleted: sha256:...")
126            if line.starts_with("Deleted:") || line.starts_with("deleted:") {
127                if let Some(id) = line.split_whitespace().nth(1) {
128                    cache_ids.push(id.to_string());
129                }
130            }
131
132            // Parse total reclaimed space
133            if line.contains("Total reclaimed space:") || line.contains("total reclaimed space:") {
134                space_reclaimed_str = line.split(':').nth(1).map(|s| s.trim().to_string());
135
136                // Try to parse the bytes value
137                if let Some(size_str) = &space_reclaimed_str {
138                    space_reclaimed = parse_size(size_str);
139                }
140            }
141        }
142
143        (cache_ids, space_reclaimed, space_reclaimed_str)
144    }
145}
146
147impl Default for BuilderPruneCommand {
148    fn default() -> Self {
149        Self::new()
150    }
151}
152
153#[async_trait]
154impl DockerCommand for BuilderPruneCommand {
155    type Output = BuilderPruneResult;
156
157    fn get_executor(&self) -> &CommandExecutor {
158        &self.executor
159    }
160
161    fn get_executor_mut(&mut self) -> &mut CommandExecutor {
162        &mut self.executor
163    }
164
165    fn build_command_args(&self) -> Vec<String> {
166        let mut args = vec!["builder".to_string(), "prune".to_string()];
167
168        if self.all {
169            args.push("--all".to_string());
170        }
171
172        for (key, value) in &self.filters {
173            args.push("--filter".to_string());
174            args.push(format!("{key}={value}"));
175        }
176
177        if self.force {
178            args.push("--force".to_string());
179        }
180
181        if let Some(storage) = &self.keep_storage {
182            args.push("--keep-storage".to_string());
183            args.push(storage.clone());
184        }
185
186        // Add any raw arguments
187        args.extend(self.executor.raw_args.clone());
188
189        args
190    }
191
192    async fn execute(&self) -> Result<Self::Output> {
193        let args = self.build_command_args();
194        let output = self.executor.execute_command("docker", args).await?;
195
196        let (deleted_cache_ids, space_reclaimed, space_reclaimed_str) =
197            Self::parse_output(&output.stdout);
198
199        Ok(BuilderPruneResult {
200            deleted_cache_ids,
201            space_reclaimed,
202            space_reclaimed_str,
203            stdout: output.stdout,
204            stderr: output.stderr,
205            exit_code: output.exit_code,
206        })
207    }
208}
209
210/// Parse a size string (e.g., "2.5GB", "100MB") into bytes
211#[allow(clippy::cast_possible_truncation)]
212#[allow(clippy::cast_sign_loss)]
213#[allow(clippy::cast_precision_loss)]
214fn parse_size(size_str: &str) -> Option<u64> {
215    let size_str = size_str.trim();
216
217    // Try to extract number and unit
218    let (num_str, unit) = if let Some(pos) = size_str.find(|c: char| c.is_alphabetic()) {
219        (&size_str[..pos], &size_str[pos..])
220    } else {
221        return size_str.parse().ok();
222    };
223
224    let number: f64 = num_str.trim().parse().ok()?;
225
226    let multiplier = match unit.to_uppercase().as_str() {
227        "B" | "" => 1.0,
228        "KB" | "K" => 1024.0,
229        "MB" | "M" => 1024.0 * 1024.0,
230        "GB" | "G" => 1024.0 * 1024.0 * 1024.0,
231        "TB" | "T" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
232        _ => return None,
233    };
234
235    if number.is_sign_negative() || !number.is_finite() {
236        return None;
237    }
238    let result = (number * multiplier).round();
239    if result > u64::MAX as f64 {
240        return None;
241    }
242    Some(result as u64)
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_builder_prune_basic() {
251        let cmd = BuilderPruneCommand::new();
252        let args = cmd.build_command_args();
253        assert_eq!(args, vec!["builder", "prune"]);
254    }
255
256    #[test]
257    fn test_builder_prune_all_options() {
258        let cmd = BuilderPruneCommand::new()
259            .all()
260            .filter("until", "24h")
261            .force()
262            .keep_storage("5GB");
263
264        let args = cmd.build_command_args();
265        assert!(args.contains(&"--all".to_string()));
266        assert!(args.contains(&"--filter".to_string()));
267        assert!(args.contains(&"until=24h".to_string()));
268        assert!(args.contains(&"--force".to_string()));
269        assert!(args.contains(&"--keep-storage".to_string()));
270        assert!(args.contains(&"5GB".to_string()));
271    }
272
273    #[test]
274    fn test_parse_size() {
275        assert_eq!(parse_size("100"), Some(100));
276        assert_eq!(parse_size("1KB"), Some(1024));
277        assert_eq!(parse_size("1.5KB"), Some(1536));
278        assert_eq!(parse_size("2MB"), Some(2 * 1024 * 1024));
279        assert_eq!(parse_size("1GB"), Some(1024 * 1024 * 1024));
280        assert_eq!(parse_size("2.5GB"), Some(2_684_354_560));
281        assert_eq!(parse_size("1TB"), Some(1_099_511_627_776));
282    }
283
284    #[test]
285    fn test_parse_output() {
286        let output = r"Deleted: sha256:abc123
287Deleted: sha256:def456
288Total reclaimed space: 2.5GB";
289
290        let (ids, bytes, str_val) = BuilderPruneCommand::parse_output(output);
291        assert_eq!(ids.len(), 2);
292        assert!(ids.contains(&"sha256:abc123".to_string()));
293        assert!(ids.contains(&"sha256:def456".to_string()));
294        assert_eq!(bytes, Some(2_684_354_560));
295        assert_eq!(str_val, Some("2.5GB".to_string()));
296    }
297
298    #[test]
299    fn test_builder_prune_extensibility() {
300        let mut cmd = BuilderPruneCommand::new();
301        cmd.get_executor_mut()
302            .raw_args
303            .push("--custom-flag".to_string());
304
305        let args = cmd.build_command_args();
306        assert!(args.contains(&"--custom-flag".to_string()));
307    }
308}