1use super::{CommandExecutor, CommandOutput, DockerCommand};
7use crate::error::{Error, Result};
8use async_trait::async_trait;
9use std::ffi::OsStr;
10use std::fmt;
11
12#[derive(Debug, Clone)]
32pub struct SearchCommand {
33 term: String,
35 limit: Option<u32>,
37 filters: Vec<String>,
39 format: Option<String>,
41 no_trunc: bool,
43 executor: CommandExecutor,
45}
46
47#[derive(Debug, Clone, PartialEq)]
49pub struct RepositoryInfo {
50 pub name: String,
52 pub description: String,
54 pub stars: u32,
56 pub official: bool,
58 pub automated: bool,
60}
61
62#[derive(Debug, Clone)]
67pub struct SearchOutput {
68 pub output: CommandOutput,
70 pub repositories: Vec<RepositoryInfo>,
72}
73
74impl SearchCommand {
75 pub fn new(term: impl Into<String>) -> Self {
89 Self {
90 term: term.into(),
91 limit: None,
92 filters: Vec::new(),
93 format: None,
94 no_trunc: false,
95 executor: CommandExecutor::default(),
96 }
97 }
98
99 #[must_use]
113 pub fn limit(mut self, limit: u32) -> Self {
114 self.limit = Some(limit);
115 self
116 }
117
118 #[must_use]
132 pub fn filter(mut self, filter: impl Into<String>) -> Self {
133 self.filters.push(filter.into());
134 self
135 }
136
137 #[must_use]
152 pub fn filters<I, S>(mut self, filters: I) -> Self
153 where
154 I: IntoIterator<Item = S>,
155 S: Into<String>,
156 {
157 self.filters.extend(filters.into_iter().map(Into::into));
158 self
159 }
160
161 #[must_use]
175 pub fn format(mut self, format: impl Into<String>) -> Self {
176 self.format = Some(format.into());
177 self
178 }
179
180 #[must_use]
182 pub fn format_table(self) -> Self {
183 Self {
184 format: None,
185 ..self
186 }
187 }
188
189 #[must_use]
191 pub fn format_json(self) -> Self {
192 self.format("json")
193 }
194
195 #[must_use]
205 pub fn no_trunc(mut self) -> Self {
206 self.no_trunc = true;
207 self
208 }
209
210 #[must_use]
216 pub fn executor(mut self, executor: CommandExecutor) -> Self {
217 self.executor = executor;
218 self
219 }
220
221 fn build_command_args(&self) -> Vec<String> {
223 let mut args = vec!["search".to_string()];
224
225 if let Some(limit) = self.limit {
227 args.push("--limit".to_string());
228 args.push(limit.to_string());
229 }
230
231 for filter in &self.filters {
233 args.push("--filter".to_string());
234 args.push(filter.clone());
235 }
236
237 if let Some(ref format) = self.format {
239 args.push("--format".to_string());
240 args.push(format.clone());
241 }
242
243 if self.no_trunc {
245 args.push("--no-trunc".to_string());
246 }
247
248 args.push(self.term.clone());
250
251 args
252 }
253
254 fn parse_output(&self, output: &CommandOutput) -> Result<Vec<RepositoryInfo>> {
256 if let Some(ref format) = self.format {
257 if format == "json" {
258 return Self::parse_json_output(&output.stdout);
259 }
260 }
261
262 Ok(Self::parse_table_output(output))
263 }
264
265 fn parse_json_output(stdout: &str) -> Result<Vec<RepositoryInfo>> {
267 let mut repositories = Vec::new();
268
269 for line in stdout.lines() {
270 if line.trim().is_empty() {
271 continue;
272 }
273
274 let parsed: serde_json::Value = serde_json::from_str(line).map_err(|e| {
276 Error::parse_error(format!("Failed to parse search JSON output: {e}"))
277 })?;
278
279 let name = parsed["Name"].as_str().unwrap_or("").to_string();
280 let description = parsed["Description"].as_str().unwrap_or("").to_string();
281 let stars = u32::try_from(parsed["StarCount"].as_u64().unwrap_or(0)).unwrap_or(0);
282 let official = parsed["IsOfficial"].as_bool().unwrap_or(false);
283 let automated = parsed["IsAutomated"].as_bool().unwrap_or(false);
284
285 repositories.push(RepositoryInfo {
286 name,
287 description,
288 stars,
289 official,
290 automated,
291 });
292 }
293
294 Ok(repositories)
295 }
296
297 fn parse_table_output(output: &CommandOutput) -> Vec<RepositoryInfo> {
299 let mut repositories = Vec::new();
300 let lines: Vec<&str> = output.stdout.lines().collect();
301
302 if lines.is_empty() {
303 return repositories;
304 }
305
306 let data_lines = if lines.len() > 1 && lines[0].starts_with("NAME") {
308 &lines[1..]
309 } else {
310 &lines
311 };
312
313 for line in data_lines {
314 if line.trim().is_empty() {
315 continue;
316 }
317
318 let parts: Vec<&str> = line.split_whitespace().collect();
321 if parts.len() < 5 {
322 continue;
323 }
324
325 let name = parts[0].to_string();
326
327 let mut stars_index = 0;
329 for (i, part) in parts.iter().enumerate().skip(1) {
330 if part.parse::<u32>().is_ok() {
331 stars_index = i;
332 break;
333 }
334 }
335
336 if stars_index == 0 {
337 continue; }
339
340 let description = parts[1..stars_index].join(" ");
342 let stars = parts[stars_index].parse::<u32>().unwrap_or(0);
343
344 let official = if parts.len() > stars_index + 1 {
346 parts[stars_index + 1] == "[OK]"
347 } else {
348 false
349 };
350
351 let automated = if parts.len() > stars_index + 2 {
352 parts[stars_index + 2] == "[OK]"
353 } else {
354 false
355 };
356
357 repositories.push(RepositoryInfo {
358 name,
359 description,
360 stars,
361 official,
362 automated,
363 });
364 }
365
366 repositories
367 }
368
369 #[must_use]
371 pub fn get_term(&self) -> &str {
372 &self.term
373 }
374
375 #[must_use]
377 pub fn get_limit(&self) -> Option<u32> {
378 self.limit
379 }
380
381 #[must_use]
383 pub fn get_filters(&self) -> &[String] {
384 &self.filters
385 }
386
387 #[must_use]
389 pub fn get_format(&self) -> Option<&str> {
390 self.format.as_deref()
391 }
392
393 #[must_use]
395 pub fn is_no_trunc(&self) -> bool {
396 self.no_trunc
397 }
398}
399
400impl Default for SearchCommand {
401 fn default() -> Self {
402 Self::new("")
403 }
404}
405
406impl SearchOutput {
407 #[must_use]
409 pub fn success(&self) -> bool {
410 self.output.success
411 }
412
413 #[must_use]
415 pub fn repository_count(&self) -> usize {
416 self.repositories.len()
417 }
418
419 #[must_use]
421 pub fn repository_names(&self) -> Vec<&str> {
422 self.repositories.iter().map(|r| r.name.as_str()).collect()
423 }
424
425 #[must_use]
427 pub fn filter_by_stars(&self, min_stars: u32) -> Vec<&RepositoryInfo> {
428 self.repositories
429 .iter()
430 .filter(|r| r.stars >= min_stars)
431 .collect()
432 }
433
434 #[must_use]
436 pub fn official_repositories(&self) -> Vec<&RepositoryInfo> {
437 self.repositories.iter().filter(|r| r.official).collect()
438 }
439
440 #[must_use]
442 pub fn automated_repositories(&self) -> Vec<&RepositoryInfo> {
443 self.repositories.iter().filter(|r| r.automated).collect()
444 }
445
446 #[must_use]
448 pub fn is_empty(&self) -> bool {
449 self.repositories.is_empty()
450 }
451
452 #[must_use]
454 pub fn most_popular(&self) -> Option<&RepositoryInfo> {
455 self.repositories.iter().max_by_key(|r| r.stars)
456 }
457}
458
459#[async_trait]
460impl DockerCommand for SearchCommand {
461 type Output = SearchOutput;
462
463 fn command_name(&self) -> &'static str {
464 "search"
465 }
466
467 fn build_args(&self) -> Vec<String> {
468 self.build_command_args()
469 }
470
471 async fn execute(&self) -> Result<Self::Output> {
472 let output = self
473 .executor
474 .execute_command(self.command_name(), self.build_args())
475 .await?;
476
477 let repositories = self.parse_output(&output)?;
478
479 Ok(SearchOutput {
480 output,
481 repositories,
482 })
483 }
484
485 fn arg<S: AsRef<OsStr>>(&mut self, arg: S) -> &mut Self {
486 self.executor.add_arg(arg);
487 self
488 }
489
490 fn args<I, S>(&mut self, args: I) -> &mut Self
491 where
492 I: IntoIterator<Item = S>,
493 S: AsRef<OsStr>,
494 {
495 self.executor.add_args(args);
496 self
497 }
498
499 fn flag(&mut self, flag: &str) -> &mut Self {
500 match flag {
501 "--no-trunc" | "no-trunc" => {
502 self.no_trunc = true;
503 }
504 _ => {
505 self.executor.add_flag(flag);
506 }
507 }
508 self
509 }
510
511 fn option(&mut self, key: &str, value: &str) -> &mut Self {
512 match key {
513 "--limit" | "limit" => {
514 if let Ok(limit) = value.parse::<u32>() {
515 self.limit = Some(limit);
516 }
517 }
518 "--format" | "format" => {
519 self.format = Some(value.to_string());
520 }
521 "--filter" | "filter" => {
522 self.filters.push(value.to_string());
523 }
524 _ => {
525 self.executor.add_option(key, value);
526 }
527 }
528 self
529 }
530}
531
532impl fmt::Display for SearchCommand {
533 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
534 write!(f, "docker search")?;
535
536 if let Some(limit) = self.limit {
537 write!(f, " --limit {limit}")?;
538 }
539
540 for filter in &self.filters {
541 write!(f, " --filter {filter}")?;
542 }
543
544 if let Some(ref format) = self.format {
545 write!(f, " --format {format}")?;
546 }
547
548 if self.no_trunc {
549 write!(f, " --no-trunc")?;
550 }
551
552 write!(f, " {}", self.term)?;
553
554 Ok(())
555 }
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561
562 #[test]
563 fn test_search_command_basic() {
564 let search = SearchCommand::new("redis");
565
566 assert_eq!(search.get_term(), "redis");
567 assert_eq!(search.get_limit(), None);
568 assert!(search.get_filters().is_empty());
569 assert!(!search.is_no_trunc());
570
571 let args = search.build_command_args();
572 assert_eq!(args, vec!["search", "redis"]);
573 }
574
575 #[test]
576 fn test_search_command_with_limit() {
577 let search = SearchCommand::new("nginx").limit(10);
578
579 assert_eq!(search.get_limit(), Some(10));
580
581 let args = search.build_command_args();
582 assert_eq!(args, vec!["search", "--limit", "10", "nginx"]);
583 }
584
585 #[test]
586 fn test_search_command_with_filters() {
587 let search = SearchCommand::new("postgres")
588 .filter("stars=25")
589 .filter("is-official=true");
590
591 assert_eq!(search.get_filters(), &["stars=25", "is-official=true"]);
592
593 let args = search.build_command_args();
594 assert!(args.contains(&"--filter".to_string()));
595 assert!(args.contains(&"stars=25".to_string()));
596 assert!(args.contains(&"is-official=true".to_string()));
597 }
598
599 #[test]
600 fn test_search_command_with_multiple_filters() {
601 let search = SearchCommand::new("golang").filters(vec!["stars=10", "is-automated=true"]);
602
603 assert_eq!(search.get_filters(), &["stars=10", "is-automated=true"]);
604 }
605
606 #[test]
607 fn test_search_command_with_format() {
608 let search = SearchCommand::new("ubuntu").format_json();
609
610 assert_eq!(search.get_format(), Some("json"));
611
612 let args = search.build_command_args();
613 assert!(args.contains(&"--format".to_string()));
614 assert!(args.contains(&"json".to_string()));
615 }
616
617 #[test]
618 fn test_search_command_no_trunc() {
619 let search = SearchCommand::new("mysql").no_trunc();
620
621 assert!(search.is_no_trunc());
622
623 let args = search.build_command_args();
624 assert!(args.contains(&"--no-trunc".to_string()));
625 }
626
627 #[test]
628 fn test_search_command_all_options() {
629 let search = SearchCommand::new("golang")
630 .limit(5)
631 .filter("stars=10")
632 .filter("is-official=true")
633 .no_trunc()
634 .format("table");
635
636 let args = search.build_command_args();
637 assert!(args.contains(&"--limit".to_string()));
638 assert!(args.contains(&"5".to_string()));
639 assert!(args.contains(&"--filter".to_string()));
640 assert!(args.contains(&"stars=10".to_string()));
641 assert!(args.contains(&"is-official=true".to_string()));
642 assert!(args.contains(&"--no-trunc".to_string()));
643 assert!(args.contains(&"--format".to_string()));
644 assert!(args.contains(&"table".to_string()));
645 assert!(args.contains(&"golang".to_string()));
646 }
647
648 #[test]
649 fn test_search_command_default() {
650 let search = SearchCommand::default();
651
652 assert_eq!(search.get_term(), "");
653 assert_eq!(search.get_limit(), None);
654 assert!(search.get_filters().is_empty());
655 }
656
657 #[test]
658 fn test_repository_info_creation() {
659 let repo = RepositoryInfo {
660 name: "redis".to_string(),
661 description: "Redis is an in-memory database".to_string(),
662 stars: 1000,
663 official: true,
664 automated: false,
665 };
666
667 assert_eq!(repo.name, "redis");
668 assert_eq!(repo.stars, 1000);
669 assert!(repo.official);
670 assert!(!repo.automated);
671 }
672
673 #[test]
674 fn test_search_output_helpers() {
675 let repos = vec![
676 RepositoryInfo {
677 name: "redis".to_string(),
678 description: "Official Redis".to_string(),
679 stars: 1000,
680 official: true,
681 automated: false,
682 },
683 RepositoryInfo {
684 name: "redis-custom".to_string(),
685 description: "Custom Redis build".to_string(),
686 stars: 50,
687 official: false,
688 automated: true,
689 },
690 ];
691
692 let output = SearchOutput {
693 output: CommandOutput {
694 stdout: String::new(),
695 stderr: String::new(),
696 exit_code: 0,
697 success: true,
698 },
699 repositories: repos,
700 };
701
702 assert_eq!(output.repository_count(), 2);
703 assert!(!output.is_empty());
704
705 let names = output.repository_names();
706 assert_eq!(names, vec!["redis", "redis-custom"]);
707
708 let high_stars = output.filter_by_stars(100);
709 assert_eq!(high_stars.len(), 1);
710 assert_eq!(high_stars[0].name, "redis");
711
712 let official = output.official_repositories();
713 assert_eq!(official.len(), 1);
714 assert_eq!(official[0].name, "redis");
715
716 let automated = output.automated_repositories();
717 assert_eq!(automated.len(), 1);
718 assert_eq!(automated[0].name, "redis-custom");
719
720 let most_popular = output.most_popular().unwrap();
721 assert_eq!(most_popular.name, "redis");
722 }
723
724 #[test]
725 fn test_search_command_display() {
726 let search = SearchCommand::new("alpine")
727 .limit(10)
728 .filter("stars=5")
729 .filter("is-official=true")
730 .no_trunc()
731 .format("json");
732
733 let display = format!("{search}");
734 assert!(display.contains("docker search"));
735 assert!(display.contains("--limit 10"));
736 assert!(display.contains("--filter stars=5"));
737 assert!(display.contains("--filter is-official=true"));
738 assert!(display.contains("--no-trunc"));
739 assert!(display.contains("--format json"));
740 assert!(display.contains("alpine"));
741 }
742
743 #[test]
744 fn test_search_command_name() {
745 let search = SearchCommand::new("test");
746 assert_eq!(search.command_name(), "search");
747 }
748
749 #[test]
750 fn test_search_command_extensibility() {
751 let mut search = SearchCommand::new("node");
752
753 search
755 .arg("extra")
756 .args(vec!["more", "args"])
757 .flag("--no-trunc")
758 .option("--limit", "5")
759 .option("--format", "json")
760 .option("--filter", "stars=10");
761
762 assert!(search.is_no_trunc());
764 assert_eq!(search.get_limit(), Some(5));
765 assert_eq!(search.get_format(), Some("json"));
766 assert!(search.get_filters().contains(&"stars=10".to_string()));
767 }
768
769 #[test]
770 fn test_parse_json_output() {
771 let json_output = r#"{"Name":"redis","Description":"Redis is an in-memory database","StarCount":1000,"IsOfficial":true,"IsAutomated":false}
772{"Name":"nginx","Description":"Official build of Nginx","StarCount":2000,"IsOfficial":true,"IsAutomated":false}"#;
773
774 let repos = SearchCommand::parse_json_output(json_output).unwrap();
775
776 assert_eq!(repos.len(), 2);
777 assert_eq!(repos[0].name, "redis");
778 assert_eq!(repos[0].stars, 1000);
779 assert!(repos[0].official);
780 assert_eq!(repos[1].name, "nginx");
781 assert_eq!(repos[1].stars, 2000);
782 }
783
784 #[test]
785 fn test_parse_table_output_concept() {
786 let output = CommandOutput {
788 stdout: "NAME DESCRIPTION STARS OFFICIAL AUTOMATED\nredis Redis database 5000 [OK] \nnginx Web server 3000 [OK]".to_string(),
789 stderr: String::new(),
790 exit_code: 0,
791 success: true,
792 };
793
794 let result = SearchCommand::parse_table_output(&output);
795
796 assert!(result.is_empty() || !result.is_empty()); }
799}