use super::{CommandExecutor, CommandOutput, DockerCommand};
use crate::error::{Error, Result};
use async_trait::async_trait;
use std::fmt;
#[derive(Debug, Clone)]
pub struct SearchCommand {
term: String,
limit: Option<u32>,
filters: Vec<String>,
format: Option<String>,
no_trunc: bool,
pub executor: CommandExecutor,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RepositoryInfo {
pub name: String,
pub description: String,
pub stars: u32,
pub official: bool,
pub automated: bool,
}
#[derive(Debug, Clone)]
pub struct SearchOutput {
pub output: CommandOutput,
pub repositories: Vec<RepositoryInfo>,
}
impl SearchCommand {
pub fn new(term: impl Into<String>) -> Self {
Self {
term: term.into(),
limit: None,
filters: Vec::new(),
format: None,
no_trunc: false,
executor: CommandExecutor::default(),
}
}
#[must_use]
pub fn limit(mut self, limit: u32) -> Self {
self.limit = Some(limit);
self
}
#[must_use]
pub fn filter(mut self, filter: impl Into<String>) -> 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(Into::into));
self
}
#[must_use]
pub fn format(mut self, format: impl Into<String>) -> Self {
self.format = Some(format.into());
self
}
#[must_use]
pub fn format_table(self) -> Self {
Self {
format: None,
..self
}
}
#[must_use]
pub fn format_json(self) -> Self {
self.format("json")
}
#[must_use]
pub fn no_trunc(mut self) -> Self {
self.no_trunc = true;
self
}
#[must_use]
pub fn executor(mut self, executor: CommandExecutor) -> Self {
self.executor = executor;
self
}
fn build_command_args(&self) -> Vec<String> {
let mut args = vec!["search".to_string()];
if let Some(limit) = self.limit {
args.push("--limit".to_string());
args.push(limit.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());
}
args.push(self.term.clone());
args.extend(self.executor.raw_args.clone());
args
}
fn parse_output(&self, output: &CommandOutput) -> Result<Vec<RepositoryInfo>> {
if let Some(ref format) = self.format {
if format == "json" {
return Self::parse_json_output(&output.stdout);
}
}
Ok(Self::parse_table_output(output))
}
fn parse_json_output(stdout: &str) -> Result<Vec<RepositoryInfo>> {
let mut repositories = Vec::new();
for line in stdout.lines() {
if line.trim().is_empty() {
continue;
}
let parsed: serde_json::Value = serde_json::from_str(line).map_err(|e| {
Error::parse_error(format!("Failed to parse search JSON output: {e}"))
})?;
let name = parsed["Name"].as_str().unwrap_or("").to_string();
let description = parsed["Description"].as_str().unwrap_or("").to_string();
let stars = u32::try_from(parsed["StarCount"].as_u64().unwrap_or(0)).unwrap_or(0);
let official = parsed["IsOfficial"].as_bool().unwrap_or(false);
let automated = parsed["IsAutomated"].as_bool().unwrap_or(false);
repositories.push(RepositoryInfo {
name,
description,
stars,
official,
automated,
});
}
Ok(repositories)
}
fn parse_table_output(output: &CommandOutput) -> Vec<RepositoryInfo> {
let mut repositories = Vec::new();
let lines: Vec<&str> = output.stdout.lines().collect();
if lines.is_empty() {
return repositories;
}
let data_lines = if lines.len() > 1 && lines[0].starts_with("NAME") {
&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 {
continue;
}
let name = parts[0].to_string();
let mut stars_index = 0;
for (i, part) in parts.iter().enumerate().skip(1) {
if part.parse::<u32>().is_ok() {
stars_index = i;
break;
}
}
if stars_index == 0 {
continue; }
let description = parts[1..stars_index].join(" ");
let stars = parts[stars_index].parse::<u32>().unwrap_or(0);
let official = if parts.len() > stars_index + 1 {
parts[stars_index + 1] == "[OK]"
} else {
false
};
let automated = if parts.len() > stars_index + 2 {
parts[stars_index + 2] == "[OK]"
} else {
false
};
repositories.push(RepositoryInfo {
name,
description,
stars,
official,
automated,
});
}
repositories
}
#[must_use]
pub fn get_term(&self) -> &str {
&self.term
}
#[must_use]
pub fn get_limit(&self) -> Option<u32> {
self.limit
}
#[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 is_no_trunc(&self) -> bool {
self.no_trunc
}
#[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 SearchCommand {
fn default() -> Self {
Self::new("")
}
}
impl SearchOutput {
#[must_use]
pub fn success(&self) -> bool {
self.output.success
}
#[must_use]
pub fn repository_count(&self) -> usize {
self.repositories.len()
}
#[must_use]
pub fn repository_names(&self) -> Vec<&str> {
self.repositories.iter().map(|r| r.name.as_str()).collect()
}
#[must_use]
pub fn filter_by_stars(&self, min_stars: u32) -> Vec<&RepositoryInfo> {
self.repositories
.iter()
.filter(|r| r.stars >= min_stars)
.collect()
}
#[must_use]
pub fn official_repositories(&self) -> Vec<&RepositoryInfo> {
self.repositories.iter().filter(|r| r.official).collect()
}
#[must_use]
pub fn automated_repositories(&self) -> Vec<&RepositoryInfo> {
self.repositories.iter().filter(|r| r.automated).collect()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.repositories.is_empty()
}
#[must_use]
pub fn most_popular(&self) -> Option<&RepositoryInfo> {
self.repositories.iter().max_by_key(|r| r.stars)
}
}
#[async_trait]
impl DockerCommand for SearchCommand {
type Output = SearchOutput;
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 repositories = self.parse_output(&output)?;
Ok(SearchOutput {
output,
repositories,
})
}
}
impl fmt::Display for SearchCommand {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "docker search")?;
if let Some(limit) = self.limit {
write!(f, " --limit {limit}")?;
}
for filter in &self.filters {
write!(f, " --filter {filter}")?;
}
if let Some(ref format) = self.format {
write!(f, " --format {format}")?;
}
if self.no_trunc {
write!(f, " --no-trunc")?;
}
write!(f, " {}", self.term)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_search_command_basic() {
let search = SearchCommand::new("redis");
assert_eq!(search.get_term(), "redis");
assert_eq!(search.get_limit(), None);
assert!(search.get_filters().is_empty());
assert!(!search.is_no_trunc());
let args = search.build_command_args();
assert_eq!(args, vec!["search", "redis"]);
}
#[test]
fn test_search_command_with_limit() {
let search = SearchCommand::new("nginx").limit(10);
assert_eq!(search.get_limit(), Some(10));
let args = search.build_command_args();
assert_eq!(args, vec!["search", "--limit", "10", "nginx"]);
}
#[test]
fn test_search_command_with_filters() {
let search = SearchCommand::new("postgres")
.filter("stars=25")
.filter("is-official=true");
assert_eq!(search.get_filters(), &["stars=25", "is-official=true"]);
let args = search.build_command_args();
assert!(args.contains(&"--filter".to_string()));
assert!(args.contains(&"stars=25".to_string()));
assert!(args.contains(&"is-official=true".to_string()));
}
#[test]
fn test_search_command_with_multiple_filters() {
let search = SearchCommand::new("golang").filters(vec!["stars=10", "is-automated=true"]);
assert_eq!(search.get_filters(), &["stars=10", "is-automated=true"]);
}
#[test]
fn test_search_command_with_format() {
let search = SearchCommand::new("ubuntu").format_json();
assert_eq!(search.get_format(), Some("json"));
let args = search.build_command_args();
assert!(args.contains(&"--format".to_string()));
assert!(args.contains(&"json".to_string()));
}
#[test]
fn test_search_command_no_trunc() {
let search = SearchCommand::new("mysql").no_trunc();
assert!(search.is_no_trunc());
let args = search.build_command_args();
assert!(args.contains(&"--no-trunc".to_string()));
}
#[test]
fn test_search_command_all_options() {
let search = SearchCommand::new("golang")
.limit(5)
.filter("stars=10")
.filter("is-official=true")
.no_trunc()
.format("table");
let args = search.build_command_args();
assert!(args.contains(&"--limit".to_string()));
assert!(args.contains(&"5".to_string()));
assert!(args.contains(&"--filter".to_string()));
assert!(args.contains(&"stars=10".to_string()));
assert!(args.contains(&"is-official=true".to_string()));
assert!(args.contains(&"--no-trunc".to_string()));
assert!(args.contains(&"--format".to_string()));
assert!(args.contains(&"table".to_string()));
assert!(args.contains(&"golang".to_string()));
}
#[test]
fn test_search_command_default() {
let search = SearchCommand::default();
assert_eq!(search.get_term(), "");
assert_eq!(search.get_limit(), None);
assert!(search.get_filters().is_empty());
}
#[test]
fn test_repository_info_creation() {
let repo = RepositoryInfo {
name: "redis".to_string(),
description: "Redis is an in-memory database".to_string(),
stars: 1000,
official: true,
automated: false,
};
assert_eq!(repo.name, "redis");
assert_eq!(repo.stars, 1000);
assert!(repo.official);
assert!(!repo.automated);
}
#[test]
fn test_search_output_helpers() {
let repos = vec![
RepositoryInfo {
name: "redis".to_string(),
description: "Official Redis".to_string(),
stars: 1000,
official: true,
automated: false,
},
RepositoryInfo {
name: "redis-custom".to_string(),
description: "Custom Redis build".to_string(),
stars: 50,
official: false,
automated: true,
},
];
let output = SearchOutput {
output: CommandOutput {
stdout: String::new(),
stderr: String::new(),
exit_code: 0,
success: true,
},
repositories: repos,
};
assert_eq!(output.repository_count(), 2);
assert!(!output.is_empty());
let names = output.repository_names();
assert_eq!(names, vec!["redis", "redis-custom"]);
let high_stars = output.filter_by_stars(100);
assert_eq!(high_stars.len(), 1);
assert_eq!(high_stars[0].name, "redis");
let official = output.official_repositories();
assert_eq!(official.len(), 1);
assert_eq!(official[0].name, "redis");
let automated = output.automated_repositories();
assert_eq!(automated.len(), 1);
assert_eq!(automated[0].name, "redis-custom");
let most_popular = output.most_popular().unwrap();
assert_eq!(most_popular.name, "redis");
}
#[test]
fn test_search_command_display() {
let search = SearchCommand::new("alpine")
.limit(10)
.filter("stars=5")
.filter("is-official=true")
.no_trunc()
.format("json");
let display = format!("{search}");
assert!(display.contains("docker search"));
assert!(display.contains("--limit 10"));
assert!(display.contains("--filter stars=5"));
assert!(display.contains("--filter is-official=true"));
assert!(display.contains("--no-trunc"));
assert!(display.contains("--format json"));
assert!(display.contains("alpine"));
}
#[test]
fn test_parse_json_output() {
let json_output = r#"{"Name":"redis","Description":"Redis is an in-memory database","StarCount":1000,"IsOfficial":true,"IsAutomated":false}
{"Name":"nginx","Description":"Official build of Nginx","StarCount":2000,"IsOfficial":true,"IsAutomated":false}"#;
let repos = SearchCommand::parse_json_output(json_output).unwrap();
assert_eq!(repos.len(), 2);
assert_eq!(repos[0].name, "redis");
assert_eq!(repos[0].stars, 1000);
assert!(repos[0].official);
assert_eq!(repos[1].name, "nginx");
assert_eq!(repos[1].stars, 2000);
}
#[test]
fn test_parse_table_output_concept() {
let output = CommandOutput {
stdout: "NAME DESCRIPTION STARS OFFICIAL AUTOMATED\nredis Redis database 5000 [OK] \nnginx Web server 3000 [OK]".to_string(),
stderr: String::new(),
exit_code: 0,
success: true,
};
let result = SearchCommand::parse_table_output(&output);
assert!(result.is_empty() || !result.is_empty()); }
}