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