docker_wrapper/command/system/
prune.rs

1//! Docker system prune command implementation.
2
3use crate::command::{CommandExecutor, DockerCommand};
4use crate::error::Result;
5use async_trait::async_trait;
6use serde::Deserialize;
7use std::collections::HashMap;
8
9/// Result from system prune operation
10#[derive(Debug, Clone, Deserialize)]
11#[serde(rename_all = "PascalCase")]
12pub struct PruneResult {
13    /// List of deleted container IDs
14    #[serde(default)]
15    pub containers_deleted: Vec<String>,
16
17    /// Space reclaimed from containers in bytes
18    #[serde(default)]
19    pub containers_space_reclaimed: u64,
20
21    /// List of deleted image IDs
22    #[serde(default)]
23    pub images_deleted: Vec<String>,
24
25    /// Space reclaimed from images in bytes
26    #[serde(default)]
27    pub images_space_reclaimed: u64,
28
29    /// List of deleted network IDs
30    #[serde(default)]
31    pub networks_deleted: Vec<String>,
32
33    /// List of deleted volume names
34    #[serde(default)]
35    pub volumes_deleted: Vec<String>,
36
37    /// Space reclaimed from volumes in bytes
38    #[serde(default)]
39    pub volumes_space_reclaimed: u64,
40
41    /// Build cache entries deleted
42    #[serde(default)]
43    pub build_cache_deleted: Vec<String>,
44
45    /// Total space reclaimed in bytes
46    #[serde(default)]
47    pub space_reclaimed: u64,
48}
49
50/// Docker system prune command
51///
52/// Remove unused data including:
53/// - All stopped containers
54/// - All networks not used by at least one container
55/// - All dangling images
56/// - All dangling build cache
57/// - Optionally all volumes not used by at least one container
58#[derive(Debug, Clone)]
59pub struct SystemPruneCommand {
60    /// Remove all unused images, not just dangling ones
61    all: bool,
62
63    /// Prune volumes
64    volumes: bool,
65
66    /// Do not prompt for confirmation
67    force: bool,
68
69    /// Provide filter values
70    filter: HashMap<String, String>,
71
72    /// Command executor
73    pub executor: CommandExecutor,
74}
75
76impl SystemPruneCommand {
77    /// Create a new system prune command
78    #[must_use]
79    pub fn new() -> Self {
80        Self {
81            all: false,
82            volumes: false,
83            force: false,
84            filter: HashMap::new(),
85            executor: CommandExecutor::new(),
86        }
87    }
88
89    /// Remove all unused images, not just dangling ones
90    #[must_use]
91    pub fn all(mut self) -> Self {
92        self.all = true;
93        self
94    }
95
96    /// Prune volumes
97    #[must_use]
98    pub fn volumes(mut self) -> Self {
99        self.volumes = true;
100        self
101    }
102
103    /// Do not prompt for confirmation
104    #[must_use]
105    pub fn force(mut self) -> Self {
106        self.force = true;
107        self
108    }
109
110    /// Add a filter (e.g., "until=24h", "label=foo=bar")
111    #[must_use]
112    pub fn filter(mut self, key: &str, value: &str) -> Self {
113        self.filter.insert(key.to_string(), value.to_string());
114        self
115    }
116
117    /// Execute the system prune command
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if the command fails to execute or if Docker is not available.
122    pub async fn run(&self) -> Result<PruneResult> {
123        self.execute().await
124    }
125}
126
127impl Default for SystemPruneCommand {
128    fn default() -> Self {
129        Self::new()
130    }
131}
132
133#[async_trait]
134impl DockerCommand for SystemPruneCommand {
135    type Output = PruneResult;
136
137    fn build_command_args(&self) -> Vec<String> {
138        let mut args = vec!["system".to_string(), "prune".to_string()];
139
140        if self.all {
141            args.push("--all".to_string());
142        }
143
144        if self.volumes {
145            args.push("--volumes".to_string());
146        }
147
148        if self.force {
149            args.push("--force".to_string());
150        }
151
152        for (key, value) in &self.filter {
153            args.push("--filter".to_string());
154            args.push(format!("{key}={value}"));
155        }
156
157        args.extend(self.executor.raw_args.clone());
158        args
159    }
160
161    fn get_executor(&self) -> &CommandExecutor {
162        &self.executor
163    }
164
165    fn get_executor_mut(&mut self) -> &mut CommandExecutor {
166        &mut self.executor
167    }
168
169    async fn execute(&self) -> Result<Self::Output> {
170        let args = self.build_command_args();
171        let command_name = args[0].clone();
172        let command_args = args[1..].to_vec();
173        let output = self
174            .executor
175            .execute_command(&command_name, command_args)
176            .await?;
177
178        // Parse the output to extract deleted items and space reclaimed
179        // Docker system prune doesn't return JSON by default, so we need to parse text
180        let stdout = &output.stdout;
181
182        let mut result = PruneResult {
183            containers_deleted: Vec::new(),
184            containers_space_reclaimed: 0,
185            images_deleted: Vec::new(),
186            images_space_reclaimed: 0,
187            networks_deleted: Vec::new(),
188            volumes_deleted: Vec::new(),
189            volumes_space_reclaimed: 0,
190            build_cache_deleted: Vec::new(),
191            space_reclaimed: 0,
192        };
193
194        // Parse the text output to extract information
195        // This is a simplified parser - actual implementation would be more robust
196        for line in stdout.lines() {
197            if line.contains("Total reclaimed space:") {
198                // Extract the space value
199                if let Some(space_str) = line.split(':').nth(1) {
200                    result.space_reclaimed = parse_size(space_str.trim());
201                }
202            }
203            // Additional parsing for containers, images, etc. would go here
204        }
205
206        Ok(result)
207    }
208}
209
210/// Parse size string (e.g., "1.5GB", "100MB") to 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) -> u64 {
215    // Remove any whitespace
216    let size_str = size_str.trim();
217
218    // Try to find the numeric part and unit
219    let mut numeric_part = String::new();
220    let mut unit_part = String::new();
221    let mut found_dot = false;
222
223    for ch in size_str.chars() {
224        if ch.is_ascii_digit() || (ch == '.' && !found_dot) {
225            numeric_part.push(ch);
226            if ch == '.' {
227                found_dot = true;
228            }
229        } else if ch.is_ascii_alphabetic() {
230            unit_part.push(ch);
231        }
232    }
233
234    // Parse the numeric value
235    let value: f64 = numeric_part.parse().unwrap_or(0.0);
236
237    // Convert based on unit
238    let multiplier = match unit_part.to_uppercase().as_str() {
239        "KB" | "K" => 1_024,
240        "MB" | "M" => 1_024 * 1_024,
241        "GB" | "G" => 1_024 * 1_024 * 1_024,
242        "TB" | "T" => 1_024_u64.pow(4),
243        _ => 1, // Includes "B" and empty string
244    };
245
246    (value * multiplier as f64) as u64
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn test_parse_size() {
255        assert_eq!(parse_size("100B"), 100);
256        assert_eq!(parse_size("1KB"), 1024);
257        assert_eq!(parse_size("1.5KB"), 1536);
258        assert_eq!(parse_size("2MB"), 2 * 1024 * 1024);
259        assert_eq!(parse_size("1GB"), 1024 * 1024 * 1024);
260        #[allow(clippy::cast_possible_truncation)]
261        #[allow(clippy::cast_sign_loss)]
262        {
263            assert_eq!(parse_size("1.5GB"), (1.5 * 1024.0 * 1024.0 * 1024.0) as u64);
264        }
265    }
266
267    #[test]
268    fn test_system_prune_builder() {
269        let cmd = SystemPruneCommand::new()
270            .all()
271            .volumes()
272            .force()
273            .filter("until", "24h");
274
275        let args = cmd.build_command_args();
276        assert_eq!(args[0], "system");
277        assert!(args.contains(&"prune".to_string()));
278        assert!(args.contains(&"--all".to_string()));
279        assert!(args.contains(&"--volumes".to_string()));
280        assert!(args.contains(&"--force".to_string()));
281        assert!(args.contains(&"--filter".to_string()));
282        assert!(args.contains(&"until=24h".to_string()));
283    }
284}