use clap::Args; use mozart_registry::packagist::SearchResult; #[derive(Args)] pub struct SearchArgs { /// Search tokens #[arg(required = true)] pub tokens: Vec, /// Search only in name #[arg(short = 'N', long)] pub only_name: bool, /// Search only for vendor / organization #[arg(short = 'O', long)] pub only_vendor: bool, /// Filter by package type #[arg(short, long, value_name = "TYPE")] pub r#type: Option, /// Output format (text, json) #[arg(short, long)] pub format: Option, } /// Format a large count as a human-readable string (e.g. 1500 -> "1.5K", 2500000 -> "2.5M"). fn format_count(n: u64) -> String { if n >= 1_000_000 { let m = n as f64 / 1_000_000.0; // Show one decimal place only when needed if (m - m.floor()).abs() < 0.05 { format!("{}M", m.floor() as u64) } else { format!("{:.1}M", m) } } else if n >= 1_000 { let k = n as f64 / 1_000.0; if (k - k.floor()).abs() < 0.05 { format!("{}K", k.floor() as u64) } else { format!("{:.1}K", k) } } else { n.to_string() } } /// Returns true if the result passes the `--only-name` filter: the package name must contain /// the query string (case-insensitive). fn passes_only_name(result: &SearchResult, query: &str) -> bool { result.name.to_lowercase().contains(&query.to_lowercase()) } /// Returns true if the result passes the `--only-vendor` filter: the vendor portion of the /// package name (before the `/`) must equal the query (case-insensitive). fn passes_only_vendor(result: &SearchResult, query: &str) -> bool { let vendor = result.name.split('/').next().unwrap_or(""); vendor.eq_ignore_ascii_case(query) } pub async fn execute( args: &SearchArgs, _cli: &super::Cli, _console: &mozart_core::console::Console, ) -> anyhow::Result<()> { if args.only_name && args.only_vendor { anyhow::bail!("--only-name and --only-vendor cannot be used together"); } let query = args.tokens.join(" "); let format = args.format.as_deref().unwrap_or("text"); if !matches!(format, "text" | "json") { eprintln!( "{}", mozart_core::console::error(&format!( "Unsupported format \"{format}\". See help for supported formats." )) ); std::process::exit(1); } let (all_results, total) = mozart_registry::packagist::search_packages(&query, args.r#type.as_deref()).await?; // Apply client-side filters let mut results: Vec<&SearchResult> = all_results.iter().collect(); if args.only_name { results.retain(|r| passes_only_name(r, &query)); } if args.only_vendor { results.retain(|r| passes_only_vendor(r, &query)); } // Output match format { "json" => { let owned: Vec = results.into_iter().cloned().collect(); let json = serde_json::to_string_pretty(&owned)?; println!("{json}"); } _ => { if results.is_empty() { eprintln!( "{}", mozart_core::console::warning(&format!("No packages found for \"{query}\"")) ); return Ok(()); } eprintln!( "Found {} packages matching \"{}\" (showing {} result{})", total, query, results.len(), if results.len() == 1 { "" } else { "s" } ); eprintln!(); // Calculate alignment widths let name_width = results.iter().map(|r| r.name.len()).max().unwrap_or(0); for result in &results { let dl_str = format!("Downloads: {}", format_count(result.downloads)); let fav_str = format!("Favers: {}", format_count(result.favers)); println!( "{} {} {}", mozart_core::console::info(&format!( "{: SearchResult { SearchResult { name: name.to_string(), description: String::new(), url: format!("https://packagist.org/packages/{name}"), repository: None, downloads: 0, favers: 0, } } }