docker_wrapper/command/
rmi.rs

1//! Docker rmi command implementation.
2//!
3//! This module provides the `docker rmi` command for removing Docker images.
4
5use super::{CommandExecutor, CommandOutput, DockerCommand};
6use crate::error::Result;
7use async_trait::async_trait;
8
9/// Docker rmi command builder
10///
11/// Remove one or more images.
12///
13/// # Example
14///
15/// ```no_run
16/// use docker_wrapper::RmiCommand;
17///
18/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
19/// // Remove a single image
20/// RmiCommand::new("old-image:v1.0")
21///     .run()
22///     .await?;
23///
24/// // Force remove multiple images
25/// RmiCommand::new_multiple(vec!["image1", "image2", "image3"])
26///     .force()
27///     .run()
28///     .await?;
29/// # Ok(())
30/// # }
31/// ```
32#[derive(Debug, Clone)]
33pub struct RmiCommand {
34    /// Image names or IDs to remove
35    images: Vec<String>,
36    /// Force removal of images
37    force: bool,
38    /// Do not delete untagged parents
39    no_prune: bool,
40    /// Command executor
41    pub executor: CommandExecutor,
42}
43
44impl RmiCommand {
45    /// Create a new rmi command for a single image
46    ///
47    /// # Example
48    ///
49    /// ```
50    /// use docker_wrapper::RmiCommand;
51    ///
52    /// let cmd = RmiCommand::new("old-image:latest");
53    /// ```
54    #[must_use]
55    pub fn new(image: impl Into<String>) -> Self {
56        Self {
57            images: vec![image.into()],
58            force: false,
59            no_prune: false,
60            executor: CommandExecutor::new(),
61        }
62    }
63
64    /// Create a new rmi command for multiple images
65    ///
66    /// # Example
67    ///
68    /// ```
69    /// use docker_wrapper::RmiCommand;
70    ///
71    /// let cmd = RmiCommand::new_multiple(vec!["image1:latest", "image2:v1.0"]);
72    /// ```
73    #[must_use]
74    pub fn new_multiple(images: Vec<impl Into<String>>) -> Self {
75        Self {
76            images: images.into_iter().map(Into::into).collect(),
77            force: false,
78            no_prune: false,
79            executor: CommandExecutor::new(),
80        }
81    }
82
83    /// Add another image to remove
84    #[must_use]
85    pub fn image(mut self, image: impl Into<String>) -> Self {
86        self.images.push(image.into());
87        self
88    }
89
90    /// Force removal of the images
91    ///
92    /// # Example
93    ///
94    /// ```
95    /// use docker_wrapper::RmiCommand;
96    ///
97    /// let cmd = RmiCommand::new("stubborn-image:latest")
98    ///     .force();
99    /// ```
100    #[must_use]
101    pub fn force(mut self) -> Self {
102        self.force = true;
103        self
104    }
105
106    /// Do not delete untagged parents
107    #[must_use]
108    pub fn no_prune(mut self) -> Self {
109        self.no_prune = true;
110        self
111    }
112
113    /// Execute the rmi command
114    ///
115    /// # Errors
116    /// Returns an error if:
117    /// - The Docker daemon is not running
118    /// - Any of the specified images don't exist
119    /// - Images are in use by containers (unless force is used)
120    ///
121    /// # Example
122    ///
123    /// ```no_run
124    /// use docker_wrapper::RmiCommand;
125    ///
126    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
127    /// let result = RmiCommand::new("unused-image:latest")
128    ///     .run()
129    ///     .await?;
130    ///
131    /// if result.success() {
132    ///     println!("Removed {} images", result.removed_images().len());
133    /// }
134    /// # Ok(())
135    /// # }
136    /// ```
137    pub async fn run(&self) -> Result<RmiResult> {
138        let output = self.execute().await?;
139
140        // Parse removed images from output
141        let removed_images = Self::parse_removed_images(&output.stdout);
142
143        Ok(RmiResult {
144            output,
145            removed_images,
146        })
147    }
148
149    /// Parse removed image IDs from the command output
150    fn parse_removed_images(stdout: &str) -> Vec<String> {
151        let mut removed = Vec::new();
152
153        for line in stdout.lines() {
154            let line = line.trim();
155            if line.starts_with("Deleted:") {
156                if let Some(id) = line.strip_prefix("Deleted:") {
157                    removed.push(id.trim().to_string());
158                }
159            } else if line.starts_with("Untagged:") {
160                if let Some(tag) = line.strip_prefix("Untagged:") {
161                    removed.push(tag.trim().to_string());
162                }
163            }
164        }
165
166        removed
167    }
168}
169
170#[async_trait]
171impl DockerCommand for RmiCommand {
172    type Output = CommandOutput;
173
174    fn build_command_args(&self) -> Vec<String> {
175        let mut args = vec!["rmi".to_string()];
176
177        if self.force {
178            args.push("--force".to_string());
179        }
180
181        if self.no_prune {
182            args.push("--no-prune".to_string());
183        }
184
185        // Add image names/IDs
186        args.extend(self.images.clone());
187
188        args.extend(self.executor.raw_args.clone());
189        args
190    }
191
192    fn get_executor(&self) -> &CommandExecutor {
193        &self.executor
194    }
195
196    fn get_executor_mut(&mut self) -> &mut CommandExecutor {
197        &mut self.executor
198    }
199
200    async fn execute(&self) -> Result<Self::Output> {
201        if self.images.is_empty() {
202            return Err(crate::error::Error::invalid_config(
203                "No images specified for removal",
204            ));
205        }
206
207        let args = self.build_command_args();
208        let command_name = args[0].clone();
209        let command_args = args[1..].to_vec();
210        self.executor
211            .execute_command(&command_name, command_args)
212            .await
213    }
214}
215
216/// Result from the rmi command
217#[derive(Debug, Clone)]
218pub struct RmiResult {
219    /// Raw command output
220    pub output: CommandOutput,
221    /// List of removed image IDs/tags
222    pub removed_images: Vec<String>,
223}
224
225impl RmiResult {
226    /// Check if the removal was successful
227    #[must_use]
228    pub fn success(&self) -> bool {
229        self.output.success
230    }
231
232    /// Get the list of removed images
233    #[must_use]
234    pub fn removed_images(&self) -> &[String] {
235        &self.removed_images
236    }
237
238    /// Get the count of removed images
239    #[must_use]
240    pub fn removed_count(&self) -> usize {
241        self.removed_images.len()
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_rmi_single_image() {
251        let cmd = RmiCommand::new("test-image:latest");
252        let args = cmd.build_command_args();
253        assert_eq!(args, vec!["rmi", "test-image:latest"]);
254    }
255
256    #[test]
257    fn test_rmi_multiple_images() {
258        let cmd = RmiCommand::new_multiple(vec!["image1:latest", "image2:v1.0", "image3"]);
259        let args = cmd.build_command_args();
260        assert_eq!(args, vec!["rmi", "image1:latest", "image2:v1.0", "image3"]);
261    }
262
263    #[test]
264    fn test_rmi_with_force() {
265        let cmd = RmiCommand::new("stubborn-image:latest").force();
266        let args = cmd.build_command_args();
267        assert_eq!(args, vec!["rmi", "--force", "stubborn-image:latest"]);
268    }
269
270    #[test]
271    fn test_rmi_with_no_prune() {
272        let cmd = RmiCommand::new("test-image:latest").no_prune();
273        let args = cmd.build_command_args();
274        assert_eq!(args, vec!["rmi", "--no-prune", "test-image:latest"]);
275    }
276
277    #[test]
278    fn test_rmi_all_options() {
279        let cmd = RmiCommand::new("test-image:latest")
280            .image("another-image:v1.0")
281            .force()
282            .no_prune();
283        let args = cmd.build_command_args();
284        assert_eq!(
285            args,
286            vec![
287                "rmi",
288                "--force",
289                "--no-prune",
290                "test-image:latest",
291                "another-image:v1.0"
292            ]
293        );
294    }
295
296    #[test]
297    fn test_parse_removed_images() {
298        let output =
299            "Untagged: test-image:latest\nDeleted: sha256:abc123def456\nDeleted: sha256:789xyz123";
300        let removed = RmiCommand::parse_removed_images(output);
301        assert_eq!(
302            removed,
303            vec![
304                "test-image:latest",
305                "sha256:abc123def456",
306                "sha256:789xyz123"
307            ]
308        );
309    }
310
311    #[test]
312    fn test_parse_removed_images_empty() {
313        let removed = RmiCommand::parse_removed_images("");
314        assert!(removed.is_empty());
315    }
316}