From 14fcc6e9a649cdaeb0e80fec808ed9824a02e20f Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 21 Feb 2026 13:50:27 +0900 Subject: feat(search): implement search command with Packagist API integration Adds full implementation of the `search` command, querying the Packagist search API with pagination, --only-name/--only-vendor filtering, --type filtering, and text/json output formats. Co-Authored-By: Claude Opus 4.6 --- crates/mozart/src/commands/search.rs | 372 ++++++++++++++++++++++++++++++++++- 1 file changed, 370 insertions(+), 2 deletions(-) (limited to 'crates') diff --git a/crates/mozart/src/commands/search.rs b/crates/mozart/src/commands/search.rs index 9396e6f..5b35a20 100644 --- a/crates/mozart/src/commands/search.rs +++ b/crates/mozart/src/commands/search.rs @@ -1,4 +1,5 @@ use clap::Args; +use serde::{Deserialize, Serialize}; #[derive(Args)] pub struct SearchArgs { @@ -23,6 +24,373 @@ pub struct SearchArgs { pub format: Option, } -pub fn execute(_args: &SearchArgs, _cli: &super::Cli) -> anyhow::Result<()> { - todo!() +/// Maximum number of pages to fetch from the Packagist search API. +const MAX_PAGES: usize = 20; + +#[derive(Debug, Deserialize)] +struct SearchResponse { + results: Vec, + total: u64, + next: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +struct SearchResult { + name: String, + description: String, + url: String, + repository: Option, + downloads: u64, + favers: u64, +} + +/// 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) +} + +/// Percent-encode a string for use in a URL query parameter value. +/// Encodes spaces as `%20` and other reserved/non-ASCII characters. +fn url_encode(s: &str) -> String { + let mut encoded = String::with_capacity(s.len()); + for byte in s.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(byte as char); + } + b' ' => encoded.push_str("%20"), + other => { + encoded.push_str(&format!("%{other:02X}")); + } + } + } + encoded +} + +pub fn execute(args: &SearchArgs, _cli: &super::Cli) -> anyhow::Result<()> { + let query = args.tokens.join(" "); + + let client = reqwest::blocking::Client::builder() + .user_agent("mozart/0.1.0") + .build()?; + + let mut all_results: Vec = Vec::new(); + let mut page = 1usize; + let mut next_url: Option = None; + let mut total: u64 = 0; + + loop { + let response: SearchResponse = if let Some(ref url) = next_url { + // Packagist gives us the full next URL; just fetch it + let resp = client.get(url).send()?; + if !resp.status().is_success() { + anyhow::bail!("Packagist search request failed (HTTP {})", resp.status()); + } + resp.json()? + } else { + // Build the first request URL with query parameters encoded manually + let encoded_query = url_encode(&query); + let mut url = format!("https://packagist.org/search.json?q={encoded_query}"); + if let Some(ref t) = args.r#type { + url.push_str("&type="); + url.push_str(&url_encode(t)); + } + + let resp = client.get(&url).send()?; + + if !resp.status().is_success() { + anyhow::bail!("Packagist search request failed (HTTP {})", resp.status()); + } + resp.json()? + }; + + if page == 1 { + total = response.total; + } + + all_results.extend(response.results); + next_url = response.next; + page += 1; + + if next_url.is_none() || page > MAX_PAGES { + break; + } + } + + // 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 + let format = args.format.as_deref().unwrap_or("text"); + + 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!( + "{}", + crate::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!( + "{} {} {}", + crate::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, + } + } } -- cgit v1.3.1