aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-registry/src/packagist.rs
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-08 03:12:05 +0900
committernsfisis <nsfisis@gmail.com>2026-05-08 03:12:05 +0900
commite6f35b567564665c6cb741a06e4c4afcdc5ab317 (patch)
treea1ae0e55afab95598ff0f6bec5b6a2e9f4f86cc0 /crates/mozart-registry/src/packagist.rs
parentb3fcdf3b5cf0d6b43109c4bcb3dfcbb6576abce5 (diff)
downloadphp-mozart-e6f35b567564665c6cb741a06e4c4afcdc5ab317.tar.gz
php-mozart-e6f35b567564665c6cb741a06e4c4afcdc5ab317.tar.zst
php-mozart-e6f35b567564665c6cb741a06e4c4afcdc5ab317.zip
fix(search): align with Composer's RepositoryInterface::search
Replace the HTTP-only post-filtered implementation with a Repository::search trait dispatch that mirrors ComposerRepository::search semantics for all three modes (FULLTEXT/NAME/VENDOR). --only-name now does an OR-of-tokens regex match against the full Packagist list.json index instead of a substring match against a fulltext page, so e.g. \`mozart search --only-name mono log\` matches \`monolog/monolog\` like Composer does. Other parity fixes: regex::escape on non-fulltext queries, format check before mutex check, 4-space JSON indent, OSC 8 terminal hyperlink emission when a result has a url, <warning>\! Abandoned \!</warning> styling on abandoned rows, and the Mozart-only "No packages found" warning is dropped to match Composer's silent empty-result behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart-registry/src/packagist.rs')
-rw-r--r--crates/mozart-registry/src/packagist.rs72
1 files changed, 72 insertions, 0 deletions
diff --git a/crates/mozart-registry/src/packagist.rs b/crates/mozart-registry/src/packagist.rs
index 6f9b24a..5c99b07 100644
--- a/crates/mozart-registry/src/packagist.rs
+++ b/crates/mozart-registry/src/packagist.rs
@@ -367,6 +367,78 @@ pub async fn search_packages(
Ok((all_results, total))
}
+/// Response shape of `https://packagist.org/packages/list.json[?type=...]`.
+#[derive(Debug, Deserialize)]
+struct ListResponse {
+ #[serde(rename = "packageNames")]
+ package_names: Vec<String>,
+}
+
+/// Fetch the full list of Packagist package names, optionally filtered by type.
+///
+/// Backs Composer's `ComposerRepository::getPackageNames()` for the
+/// `SEARCH_NAME` and `SEARCH_VENDOR` search modes. Cached on disk under
+/// `list-packages~{type}.json` (or `list-packages~all.json` when no type
+/// filter is given).
+#[tracing::instrument(skip(repo_cache))]
+pub async fn fetch_package_names(
+ package_type: Option<&str>,
+ repo_cache: &Cache,
+) -> anyhow::Result<Vec<String>> {
+ let cache_key = match package_type {
+ Some(t) => format!("list-packages~{t}.json"),
+ None => "list-packages~all.json".to_string(),
+ };
+
+ if let Some(cached) = repo_cache.read(&cache_key) {
+ tracing::debug!("cache hit");
+ let parsed: ListResponse = serde_json::from_str(&cached)?;
+ return Ok(parsed.package_names);
+ }
+
+ let mut url = "https://packagist.org/packages/list.json".to_string();
+ if let Some(t) = package_type {
+ url.push_str("?type=");
+ url.push_str(&url_encode(t));
+ }
+ tracing::debug!(%url, "fetching package list");
+ let client = mozart_core::http::client_builder().build()?;
+ let response = client.get(&url).send().await?;
+ tracing::debug!(status = %response.status(), "received response");
+
+ if !response.status().is_success() {
+ anyhow::bail!(
+ "Failed to fetch package list from Packagist (HTTP {})",
+ response.status()
+ );
+ }
+
+ let body = response.text().await?;
+ let _ = repo_cache.write(&cache_key, &body);
+
+ let parsed: ListResponse = serde_json::from_str(&body)?;
+ Ok(parsed.package_names)
+}
+
+/// Fetch the deduplicated list of Packagist vendor names.
+///
+/// Mirrors Composer's `ComposerRepository::getVendorNames()` which derives
+/// vendors from `getPackageNames()` (regardless of type) by stripping the
+/// `/...` suffix and de-duplicating in insertion order.
+#[tracing::instrument(skip(repo_cache))]
+pub async fn fetch_vendor_names(repo_cache: &Cache) -> anyhow::Result<Vec<String>> {
+ let names = fetch_package_names(None, repo_cache).await?;
+ let mut seen: indexmap::IndexSet<String> = indexmap::IndexSet::new();
+ for name in names {
+ let vendor = match name.split_once('/') {
+ Some((v, _)) => v.to_string(),
+ None => name,
+ };
+ seen.insert(vendor);
+ }
+ Ok(seen.into_iter().collect())
+}
+
/// A single security advisory from the Packagist API.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SecurityAdvisory {