use super::{CommandExecutor, CommandOutput, DockerCommand};
use crate::error::Result;
use async_trait::async_trait;
use serde_json::Value;
#[derive(Debug, Clone)]
#[allow(clippy::struct_excessive_bools)]
pub struct ImagesCommand {
repository: Option<String>,
all: bool,
digests: bool,
filters: Vec<String>,
format: Option<String>,
no_trunc: bool,
quiet: bool,
tree: bool,
pub executor: CommandExecutor,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ImageInfo {
pub repository: String,
pub tag: String,
pub image_id: String,
pub created: String,
pub size: String,
pub digest: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ImagesOutput {
pub output: CommandOutput,
pub images: Vec<ImageInfo>,
}
impl ImagesCommand {
#[must_use]
pub fn new() -> Self {
Self {
repository: None,
all: false,
digests: false,
filters: Vec::new(),
format: None,
no_trunc: false,
quiet: false,
tree: false,
executor: CommandExecutor::new(),
}
}
#[must_use]
pub fn repository<S: Into<String>>(mut self, repository: S) -> Self {
self.repository = Some(repository.into());
self
}
#[must_use]
pub fn all(mut self) -> Self {
self.all = true;
self
}
#[must_use]
pub fn digests(mut self) -> Self {
self.digests = true;
self
}
#[must_use]
pub fn filter<S: Into<String>>(mut self, filter: S) -> Self {
self.filters.push(filter.into());
self
}
#[must_use]
pub fn filters<I, S>(mut self, filters: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.filters
.extend(filters.into_iter().map(std::convert::Into::into));
self
}
#[must_use]
pub fn format<S: Into<String>>(mut self, format: S) -> Self {
self.format = Some(format.into());
self
}
#[must_use]
pub fn format_table(mut self) -> Self {
self.format = Some("table".to_string());
self
}
#[must_use]
pub fn format_json(mut self) -> Self {
self.format = Some("json".to_string());
self
}
#[must_use]
pub fn no_trunc(mut self) -> Self {
self.no_trunc = true;
self
}
#[must_use]
pub fn quiet(mut self) -> Self {
self.quiet = true;
self
}
#[must_use]
pub fn tree(mut self) -> Self {
self.tree = true;
self
}
fn build_command_args(&self) -> Vec<String> {
let mut args = vec!["images".to_string()];
if self.all {
args.push("--all".to_string());
}
if self.digests {
args.push("--digests".to_string());
}
for filter in &self.filters {
args.push("--filter".to_string());
args.push(filter.clone());
}
if let Some(ref format) = self.format {
args.push("--format".to_string());
args.push(format.clone());
}
if self.no_trunc {
args.push("--no-trunc".to_string());
}
if self.quiet {
args.push("--quiet".to_string());
}
if self.tree {
args.push("--tree".to_string());
}
if let Some(ref repository) = self.repository {
args.push(repository.clone());
}
args
}
fn parse_output(&self, output: &CommandOutput) -> Vec<ImageInfo> {
if self.quiet {
return output
.stdout
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| ImageInfo {
repository: "<unknown>".to_string(),
tag: "<unknown>".to_string(),
image_id: line.trim().to_string(),
created: "<unknown>".to_string(),
size: "<unknown>".to_string(),
digest: None,
})
.collect();
}
if let Some(ref format) = self.format {
if format == "json" {
return Self::parse_json_output(&output.stdout);
}
}
self.parse_table_output(&output.stdout)
}
fn parse_json_output(stdout: &str) -> Vec<ImageInfo> {
let mut images = Vec::new();
for line in stdout.lines() {
if let Ok(json) = serde_json::from_str::<Value>(line) {
if let Some(obj) = json.as_object() {
let repository = obj
.get("Repository")
.and_then(|v| v.as_str())
.unwrap_or("<none>")
.to_string();
let tag = obj
.get("Tag")
.and_then(|v| v.as_str())
.unwrap_or("<none>")
.to_string();
let image_id = obj
.get("ID")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let created = obj
.get("CreatedAt")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let size = obj
.get("Size")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let digest = obj.get("Digest").and_then(|v| v.as_str()).map(String::from);
images.push(ImageInfo {
repository,
tag,
image_id,
created,
size,
digest,
});
}
}
}
images
}
fn parse_table_output(&self, stdout: &str) -> Vec<ImageInfo> {
let mut images = Vec::new();
let lines: Vec<&str> = stdout.lines().collect();
if lines.is_empty() {
return images;
}
let data_lines = if lines[0].starts_with("REPOSITORY") || lines[0].starts_with("IMAGE") {
&lines[1..]
} else {
&lines[..]
};
for line in data_lines {
if line.trim().is_empty() {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 5 {
let repository = parts[0].to_string();
let tag = parts[1].to_string();
let image_id = parts[2].to_string();
let (created, size, digest) = if self.digests && parts.len() >= 7 {
let digest = Some(parts[3].to_string());
let created_parts = &parts[4..parts.len() - 1];
let created = created_parts.join(" ");
let size = parts[parts.len() - 1].to_string();
(created, size, digest)
} else if parts.len() >= 5 {
let created_parts = &parts[3..parts.len() - 1];
let created = created_parts.join(" ");
let size = parts[parts.len() - 1].to_string();
(created, size, None)
} else {
(String::new(), String::new(), None)
};
images.push(ImageInfo {
repository,
tag,
image_id,
created,
size,
digest,
});
}
}
images
}
#[must_use]
pub fn get_repository(&self) -> Option<&str> {
self.repository.as_deref()
}
#[must_use]
pub fn is_all(&self) -> bool {
self.all
}
#[must_use]
pub fn is_digests(&self) -> bool {
self.digests
}
#[must_use]
pub fn is_quiet(&self) -> bool {
self.quiet
}
#[must_use]
pub fn is_no_trunc(&self) -> bool {
self.no_trunc
}
#[must_use]
pub fn is_tree(&self) -> bool {
self.tree
}
#[must_use]
pub fn get_filters(&self) -> &[String] {
&self.filters
}
#[must_use]
pub fn get_format(&self) -> Option<&str> {
self.format.as_deref()
}
#[must_use]
pub fn get_executor(&self) -> &CommandExecutor {
&self.executor
}
#[must_use]
pub fn get_executor_mut(&mut self) -> &mut CommandExecutor {
&mut self.executor
}
}
impl Default for ImagesCommand {
fn default() -> Self {
Self::new()
}
}
impl ImagesOutput {
#[must_use]
pub fn success(&self) -> bool {
self.output.success
}
#[must_use]
pub fn image_count(&self) -> usize {
self.images.len()
}
#[must_use]
pub fn image_ids(&self) -> Vec<&str> {
self.images
.iter()
.map(|img| img.image_id.as_str())
.collect()
}
#[must_use]
pub fn filter_by_repository(&self, repository: &str) -> Vec<&ImageInfo> {
self.images
.iter()
.filter(|img| img.repository == repository)
.collect()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.images.is_empty()
}
}
#[async_trait]
impl DockerCommand for ImagesCommand {
type Output = ImagesOutput;
fn get_executor(&self) -> &CommandExecutor {
&self.executor
}
fn get_executor_mut(&mut self) -> &mut CommandExecutor {
&mut self.executor
}
fn build_command_args(&self) -> Vec<String> {
self.build_command_args()
}
async fn execute(&self) -> Result<Self::Output> {
let args = self.build_command_args();
let output = self.execute_command(args).await?;
let images = self.parse_output(&output);
Ok(ImagesOutput { output, images })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_images_command_basic() {
let images_cmd = ImagesCommand::new();
let args = images_cmd.build_command_args();
assert_eq!(args, vec!["images"]); assert!(!images_cmd.is_all());
assert!(!images_cmd.is_digests());
assert!(!images_cmd.is_quiet());
assert!(!images_cmd.is_no_trunc());
assert!(!images_cmd.is_tree());
assert_eq!(images_cmd.get_repository(), None);
assert_eq!(images_cmd.get_format(), None);
assert!(images_cmd.get_filters().is_empty());
}
#[test]
fn test_images_command_with_repository() {
let images_cmd = ImagesCommand::new().repository("nginx:alpine");
let args = images_cmd.build_command_args();
assert!(args.contains(&"nginx:alpine".to_string()));
assert_eq!(args.last(), Some(&"nginx:alpine".to_string()));
assert_eq!(images_cmd.get_repository(), Some("nginx:alpine"));
}
#[test]
fn test_images_command_with_all_flags() {
let images_cmd = ImagesCommand::new()
.all()
.digests()
.no_trunc()
.quiet()
.tree();
let args = images_cmd.build_command_args();
assert!(args.contains(&"--all".to_string()));
assert!(args.contains(&"--digests".to_string()));
assert!(args.contains(&"--no-trunc".to_string()));
assert!(args.contains(&"--quiet".to_string()));
assert!(args.contains(&"--tree".to_string()));
assert!(images_cmd.is_all());
assert!(images_cmd.is_digests());
assert!(images_cmd.is_no_trunc());
assert!(images_cmd.is_quiet());
assert!(images_cmd.is_tree());
}
#[test]
fn test_images_command_with_filters() {
let images_cmd = ImagesCommand::new()
.filter("dangling=true")
.filter("label=maintainer=nginx")
.filters(vec!["before=alpine:latest", "since=ubuntu:20.04"]);
let args = images_cmd.build_command_args();
assert!(args.contains(&"--filter".to_string()));
assert!(args.contains(&"dangling=true".to_string()));
assert!(args.contains(&"label=maintainer=nginx".to_string()));
assert!(args.contains(&"before=alpine:latest".to_string()));
assert!(args.contains(&"since=ubuntu:20.04".to_string()));
let filters = images_cmd.get_filters();
assert_eq!(filters.len(), 4);
assert!(filters.contains(&"dangling=true".to_string()));
}
#[test]
fn test_images_command_with_format() {
let images_cmd = ImagesCommand::new().format_json();
let args = images_cmd.build_command_args();
assert!(args.contains(&"--format".to_string()));
assert!(args.contains(&"json".to_string()));
assert_eq!(images_cmd.get_format(), Some("json"));
}
#[test]
fn test_images_command_custom_format() {
let custom_format = "table {{.Repository}}:{{.Tag}}\t{{.Size}}";
let images_cmd = ImagesCommand::new().format(custom_format);
let args = images_cmd.build_command_args();
assert!(args.contains(&"--format".to_string()));
assert!(args.contains(&custom_format.to_string()));
assert_eq!(images_cmd.get_format(), Some(custom_format));
}
#[test]
fn test_images_command_all_options() {
let images_cmd = ImagesCommand::new()
.repository("ubuntu")
.all()
.digests()
.filter("dangling=false")
.format_table()
.no_trunc()
.quiet();
let args = images_cmd.build_command_args();
assert_eq!(args.last(), Some(&"ubuntu".to_string()));
assert!(args.contains(&"--all".to_string()));
assert!(args.contains(&"--digests".to_string()));
assert!(args.contains(&"--filter".to_string()));
assert!(args.contains(&"dangling=false".to_string()));
assert!(args.contains(&"--format".to_string()));
assert!(args.contains(&"table".to_string()));
assert!(args.contains(&"--no-trunc".to_string()));
assert!(args.contains(&"--quiet".to_string()));
assert_eq!(images_cmd.get_repository(), Some("ubuntu"));
assert!(images_cmd.is_all());
assert!(images_cmd.is_digests());
assert!(images_cmd.is_no_trunc());
assert!(images_cmd.is_quiet());
assert_eq!(images_cmd.get_format(), Some("table"));
assert_eq!(images_cmd.get_filters(), &["dangling=false"]);
}
#[test]
fn test_images_command_default() {
let images_cmd = ImagesCommand::default();
assert_eq!(images_cmd.get_repository(), None);
assert!(!images_cmd.is_all());
}
#[test]
fn test_image_info_creation() {
let image = ImageInfo {
repository: "nginx".to_string(),
tag: "alpine".to_string(),
image_id: "abc123456789".to_string(),
created: "2 days ago".to_string(),
size: "16.1MB".to_string(),
digest: Some("sha256:abc123".to_string()),
};
assert_eq!(image.repository, "nginx");
assert_eq!(image.tag, "alpine");
assert_eq!(image.image_id, "abc123456789");
assert_eq!(image.digest, Some("sha256:abc123".to_string()));
}
#[test]
fn test_parse_json_output() {
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"}
{"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"}"#;
let images = ImagesCommand::parse_json_output(json_output);
assert_eq!(images.len(), 2);
assert_eq!(images[0].repository, "nginx");
assert_eq!(images[0].tag, "alpine");
assert_eq!(images[0].image_id, "sha256:def456");
assert_eq!(images[0].size, "16.1MB");
assert_eq!(images[0].digest, Some("sha256:abc123".to_string()));
assert_eq!(images[1].repository, "ubuntu");
assert_eq!(images[1].tag, "20.04");
}
#[test]
fn test_parse_table_output() {
let images_cmd = ImagesCommand::new();
let table_output = r"REPOSITORY TAG IMAGE ID CREATED SIZE
nginx alpine abc123456789 2 days ago 16.1MB
ubuntu 20.04 def456789012 1 day ago 72.8MB
<none> <none> ghi789012345 3 hours ago 5.59MB";
let images = images_cmd.parse_table_output(table_output);
assert_eq!(images.len(), 3);
assert_eq!(images[0].repository, "nginx");
assert_eq!(images[0].tag, "alpine");
assert_eq!(images[0].image_id, "abc123456789");
assert_eq!(images[0].created, "2 days ago");
assert_eq!(images[0].size, "16.1MB");
assert_eq!(images[1].repository, "ubuntu");
assert_eq!(images[1].tag, "20.04");
}
#[test]
fn test_parse_quiet_output() {
let images_cmd = ImagesCommand::new().quiet();
let quiet_output = "abc123456789\ndef456789012\nghi789012345";
let images = images_cmd.parse_output(&CommandOutput {
stdout: quiet_output.to_string(),
stderr: String::new(),
exit_code: 0,
success: true,
});
assert_eq!(images.len(), 3);
assert_eq!(images[0].image_id, "abc123456789");
assert_eq!(images[0].repository, "<unknown>");
assert_eq!(images[1].image_id, "def456789012");
assert_eq!(images[2].image_id, "ghi789012345");
}
#[test]
fn test_images_output_helpers() {
let output = ImagesOutput {
output: CommandOutput {
stdout: "test".to_string(),
stderr: String::new(),
exit_code: 0,
success: true,
},
images: vec![
ImageInfo {
repository: "nginx".to_string(),
tag: "alpine".to_string(),
image_id: "abc123".to_string(),
created: "2 days ago".to_string(),
size: "16.1MB".to_string(),
digest: None,
},
ImageInfo {
repository: "nginx".to_string(),
tag: "latest".to_string(),
image_id: "def456".to_string(),
created: "1 day ago".to_string(),
size: "133MB".to_string(),
digest: None,
},
ImageInfo {
repository: "ubuntu".to_string(),
tag: "20.04".to_string(),
image_id: "ghi789".to_string(),
created: "3 days ago".to_string(),
size: "72.8MB".to_string(),
digest: None,
},
],
};
assert!(output.success());
assert_eq!(output.image_count(), 3);
assert!(!output.is_empty());
let ids = output.image_ids();
assert_eq!(ids, vec!["abc123", "def456", "ghi789"]);
let nginx_images = output.filter_by_repository("nginx");
assert_eq!(nginx_images.len(), 2);
assert_eq!(nginx_images[0].tag, "alpine");
assert_eq!(nginx_images[1].tag, "latest");
}
#[test]
fn test_images_command_extensibility() {
let mut images_cmd = ImagesCommand::new();
images_cmd
.arg("--experimental")
.args(vec!["--custom", "value"]);
println!("Extensibility methods called successfully");
}
}