docker_wrapper/command/
images.rs

1//! Docker Images Command Implementation
2//!
3//! This module provides a comprehensive implementation of the `docker images` command,
4//! supporting all native Docker images options for listing local images.
5//!
6//! # Examples
7//!
8//! ## Basic Usage
9//!
10//! ```no_run
11//! use docker_wrapper::ImagesCommand;
12//! use docker_wrapper::DockerCommand;
13//!
14//! #[tokio::main]
15//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
16//!     // List all images
17//!     let images_cmd = ImagesCommand::new();
18//!     let output = images_cmd.execute().await?;
19//!     println!("Images listed: {}", output.success());
20//!     Ok(())
21//! }
22//! ```
23//!
24//! ## Advanced Usage
25//!
26//! ```no_run
27//! use docker_wrapper::ImagesCommand;
28//! use docker_wrapper::DockerCommand;
29//!
30//! #[tokio::main]
31//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
32//!     // List images with filtering and JSON format
33//!     let images_cmd = ImagesCommand::new()
34//!         .repository("nginx")
35//!         .all()
36//!         .filter("dangling=false")
37//!         .format_json()
38//!         .digests()
39//!         .no_trunc();
40//!
41//!     let output = images_cmd.execute().await?;
42//!     println!("Filtered images: {}", output.success());
43//!     Ok(())
44//! }
45//! ```
46
47use super::{CommandExecutor, CommandOutput, DockerCommand};
48use crate::error::Result;
49use async_trait::async_trait;
50use serde_json::Value;
51
52/// Docker Images Command Builder
53///
54/// Implements the `docker images` command for listing local Docker images.
55///
56/// # Docker Images Overview
57///
58/// The images command lists Docker images stored locally on the system. It supports:
59/// - Repository and tag filtering
60/// - Multiple output formats (table, JSON, custom templates)
61/// - Image metadata display (digests, sizes, creation dates)
62/// - Advanced filtering by various criteria
63/// - Quiet mode for scripts
64///
65/// # Image Information
66///
67/// Each image entry typically includes:
68/// - Repository name
69/// - Tag
70/// - Image ID
71/// - Creation date
72/// - Size
73/// - Optionally: digests, intermediate layers
74///
75/// # Examples
76///
77/// ```no_run
78/// use docker_wrapper::ImagesCommand;
79/// use docker_wrapper::DockerCommand;
80///
81/// #[tokio::main]
82/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
83///     // List all nginx images
84///     let output = ImagesCommand::new()
85///         .repository("nginx")
86///         .execute()
87///         .await?;
88///
89///     println!("Images success: {}", output.success());
90///     Ok(())
91/// }
92/// ```
93#[derive(Debug, Clone)]
94#[allow(clippy::struct_excessive_bools)]
95pub struct ImagesCommand {
96    /// Optional repository filter (e.g., "nginx", "nginx:alpine")
97    repository: Option<String>,
98    /// Show all images (including intermediate images)
99    all: bool,
100    /// Show digests
101    digests: bool,
102    /// Filter output based on conditions
103    filters: Vec<String>,
104    /// Output format
105    format: Option<String>,
106    /// Don't truncate output
107    no_trunc: bool,
108    /// Only show image IDs
109    quiet: bool,
110    /// List multi-platform images as a tree (experimental)
111    tree: bool,
112    /// Command executor for handling raw arguments and execution
113    pub executor: CommandExecutor,
114}
115
116/// Represents a Docker image from the output
117#[derive(Debug, Clone, PartialEq)]
118pub struct ImageInfo {
119    /// Repository name
120    pub repository: String,
121    /// Tag
122    pub tag: String,
123    /// Image ID
124    pub image_id: String,
125    /// Creation date/time
126    pub created: String,
127    /// Image size
128    pub size: String,
129    /// Digest (if available)
130    pub digest: Option<String>,
131}
132
133/// Output from the images command with parsed image information
134#[derive(Debug, Clone)]
135pub struct ImagesOutput {
136    /// Raw command output
137    pub output: CommandOutput,
138    /// Parsed image information (if output is parseable)
139    pub images: Vec<ImageInfo>,
140}
141
142impl ImagesCommand {
143    /// Create a new `ImagesCommand` instance
144    ///
145    /// # Examples
146    ///
147    /// ```
148    /// use docker_wrapper::ImagesCommand;
149    ///
150    /// let images_cmd = ImagesCommand::new();
151    /// ```
152    #[must_use]
153    pub fn new() -> Self {
154        Self {
155            repository: None,
156            all: false,
157            digests: false,
158            filters: Vec::new(),
159            format: None,
160            no_trunc: false,
161            quiet: false,
162            tree: false,
163            executor: CommandExecutor::new(),
164        }
165    }
166
167    /// Filter images by repository name (and optionally tag)
168    ///
169    /// # Arguments
170    ///
171    /// * `repository` - Repository name (e.g., "nginx", "nginx:alpine", "ubuntu:20.04")
172    ///
173    /// # Examples
174    ///
175    /// ```
176    /// use docker_wrapper::ImagesCommand;
177    ///
178    /// let images_cmd = ImagesCommand::new()
179    ///     .repository("nginx:alpine");
180    /// ```
181    #[must_use]
182    pub fn repository<S: Into<String>>(mut self, repository: S) -> Self {
183        self.repository = Some(repository.into());
184        self
185    }
186
187    /// Show all images (including intermediate images)
188    ///
189    /// By default, Docker hides intermediate images. This option shows them all.
190    ///
191    /// # Examples
192    ///
193    /// ```
194    /// use docker_wrapper::ImagesCommand;
195    ///
196    /// let images_cmd = ImagesCommand::new()
197    ///     .all();
198    /// ```
199    #[must_use]
200    pub fn all(mut self) -> Self {
201        self.all = true;
202        self
203    }
204
205    /// Show digests
206    ///
207    /// Displays the digest (SHA256 hash) for each image.
208    ///
209    /// # Examples
210    ///
211    /// ```
212    /// use docker_wrapper::ImagesCommand;
213    ///
214    /// let images_cmd = ImagesCommand::new()
215    ///     .digests();
216    /// ```
217    #[must_use]
218    pub fn digests(mut self) -> Self {
219        self.digests = true;
220        self
221    }
222
223    /// Add a filter condition
224    ///
225    /// Common filters:
226    /// - `dangling=true|false` - Show dangling images
227    /// - `label=<key>` or `label=<key>=<value>` - Filter by label
228    /// - `before=<image>` - Images created before this image
229    /// - `since=<image>` - Images created since this image
230    /// - `reference=<pattern>` - Filter by repository name pattern
231    ///
232    /// # Examples
233    ///
234    /// ```
235    /// use docker_wrapper::ImagesCommand;
236    ///
237    /// let images_cmd = ImagesCommand::new()
238    ///     .filter("dangling=true")
239    ///     .filter("label=maintainer=nginx");
240    /// ```
241    #[must_use]
242    pub fn filter<S: Into<String>>(mut self, filter: S) -> Self {
243        self.filters.push(filter.into());
244        self
245    }
246
247    /// Add multiple filter conditions
248    ///
249    /// # Examples
250    ///
251    /// ```
252    /// use docker_wrapper::ImagesCommand;
253    ///
254    /// let images_cmd = ImagesCommand::new()
255    ///     .filters(vec!["dangling=false", "label=version=latest"]);
256    /// ```
257    #[must_use]
258    pub fn filters<I, S>(mut self, filters: I) -> Self
259    where
260        I: IntoIterator<Item = S>,
261        S: Into<String>,
262    {
263        self.filters
264            .extend(filters.into_iter().map(std::convert::Into::into));
265        self
266    }
267
268    /// Set custom output format
269    ///
270    /// # Examples
271    ///
272    /// ```
273    /// use docker_wrapper::ImagesCommand;
274    ///
275    /// let images_cmd = ImagesCommand::new()
276    ///     .format("table {{.Repository}}:{{.Tag}}\t{{.Size}}");
277    /// ```
278    #[must_use]
279    pub fn format<S: Into<String>>(mut self, format: S) -> Self {
280        self.format = Some(format.into());
281        self
282    }
283
284    /// Format output as table (default)
285    ///
286    /// # Examples
287    ///
288    /// ```
289    /// use docker_wrapper::ImagesCommand;
290    ///
291    /// let images_cmd = ImagesCommand::new()
292    ///     .format_table();
293    /// ```
294    #[must_use]
295    pub fn format_table(mut self) -> Self {
296        self.format = Some("table".to_string());
297        self
298    }
299
300    /// Format output as JSON
301    ///
302    /// # Examples
303    ///
304    /// ```
305    /// use docker_wrapper::ImagesCommand;
306    ///
307    /// let images_cmd = ImagesCommand::new()
308    ///     .format_json();
309    /// ```
310    #[must_use]
311    pub fn format_json(mut self) -> Self {
312        self.format = Some("json".to_string());
313        self
314    }
315
316    /// Don't truncate output
317    ///
318    /// By default, Docker truncates long values. This shows full values.
319    ///
320    /// # Examples
321    ///
322    /// ```
323    /// use docker_wrapper::ImagesCommand;
324    ///
325    /// let images_cmd = ImagesCommand::new()
326    ///     .no_trunc();
327    /// ```
328    #[must_use]
329    pub fn no_trunc(mut self) -> Self {
330        self.no_trunc = true;
331        self
332    }
333
334    /// Only show image IDs
335    ///
336    /// Useful for scripting and automation.
337    ///
338    /// # Examples
339    ///
340    /// ```
341    /// use docker_wrapper::ImagesCommand;
342    ///
343    /// let images_cmd = ImagesCommand::new()
344    ///     .quiet();
345    /// ```
346    #[must_use]
347    pub fn quiet(mut self) -> Self {
348        self.quiet = true;
349        self
350    }
351
352    /// List multi-platform images as a tree (experimental)
353    ///
354    /// This is an experimental Docker feature for displaying multi-platform images.
355    ///
356    /// # Examples
357    ///
358    /// ```
359    /// use docker_wrapper::ImagesCommand;
360    ///
361    /// let images_cmd = ImagesCommand::new()
362    ///     .tree();
363    /// ```
364    #[must_use]
365    pub fn tree(mut self) -> Self {
366        self.tree = true;
367        self
368    }
369
370    /// Build the command arguments
371    ///
372    /// This method constructs the complete argument list for the docker images command.
373    fn build_command_args(&self) -> Vec<String> {
374        let mut args = Vec::new();
375
376        // Add all flag
377        if self.all {
378            args.push("--all".to_string());
379        }
380
381        // Add digests flag
382        if self.digests {
383            args.push("--digests".to_string());
384        }
385
386        // Add filters
387        for filter in &self.filters {
388            args.push("--filter".to_string());
389            args.push(filter.clone());
390        }
391
392        // Add format
393        if let Some(ref format) = self.format {
394            args.push("--format".to_string());
395            args.push(format.clone());
396        }
397
398        // Add no-trunc flag
399        if self.no_trunc {
400            args.push("--no-trunc".to_string());
401        }
402
403        // Add quiet flag
404        if self.quiet {
405            args.push("--quiet".to_string());
406        }
407
408        // Add tree flag
409        if self.tree {
410            args.push("--tree".to_string());
411        }
412
413        // Add repository filter (must be last)
414        if let Some(ref repository) = self.repository {
415            args.push(repository.clone());
416        }
417
418        args
419    }
420
421    /// Parse the output to extract image information
422    ///
423    /// This attempts to parse the docker images output into structured data.
424    fn parse_output(&self, output: &CommandOutput) -> Vec<ImageInfo> {
425        if self.quiet {
426            // In quiet mode, output is just image IDs
427            return output
428                .stdout
429                .lines()
430                .filter(|line| !line.trim().is_empty())
431                .map(|line| ImageInfo {
432                    repository: "<unknown>".to_string(),
433                    tag: "<unknown>".to_string(),
434                    image_id: line.trim().to_string(),
435                    created: "<unknown>".to_string(),
436                    size: "<unknown>".to_string(),
437                    digest: None,
438                })
439                .collect();
440        }
441
442        if let Some(ref format) = self.format {
443            if format == "json" {
444                return Self::parse_json_output(&output.stdout);
445            }
446        }
447
448        // Parse table format (default)
449        self.parse_table_output(&output.stdout)
450    }
451
452    /// Parse JSON format output
453    fn parse_json_output(stdout: &str) -> Vec<ImageInfo> {
454        let mut images = Vec::new();
455
456        for line in stdout.lines() {
457            if let Ok(json) = serde_json::from_str::<Value>(line) {
458                if let Some(obj) = json.as_object() {
459                    let repository = obj
460                        .get("Repository")
461                        .and_then(|v| v.as_str())
462                        .unwrap_or("<none>")
463                        .to_string();
464                    let tag = obj
465                        .get("Tag")
466                        .and_then(|v| v.as_str())
467                        .unwrap_or("<none>")
468                        .to_string();
469                    let image_id = obj
470                        .get("ID")
471                        .and_then(|v| v.as_str())
472                        .unwrap_or("")
473                        .to_string();
474                    let created = obj
475                        .get("CreatedAt")
476                        .and_then(|v| v.as_str())
477                        .unwrap_or("")
478                        .to_string();
479                    let size = obj
480                        .get("Size")
481                        .and_then(|v| v.as_str())
482                        .unwrap_or("")
483                        .to_string();
484                    let digest = obj.get("Digest").and_then(|v| v.as_str()).map(String::from);
485
486                    images.push(ImageInfo {
487                        repository,
488                        tag,
489                        image_id,
490                        created,
491                        size,
492                        digest,
493                    });
494                }
495            }
496        }
497
498        images
499    }
500
501    /// Parse table format output
502    fn parse_table_output(&self, stdout: &str) -> Vec<ImageInfo> {
503        let mut images = Vec::new();
504        let lines: Vec<&str> = stdout.lines().collect();
505
506        if lines.is_empty() {
507            return images;
508        }
509
510        // Skip header line if present
511        let data_lines = if lines[0].starts_with("REPOSITORY") {
512            &lines[1..]
513        } else {
514            &lines[..]
515        };
516
517        for line in data_lines {
518            if line.trim().is_empty() {
519                continue;
520            }
521
522            // Split by whitespace, but handle multi-word fields like "2 days ago"
523            let parts: Vec<&str> = line.split_whitespace().collect();
524            if parts.len() >= 5 {
525                let repository = parts[0].to_string();
526                let tag = parts[1].to_string();
527                let image_id = parts[2].to_string();
528
529                // Handle multi-word created field and size
530                let (created, size, digest) = if self.digests && parts.len() >= 7 {
531                    // With digests: REPO TAG IMAGE_ID DIGEST CREATED... SIZE
532                    let digest = Some(parts[3].to_string());
533                    let created_parts = &parts[4..parts.len() - 1];
534                    let created = created_parts.join(" ");
535                    let size = parts[parts.len() - 1].to_string();
536                    (created, size, digest)
537                } else if parts.len() >= 5 {
538                    // Without digests: REPO TAG IMAGE_ID CREATED... SIZE
539                    let created_parts = &parts[3..parts.len() - 1];
540                    let created = created_parts.join(" ");
541                    let size = parts[parts.len() - 1].to_string();
542                    (created, size, None)
543                } else {
544                    (String::new(), String::new(), None)
545                };
546
547                images.push(ImageInfo {
548                    repository,
549                    tag,
550                    image_id,
551                    created,
552                    size,
553                    digest,
554                });
555            }
556        }
557
558        images
559    }
560
561    /// Get the repository filter if set
562    ///
563    /// # Examples
564    ///
565    /// ```
566    /// use docker_wrapper::ImagesCommand;
567    ///
568    /// let images_cmd = ImagesCommand::new().repository("nginx");
569    /// assert_eq!(images_cmd.get_repository(), Some("nginx"));
570    /// ```
571    #[must_use]
572    pub fn get_repository(&self) -> Option<&str> {
573        self.repository.as_deref()
574    }
575
576    /// Check if showing all images
577    ///
578    /// # Examples
579    ///
580    /// ```
581    /// use docker_wrapper::ImagesCommand;
582    ///
583    /// let images_cmd = ImagesCommand::new().all();
584    /// assert!(images_cmd.is_all());
585    /// ```
586    #[must_use]
587    pub fn is_all(&self) -> bool {
588        self.all
589    }
590
591    /// Check if showing digests
592    ///
593    /// # Examples
594    ///
595    /// ```
596    /// use docker_wrapper::ImagesCommand;
597    ///
598    /// let images_cmd = ImagesCommand::new().digests();
599    /// assert!(images_cmd.is_digests());
600    /// ```
601    #[must_use]
602    pub fn is_digests(&self) -> bool {
603        self.digests
604    }
605
606    /// Check if quiet mode is enabled
607    ///
608    /// # Examples
609    ///
610    /// ```
611    /// use docker_wrapper::ImagesCommand;
612    ///
613    /// let images_cmd = ImagesCommand::new().quiet();
614    /// assert!(images_cmd.is_quiet());
615    /// ```
616    #[must_use]
617    pub fn is_quiet(&self) -> bool {
618        self.quiet
619    }
620
621    /// Check if no-trunc is enabled
622    ///
623    /// # Examples
624    ///
625    /// ```
626    /// use docker_wrapper::ImagesCommand;
627    ///
628    /// let images_cmd = ImagesCommand::new().no_trunc();
629    /// assert!(images_cmd.is_no_trunc());
630    /// ```
631    #[must_use]
632    pub fn is_no_trunc(&self) -> bool {
633        self.no_trunc
634    }
635
636    /// Check if tree mode is enabled
637    ///
638    /// # Examples
639    ///
640    /// ```
641    /// use docker_wrapper::ImagesCommand;
642    ///
643    /// let images_cmd = ImagesCommand::new().tree();
644    /// assert!(images_cmd.is_tree());
645    /// ```
646    #[must_use]
647    pub fn is_tree(&self) -> bool {
648        self.tree
649    }
650
651    /// Get the current filters
652    ///
653    /// # Examples
654    ///
655    /// ```
656    /// use docker_wrapper::ImagesCommand;
657    ///
658    /// let images_cmd = ImagesCommand::new()
659    ///     .filter("dangling=true");
660    /// assert_eq!(images_cmd.get_filters(), &["dangling=true"]);
661    /// ```
662    #[must_use]
663    pub fn get_filters(&self) -> &[String] {
664        &self.filters
665    }
666
667    /// Get the format if set
668    ///
669    /// # Examples
670    ///
671    /// ```
672    /// use docker_wrapper::ImagesCommand;
673    ///
674    /// let images_cmd = ImagesCommand::new().format_json();
675    /// assert_eq!(images_cmd.get_format(), Some("json"));
676    /// ```
677    #[must_use]
678    pub fn get_format(&self) -> Option<&str> {
679        self.format.as_deref()
680    }
681
682    /// Get a reference to the command executor
683    #[must_use]
684    pub fn get_executor(&self) -> &CommandExecutor {
685        &self.executor
686    }
687
688    /// Get a mutable reference to the command executor
689    #[must_use]
690    pub fn get_executor_mut(&mut self) -> &mut CommandExecutor {
691        &mut self.executor
692    }
693}
694
695impl Default for ImagesCommand {
696    fn default() -> Self {
697        Self::new()
698    }
699}
700
701impl ImagesOutput {
702    /// Check if the command was successful
703    ///
704    /// # Examples
705    ///
706    /// ```no_run
707    /// # use docker_wrapper::ImagesCommand;
708    /// # use docker_wrapper::DockerCommand;
709    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
710    /// let output = ImagesCommand::new().execute().await?;
711    /// if output.success() {
712    ///     println!("Images listed successfully");
713    /// }
714    /// # Ok(())
715    /// # }
716    /// ```
717    #[must_use]
718    pub fn success(&self) -> bool {
719        self.output.success
720    }
721
722    /// Get the number of images found
723    ///
724    /// # Examples
725    ///
726    /// ```no_run
727    /// # use docker_wrapper::ImagesCommand;
728    /// # use docker_wrapper::DockerCommand;
729    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
730    /// let output = ImagesCommand::new().execute().await?;
731    /// println!("Found {} images", output.image_count());
732    /// # Ok(())
733    /// # }
734    /// ```
735    #[must_use]
736    pub fn image_count(&self) -> usize {
737        self.images.len()
738    }
739
740    /// Get image IDs only
741    ///
742    /// # Examples
743    ///
744    /// ```no_run
745    /// # use docker_wrapper::ImagesCommand;
746    /// # use docker_wrapper::DockerCommand;
747    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
748    /// let output = ImagesCommand::new().execute().await?;
749    /// let ids = output.image_ids();
750    /// println!("Image IDs: {:?}", ids);
751    /// # Ok(())
752    /// # }
753    /// ```
754    #[must_use]
755    pub fn image_ids(&self) -> Vec<&str> {
756        self.images
757            .iter()
758            .map(|img| img.image_id.as_str())
759            .collect()
760    }
761
762    /// Filter images by repository name
763    ///
764    /// # Examples
765    ///
766    /// ```no_run
767    /// # use docker_wrapper::ImagesCommand;
768    /// # use docker_wrapper::DockerCommand;
769    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
770    /// let output = ImagesCommand::new().execute().await?;
771    /// let nginx_images = output.filter_by_repository("nginx");
772    /// println!("Nginx images: {}", nginx_images.len());
773    /// # Ok(())
774    /// # }
775    /// ```
776    #[must_use]
777    pub fn filter_by_repository(&self, repository: &str) -> Vec<&ImageInfo> {
778        self.images
779            .iter()
780            .filter(|img| img.repository == repository)
781            .collect()
782    }
783
784    /// Check if output is empty (no images)
785    ///
786    /// # Examples
787    ///
788    /// ```no_run
789    /// # use docker_wrapper::ImagesCommand;
790    /// # use docker_wrapper::DockerCommand;
791    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
792    /// let output = ImagesCommand::new().execute().await?;
793    /// if output.is_empty() {
794    ///     println!("No images found");
795    /// }
796    /// # Ok(())
797    /// # }
798    /// ```
799    #[must_use]
800    pub fn is_empty(&self) -> bool {
801        self.images.is_empty()
802    }
803}
804
805#[async_trait]
806impl DockerCommand for ImagesCommand {
807    type Output = ImagesOutput;
808
809    fn get_executor(&self) -> &CommandExecutor {
810        &self.executor
811    }
812
813    fn get_executor_mut(&mut self) -> &mut CommandExecutor {
814        &mut self.executor
815    }
816
817    fn build_command_args(&self) -> Vec<String> {
818        self.build_command_args()
819    }
820
821    async fn execute(&self) -> Result<Self::Output> {
822        let args = self.build_command_args();
823        let output = self.executor.execute_command("docker", args).await?;
824
825        let images = self.parse_output(&output);
826
827        Ok(ImagesOutput { output, images })
828    }
829}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834
835    #[test]
836    fn test_images_command_basic() {
837        let images_cmd = ImagesCommand::new();
838        let args = images_cmd.build_command_args();
839
840        assert!(args.is_empty()); // No arguments for basic images command
841        assert!(!images_cmd.is_all());
842        assert!(!images_cmd.is_digests());
843        assert!(!images_cmd.is_quiet());
844        assert!(!images_cmd.is_no_trunc());
845        assert!(!images_cmd.is_tree());
846        assert_eq!(images_cmd.get_repository(), None);
847        assert_eq!(images_cmd.get_format(), None);
848        assert!(images_cmd.get_filters().is_empty());
849    }
850
851    #[test]
852    fn test_images_command_with_repository() {
853        let images_cmd = ImagesCommand::new().repository("nginx:alpine");
854        let args = images_cmd.build_command_args();
855
856        assert!(args.contains(&"nginx:alpine".to_string()));
857        assert_eq!(args.last(), Some(&"nginx:alpine".to_string()));
858        assert_eq!(images_cmd.get_repository(), Some("nginx:alpine"));
859    }
860
861    #[test]
862    fn test_images_command_with_all_flags() {
863        let images_cmd = ImagesCommand::new()
864            .all()
865            .digests()
866            .no_trunc()
867            .quiet()
868            .tree();
869
870        let args = images_cmd.build_command_args();
871
872        assert!(args.contains(&"--all".to_string()));
873        assert!(args.contains(&"--digests".to_string()));
874        assert!(args.contains(&"--no-trunc".to_string()));
875        assert!(args.contains(&"--quiet".to_string()));
876        assert!(args.contains(&"--tree".to_string()));
877
878        assert!(images_cmd.is_all());
879        assert!(images_cmd.is_digests());
880        assert!(images_cmd.is_no_trunc());
881        assert!(images_cmd.is_quiet());
882        assert!(images_cmd.is_tree());
883    }
884
885    #[test]
886    fn test_images_command_with_filters() {
887        let images_cmd = ImagesCommand::new()
888            .filter("dangling=true")
889            .filter("label=maintainer=nginx")
890            .filters(vec!["before=alpine:latest", "since=ubuntu:20.04"]);
891
892        let args = images_cmd.build_command_args();
893
894        assert!(args.contains(&"--filter".to_string()));
895        assert!(args.contains(&"dangling=true".to_string()));
896        assert!(args.contains(&"label=maintainer=nginx".to_string()));
897        assert!(args.contains(&"before=alpine:latest".to_string()));
898        assert!(args.contains(&"since=ubuntu:20.04".to_string()));
899
900        let filters = images_cmd.get_filters();
901        assert_eq!(filters.len(), 4);
902        assert!(filters.contains(&"dangling=true".to_string()));
903    }
904
905    #[test]
906    fn test_images_command_with_format() {
907        let images_cmd = ImagesCommand::new().format_json();
908        let args = images_cmd.build_command_args();
909
910        assert!(args.contains(&"--format".to_string()));
911        assert!(args.contains(&"json".to_string()));
912        assert_eq!(images_cmd.get_format(), Some("json"));
913    }
914
915    #[test]
916    fn test_images_command_custom_format() {
917        let custom_format = "table {{.Repository}}:{{.Tag}}\t{{.Size}}";
918        let images_cmd = ImagesCommand::new().format(custom_format);
919        let args = images_cmd.build_command_args();
920
921        assert!(args.contains(&"--format".to_string()));
922        assert!(args.contains(&custom_format.to_string()));
923        assert_eq!(images_cmd.get_format(), Some(custom_format));
924    }
925
926    #[test]
927    fn test_images_command_all_options() {
928        let images_cmd = ImagesCommand::new()
929            .repository("ubuntu")
930            .all()
931            .digests()
932            .filter("dangling=false")
933            .format_table()
934            .no_trunc()
935            .quiet();
936
937        let args = images_cmd.build_command_args();
938
939        // Repository should be last
940        assert_eq!(args.last(), Some(&"ubuntu".to_string()));
941
942        // All options should be present
943        assert!(args.contains(&"--all".to_string()));
944        assert!(args.contains(&"--digests".to_string()));
945        assert!(args.contains(&"--filter".to_string()));
946        assert!(args.contains(&"dangling=false".to_string()));
947        assert!(args.contains(&"--format".to_string()));
948        assert!(args.contains(&"table".to_string()));
949        assert!(args.contains(&"--no-trunc".to_string()));
950        assert!(args.contains(&"--quiet".to_string()));
951
952        // Verify helper methods
953        assert_eq!(images_cmd.get_repository(), Some("ubuntu"));
954        assert!(images_cmd.is_all());
955        assert!(images_cmd.is_digests());
956        assert!(images_cmd.is_no_trunc());
957        assert!(images_cmd.is_quiet());
958        assert_eq!(images_cmd.get_format(), Some("table"));
959        assert_eq!(images_cmd.get_filters(), &["dangling=false"]);
960    }
961
962    #[test]
963    fn test_images_command_default() {
964        let images_cmd = ImagesCommand::default();
965        assert_eq!(images_cmd.get_repository(), None);
966        assert!(!images_cmd.is_all());
967    }
968
969    #[test]
970    fn test_image_info_creation() {
971        let image = ImageInfo {
972            repository: "nginx".to_string(),
973            tag: "alpine".to_string(),
974            image_id: "abc123456789".to_string(),
975            created: "2 days ago".to_string(),
976            size: "16.1MB".to_string(),
977            digest: Some("sha256:abc123".to_string()),
978        };
979
980        assert_eq!(image.repository, "nginx");
981        assert_eq!(image.tag, "alpine");
982        assert_eq!(image.image_id, "abc123456789");
983        assert_eq!(image.digest, Some("sha256:abc123".to_string()));
984    }
985
986    #[test]
987    fn test_parse_json_output() {
988        let json_output = r#"{"Containers":"N/A","CreatedAt":"2023-01-01T00:00:00Z","CreatedSince":"2 days ago","Digest":"sha256:abc123","ID":"sha256:def456","Repository":"nginx","SharedSize":"N/A","Size":"16.1MB","Tag":"alpine","UniqueSize":"N/A","VirtualSize":"16.1MB"}
989{"Containers":"N/A","CreatedAt":"2023-01-02T00:00:00Z","CreatedSince":"1 day ago","Digest":"sha256:xyz789","ID":"sha256:ghi012","Repository":"ubuntu","SharedSize":"N/A","Size":"72.8MB","Tag":"20.04","UniqueSize":"N/A","VirtualSize":"72.8MB"}"#;
990
991        let images = ImagesCommand::parse_json_output(json_output);
992
993        assert_eq!(images.len(), 2);
994        assert_eq!(images[0].repository, "nginx");
995        assert_eq!(images[0].tag, "alpine");
996        assert_eq!(images[0].image_id, "sha256:def456");
997        assert_eq!(images[0].size, "16.1MB");
998        assert_eq!(images[0].digest, Some("sha256:abc123".to_string()));
999
1000        assert_eq!(images[1].repository, "ubuntu");
1001        assert_eq!(images[1].tag, "20.04");
1002    }
1003
1004    #[test]
1005    fn test_parse_table_output() {
1006        let images_cmd = ImagesCommand::new();
1007        let table_output = r"REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
1008nginx               alpine              abc123456789        2 days ago          16.1MB
1009ubuntu              20.04               def456789012        1 day ago           72.8MB
1010<none>              <none>              ghi789012345        3 hours ago         5.59MB";
1011
1012        let images = images_cmd.parse_table_output(table_output);
1013
1014        assert_eq!(images.len(), 3);
1015        assert_eq!(images[0].repository, "nginx");
1016        assert_eq!(images[0].tag, "alpine");
1017        assert_eq!(images[0].image_id, "abc123456789");
1018        assert_eq!(images[0].created, "2 days ago");
1019        assert_eq!(images[0].size, "16.1MB");
1020
1021        assert_eq!(images[1].repository, "ubuntu");
1022        assert_eq!(images[1].tag, "20.04");
1023    }
1024
1025    #[test]
1026    fn test_parse_quiet_output() {
1027        let images_cmd = ImagesCommand::new().quiet();
1028        let quiet_output = "abc123456789\ndef456789012\nghi789012345";
1029
1030        let images = images_cmd.parse_output(&CommandOutput {
1031            stdout: quiet_output.to_string(),
1032            stderr: String::new(),
1033            exit_code: 0,
1034            success: true,
1035        });
1036
1037        assert_eq!(images.len(), 3);
1038        assert_eq!(images[0].image_id, "abc123456789");
1039        assert_eq!(images[0].repository, "<unknown>");
1040        assert_eq!(images[1].image_id, "def456789012");
1041        assert_eq!(images[2].image_id, "ghi789012345");
1042    }
1043
1044    #[test]
1045    fn test_images_output_helpers() {
1046        let output = ImagesOutput {
1047            output: CommandOutput {
1048                stdout: "test".to_string(),
1049                stderr: String::new(),
1050                exit_code: 0,
1051                success: true,
1052            },
1053            images: vec![
1054                ImageInfo {
1055                    repository: "nginx".to_string(),
1056                    tag: "alpine".to_string(),
1057                    image_id: "abc123".to_string(),
1058                    created: "2 days ago".to_string(),
1059                    size: "16.1MB".to_string(),
1060                    digest: None,
1061                },
1062                ImageInfo {
1063                    repository: "nginx".to_string(),
1064                    tag: "latest".to_string(),
1065                    image_id: "def456".to_string(),
1066                    created: "1 day ago".to_string(),
1067                    size: "133MB".to_string(),
1068                    digest: None,
1069                },
1070                ImageInfo {
1071                    repository: "ubuntu".to_string(),
1072                    tag: "20.04".to_string(),
1073                    image_id: "ghi789".to_string(),
1074                    created: "3 days ago".to_string(),
1075                    size: "72.8MB".to_string(),
1076                    digest: None,
1077                },
1078            ],
1079        };
1080
1081        assert!(output.success());
1082        assert_eq!(output.image_count(), 3);
1083        assert!(!output.is_empty());
1084
1085        let ids = output.image_ids();
1086        assert_eq!(ids, vec!["abc123", "def456", "ghi789"]);
1087
1088        let nginx_images = output.filter_by_repository("nginx");
1089        assert_eq!(nginx_images.len(), 2);
1090        assert_eq!(nginx_images[0].tag, "alpine");
1091        assert_eq!(nginx_images[1].tag, "latest");
1092    }
1093
1094    #[test]
1095    fn test_images_command_extensibility() {
1096        let mut images_cmd = ImagesCommand::new();
1097        images_cmd
1098            .arg("--experimental")
1099            .args(vec!["--custom", "value"]);
1100
1101        // Extensibility is handled through the executor's raw_args
1102        // The actual testing of raw args is done in command.rs tests
1103        // We can't access private fields, but we know the methods work
1104        println!("Extensibility methods called successfully");
1105    }
1106}