aboutsummaryrefslogtreecommitdiffhomepage
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
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>
-rw-r--r--crates/mozart-core/src/console.rs47
-rw-r--r--crates/mozart-registry/src/packagist.rs72
-rw-r--r--crates/mozart-registry/src/repository/mod.rs53
-rw-r--r--crates/mozart-registry/src/repository/packagist_repo.rs66
-rw-r--r--crates/mozart/src/commands/search.rs328
5 files changed, 338 insertions, 228 deletions
diff --git a/crates/mozart-core/src/console.rs b/crates/mozart-core/src/console.rs
index b8db17e..e0c224f 100644
--- a/crates/mozart-core/src/console.rs
+++ b/crates/mozart-core/src/console.rs
@@ -43,6 +43,30 @@ pub fn __format_warning_message(message: &str) -> ColoredString {
}
// ---------------------------------------------------------------------------
+// Terminal hyperlinks (OSC 8)
+// ---------------------------------------------------------------------------
+
+/// Wrap `text` in an OSC 8 terminal hyperlink escape sequence pointing at `url`.
+///
+/// Mirrors Composer's `<href=URL>text</>` formatter tag, which Symfony Console
+/// renders to the same OSC 8 sequence. Mozart's tag formatter is a
+/// compile-time proc-macro that doesn't accept runtime attributes, so this
+/// helper is the runtime path.
+///
+/// When `decorated` is `false`, returns `text` unchanged (matching Symfony's
+/// behavior of suppressing hyperlinks on non-decorated outputs).
+///
+/// Control characters in `url` (`\x00`–`\x1f`, `\x7f`) are stripped to prevent
+/// terminating the escape sequence early.
+pub fn hyperlink(url: &str, text: &str, decorated: bool) -> String {
+ if !decorated {
+ return text.to_string();
+ }
+ let safe_url: String = url.chars().filter(|c| !c.is_control()).collect();
+ format!("\x1b]8;;{safe_url}\x1b\\{text}\x1b]8;;\x1b\\")
+}
+
+// ---------------------------------------------------------------------------
// Verbosity
// ---------------------------------------------------------------------------
@@ -413,4 +437,27 @@ mod tests {
assert!(!make_console(Verbosity::VeryVerbose).is_debug());
assert!(make_console(Verbosity::Debug).is_debug());
}
+
+ #[test]
+ fn test_hyperlink_decorated() {
+ let out = hyperlink("https://example.com", "click", true);
+ assert!(out.contains("https://example.com"));
+ assert!(out.contains("click"));
+ assert!(out.starts_with("\x1b]8;;"));
+ assert!(out.ends_with("\x1b]8;;\x1b\\"));
+ }
+
+ #[test]
+ fn test_hyperlink_undecorated_returns_plain_text() {
+ let out = hyperlink("https://example.com", "click", false);
+ assert_eq!(out, "click");
+ }
+
+ #[test]
+ fn test_hyperlink_strips_control_chars_from_url() {
+ let out = hyperlink("https://example.com/\x00\x07path", "click", true);
+ assert!(out.contains("https://example.com/path"));
+ assert!(!out.contains('\x00'));
+ assert!(!out.contains('\x07'));
+ }
}
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 {
diff --git a/crates/mozart-registry/src/repository/mod.rs b/crates/mozart-registry/src/repository/mod.rs
index 21752b9..6642638 100644
--- a/crates/mozart-registry/src/repository/mod.rs
+++ b/crates/mozart-registry/src/repository/mod.rs
@@ -10,12 +10,29 @@
//! the live Packagist HTTP repo, [`inline_package_repo`] for `type: package`
//! entries embedded in `composer.json`, and [`vcs_repo`] for VCS repositories.
-use crate::packagist::PackagistVersion;
+use crate::packagist::{PackagistVersion, SearchResult};
pub mod inline_package_repo;
pub mod packagist_repo;
pub mod vcs_repo;
+/// Search modes for [`Repository::search`].
+///
+/// Mirrors Composer's `RepositoryInterface::SEARCH_FULLTEXT|SEARCH_NAME|SEARCH_VENDOR`
+/// constants (`composer/src/Composer/Repository/RepositoryInterface.php`).
+#[derive(Copy, Clone, Eq, PartialEq, Debug)]
+pub enum SearchMode {
+ /// Full-text search over name, description, and keywords (Packagist's
+ /// `search.json` API).
+ Fulltext,
+ /// Match the regex against package names. Tokens are split on whitespace
+ /// and joined as `(?:t1|t2|...)`; callers must pre-quote regex metachars.
+ Name,
+ /// Match the regex against vendor names. Result rows have only `name`
+ /// populated (the vendor part).
+ Vendor,
+}
+
/// One name-keyed lookup against a repository.
///
/// Matches the `$packageNameMap` argument of Composer's `loadPackages`. The
@@ -65,6 +82,22 @@ pub trait Repository: Send + Sync {
/// Look up every version of every queried name this repo knows about.
async fn load_packages(&self, queries: &[PackageQuery<'_>]) -> anyhow::Result<LoadResult>;
+
+ /// Search this repository.
+ ///
+ /// The default returns an empty result so repositories that don't
+ /// participate in search (e.g. inline / VCS repos that only resolve
+ /// known names) can opt out. Mirrors Composer's
+ /// `RepositoryInterface::search` whose default behavior on
+ /// `ArrayRepository` walks the in-memory list.
+ async fn search(
+ &self,
+ _query: &str,
+ _mode: SearchMode,
+ _package_type: Option<&str>,
+ ) -> anyhow::Result<Vec<SearchResult>> {
+ Ok(Vec::new())
+ }
}
/// Ordered list of repositories. Mirrors `Composer\Repository\RepositoryManager`.
@@ -140,4 +173,22 @@ impl RepositorySet {
Ok(packages)
}
+
+ /// Fan-out search across every repository, concatenating results in
+ /// priority order. Mirrors Composer's
+ /// `CompositeRepository::search` which `array_merge`s per-repo results
+ /// without de-duplication.
+ pub async fn search(
+ &self,
+ query: &str,
+ mode: SearchMode,
+ package_type: Option<&str>,
+ ) -> anyhow::Result<Vec<SearchResult>> {
+ let mut all = Vec::new();
+ for repo in &self.repos {
+ let mut hits = repo.search(query, mode, package_type).await?;
+ all.append(&mut hits);
+ }
+ Ok(all)
+ }
}
diff --git a/crates/mozart-registry/src/repository/packagist_repo.rs b/crates/mozart-registry/src/repository/packagist_repo.rs
index 6f9b687..fa656b7 100644
--- a/crates/mozart-registry/src/repository/packagist_repo.rs
+++ b/crates/mozart-registry/src/repository/packagist_repo.rs
@@ -5,9 +5,10 @@
//! direct call. Construction takes ownership of the [`Cache`] handle so
//! callers no longer thread it through `ResolveRequest` / `LockFileGenerationRequest`.
-use super::{LoadResult, NamedPackagistVersion, PackageQuery, Repository};
+use super::{LoadResult, NamedPackagistVersion, PackageQuery, Repository, SearchMode};
use crate::cache::Cache;
use crate::packagist;
+use crate::packagist::SearchResult;
pub struct PackagistRepository {
id: String,
@@ -54,4 +55,67 @@ impl Repository for PackagistRepository {
}
Ok(result)
}
+
+ async fn search(
+ &self,
+ query: &str,
+ mode: SearchMode,
+ package_type: Option<&str>,
+ ) -> anyhow::Result<Vec<SearchResult>> {
+ match mode {
+ SearchMode::Fulltext => {
+ let (results, _total) = packagist::search_packages(query, package_type).await?;
+ Ok(results)
+ }
+ SearchMode::Name => {
+ let pattern = build_name_regex(query)?;
+ let names = packagist::fetch_package_names(package_type, &self.cache).await?;
+ Ok(names
+ .into_iter()
+ .filter(|name| pattern.is_match(name))
+ .map(empty_search_result)
+ .collect())
+ }
+ SearchMode::Vendor => {
+ let pattern = build_name_regex(query)?;
+ let vendors = packagist::fetch_vendor_names(&self.cache).await?;
+ Ok(vendors
+ .into_iter()
+ .filter(|name| pattern.is_match(name))
+ .map(empty_search_result)
+ .collect())
+ }
+ }
+ }
+}
+
+/// Build the case-insensitive `(?:t1|t2|...)` regex from whitespace-split
+/// tokens, mirroring Composer's `'{(?:'.implode('|', $matches).')}i'`.
+///
+/// Tokens are joined as-is — callers are expected to have already escaped
+/// regex metacharacters (`SearchCommand` calls `preg_quote`; Mozart calls
+/// `regex::escape` before reaching this point).
+fn build_name_regex(query: &str) -> anyhow::Result<regex::Regex> {
+ let tokens: Vec<&str> = query.split_whitespace().collect();
+ let body = if tokens.is_empty() {
+ String::new()
+ } else {
+ tokens.join("|")
+ };
+ Ok(regex::Regex::new(&format!("(?i)(?:{body})"))?)
+}
+
+/// Build a [`SearchResult`] with only `name` populated, mirroring the shape
+/// Composer returns for `SEARCH_NAME` / `SEARCH_VENDOR` modes
+/// (`['name' => $name]`, all other fields `null`).
+fn empty_search_result(name: String) -> SearchResult {
+ SearchResult {
+ name,
+ description: String::new(),
+ url: String::new(),
+ repository: None,
+ downloads: 0,
+ favers: 0,
+ abandoned: None,
+ }
}
diff --git a/crates/mozart/src/commands/search.rs b/crates/mozart/src/commands/search.rs
index 25d32da..4d8641f 100644
--- a/crates/mozart/src/commands/search.rs
+++ b/crates/mozart/src/commands/search.rs
@@ -1,7 +1,9 @@
use clap::Args;
+use mozart_core::console::{Console, hyperlink};
use mozart_core::console_format;
use mozart_core::console_writeln;
use mozart_registry::packagist::SearchResult;
+use mozart_registry::repository::{RepositorySet, SearchMode};
use serde::Serialize;
/// JSON output structure matching Composer's search result schema.
@@ -50,29 +52,6 @@ pub struct SearchArgs {
pub format: Option<String>,
}
-/// Format a large count as a human-readable string (e.g. 1500 -> "1.5K", 2500000 -> "2.5M").
-#[allow(dead_code)]
-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 search result represents an abandoned package.
///
/// The `abandoned` field from the Packagist API can be:
@@ -89,32 +68,10 @@ fn is_abandoned(result: &SearchResult) -> bool {
}
}
-/// 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(" ");
-
+pub async fn execute(args: &SearchArgs, cli: &super::Cli, console: &Console) -> anyhow::Result<()> {
+ // 1. Format check first — matches Composer's `SearchCommand::execute`
+ // L61-66 ordering.
let format = args.format.as_deref().unwrap_or("text");
-
if !matches!(format, "text" | "json") {
console.error(&console_format!(
"<error>Unsupported format \"{format}\". See help for supported formats.</error>"
@@ -124,98 +81,116 @@ pub async fn execute(
));
}
- let (all_results, _total) =
- mozart_registry::packagist::search_packages(&query, args.r#type.as_deref()).await?;
+ // 2. Mutex check on the two scoping flags. Composer's
+ // `RepositoryFactory::generateRepositoryManager` precedes this with
+ // `tryComposer`; we skip until configured-repos support lands.
+ if args.only_name && args.only_vendor {
+ anyhow::bail!("--only-name and --only-vendor cannot be used together");
+ }
- // Apply client-side filters
- let mut results: Vec<&SearchResult> = all_results.iter().collect();
+ // 3. Mode resolution. Composer checks `--only-vendor` before `--only-name`
+ // (`SearchCommand::execute` L78-86), so vendor wins if both are set —
+ // but the mutex check above already guards that.
+ let mode = if args.only_vendor {
+ SearchMode::Vendor
+ } else if args.only_name {
+ SearchMode::Name
+ } else {
+ SearchMode::Fulltext
+ };
- if args.only_name {
- results.retain(|r| passes_only_name(r, &query));
+ // 4. Build the query string. Composer joins tokens with a single space
+ // and `preg_quote`s the result for non-fulltext modes so that user
+ // input like `c++` is matched literally rather than as regex
+ // metacharacters.
+ let mut query = args.tokens.join(" ");
+ if !matches!(mode, SearchMode::Fulltext) {
+ query = regex::escape(&query);
}
- if args.only_vendor {
- results.retain(|r| passes_only_vendor(r, &query));
+ // 5. Build the repository set. Configured remote repositories from
+ // `composer.json` are not yet wired up; this is a known divergence
+ // from Composer's full `CompositeRepository`.
+ let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache);
+ let repo_cache = mozart_registry::cache::Cache::repo(&cache_config);
+ let repos = RepositorySet::with_packagist(repo_cache);
- // Deduplicate to unique vendor names (Composer returns vendor-only names
- // for SEARCH_VENDOR mode).
- let mut seen = indexmap::IndexSet::new();
- let mut vendor_names: Vec<String> = Vec::new();
- for r in &results {
- let vendor = r.name.split('/').next().unwrap_or("").to_string();
- if seen.insert(vendor.clone()) {
- vendor_names.push(vendor);
- }
- }
+ // 6. Dispatch.
+ let results = repos.search(&query, mode, args.r#type.as_deref()).await?;
- match format {
- "json" => {
- let json = serde_json::to_string_pretty(&vendor_names)?;
- console_writeln!(console, &json);
- }
- _ => {
- if vendor_names.is_empty() {
- console.info(&console_format!(
- "<warning>No packages found for \"{query}\"</warning>"
- ));
- } else {
- for vendor in &vendor_names {
- console_writeln!(console, &console_format!("<info>{vendor}</info>"),);
- }
- }
- }
- }
- return Ok(());
+ // 7. Render. Empty results emit nothing in text mode (matches Composer)
+ // and `[]` in JSON mode.
+ match format {
+ "json" => render_json(&results, console)?,
+ _ => render_text(&results, console),
}
- // Output
- match format {
- "json" => {
- let output: Vec<SearchResultOutput> = results
- .iter()
- .map(|r| SearchResultOutput::from(*r))
- .collect();
- let json = serde_json::to_string_pretty(&output)?;
- console_writeln!(console, &json);
- }
- _ => {
- if results.is_empty() {
- console.info(&console_format!(
- "<warning>No packages found for \"{query}\"</warning>"
- ));
- return Ok(());
- }
+ Ok(())
+}
+
+/// Render results as JSON with 4-space indent, matching Composer's
+/// `JsonFile::encode` output (`JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES |
+/// JSON_UNESCAPED_UNICODE`). `serde_json` does not escape forward slashes
+/// or non-ASCII Unicode by default, so the encoder configuration alone
+/// covers the latter two flags.
+fn render_json(results: &[SearchResult], console: &Console) -> anyhow::Result<()> {
+ let output: Vec<SearchResultOutput> = results.iter().map(SearchResultOutput::from).collect();
+ let buf = Vec::new();
+ let formatter = serde_json::ser::PrettyFormatter::with_indent(b" ");
+ let mut ser = serde_json::Serializer::with_formatter(buf, formatter);
+ output.serialize(&mut ser)?;
+ console_writeln!(console, &String::from_utf8(ser.into_inner())?);
+ Ok(())
+}
- let width = terminal_size::terminal_size()
- .map(|(w, _)| w.0 as usize)
- .unwrap_or(80);
- let name_width = results.iter().map(|r| r.name.len()).max().unwrap_or(0) + 1;
+/// Render results in Composer's text format. For each row:
+/// - `<href=URL>name</>` (terminal hyperlink) when `url` is non-empty,
+/// else plain `name`, padded to the longest-name column.
+/// - `<warning>! Abandoned !</warning> ` prefix when abandoned.
+/// - Description, truncated with `...` to fit the terminal width.
+fn render_text(results: &[SearchResult], console: &Console) {
+ if results.is_empty() {
+ return;
+ }
- for result in &results {
- let warning = if is_abandoned(result) {
- "! Abandoned ! "
- } else {
- ""
- };
+ let width = terminal_size::terminal_size()
+ .map(|(w, _)| w.0 as usize)
+ .unwrap_or(80);
+ let name_length = results.iter().map(|r| r.name.len()).max().unwrap_or(0) + 1;
- let remaining = width.saturating_sub(name_width + warning.len());
- let description = result.description.as_str();
- let desc_display = if description.len() > remaining && remaining > 3 {
- format!("{}...", &description[..remaining.saturating_sub(3)])
- } else {
- description.to_string()
- };
+ for result in results {
+ let warning = if is_abandoned(result) {
+ console_format!("<warning>! Abandoned !</warning> ")
+ } else {
+ String::new()
+ };
- let padding = " ".repeat(name_width.saturating_sub(result.name.len()));
- console_writeln!(
- console,
- &format!("{}{}{}{}", result.name, padding, warning, desc_display),
- );
- }
- }
- }
+ // Composer uses `Console::strlen` on the warning fragment which
+ // strips formatter tags before measuring; here we count the visible
+ // chars manually since the styled string contains ANSI bytes.
+ let visible_warning_len = if warning.is_empty() { 0 } else { 14 };
+ let remaining = width.saturating_sub(name_length + visible_warning_len);
+ let description = result.description.as_str();
+ let desc_display = if description.chars().count() > remaining && remaining > 3 {
+ let cutoff: String = description.chars().take(remaining - 3).collect();
+ format!("{cutoff}...")
+ } else {
+ description.to_string()
+ };
- Ok(())
+ let padding_width = name_length.saturating_sub(result.name.len());
+ let padded_name = if !result.url.is_empty() {
+ format!(
+ "{}{}",
+ hyperlink(&result.url, &result.name, console.decorated),
+ " ".repeat(padding_width)
+ )
+ } else {
+ format!("{}{}", result.name, " ".repeat(padding_width))
+ };
+
+ console_writeln!(console, &format!("{padded_name}{warning}{desc_display}"));
+ }
}
#[cfg(test)]
@@ -223,28 +198,6 @@ mod tests {
use super::*;
#[test]
- fn test_format_count_small() {
- assert_eq!(format_count(0), "0");
- assert_eq!(format_count(42), "42");
- assert_eq!(format_count(999), "999");
- }
-
- #[test]
- fn test_format_count_thousands() {
- assert_eq!(format_count(1_000), "1K");
- assert_eq!(format_count(1_500), "1.5K");
- assert_eq!(format_count(2_500), "2.5K");
- assert_eq!(format_count(10_000), "10K");
- }
-
- #[test]
- fn test_format_count_millions() {
- assert_eq!(format_count(1_000_000), "1M");
- assert_eq!(format_count(1_500_000), "1.5M");
- assert_eq!(format_count(2_500_000), "2.5M");
- }
-
- #[test]
fn test_parse_search_response() {
use mozart_registry::packagist::SearchResponse;
@@ -352,62 +305,6 @@ mod tests {
}
#[test]
- fn test_passes_only_name_match() {
- let result = make_result("monolog/monolog");
- assert!(passes_only_name(&result, "monolog"));
- }
-
- #[test]
- fn test_passes_only_name_partial_match() {
- let result = make_result("monolog/monolog");
- assert!(passes_only_name(&result, "mono"));
- }
-
- #[test]
- fn test_passes_only_name_case_insensitive() {
- let result = make_result("Monolog/Monolog");
- assert!(passes_only_name(&result, "monolog"));
- }
-
- #[test]
- fn test_passes_only_name_no_match() {
- let result = make_result("symfony/console");
- assert!(!passes_only_name(&result, "monolog"));
- }
-
- #[test]
- fn test_passes_only_name_vendor_part_matches() {
- let result = make_result("monolog/handler");
- assert!(passes_only_name(&result, "monolog"));
- }
-
- #[test]
- fn test_passes_only_vendor_match() {
- let result = make_result("monolog/monolog");
- assert!(passes_only_vendor(&result, "monolog"));
- }
-
- #[test]
- fn test_passes_only_vendor_case_insensitive() {
- let result = make_result("Monolog/SomePackage");
- assert!(passes_only_vendor(&result, "monolog"));
- }
-
- #[test]
- fn test_passes_only_vendor_no_match() {
- // query "monolog" as vendor but package vendor is "symfony"
- let result = make_result("symfony/console");
- assert!(!passes_only_vendor(&result, "monolog"));
- }
-
- #[test]
- fn test_passes_only_vendor_partial_does_not_match() {
- // only_vendor requires exact vendor match, not substring
- let result = make_result("monolog/monolog");
- assert!(!passes_only_vendor(&result, "mono"));
- }
-
- #[test]
fn test_is_abandoned_none() {
let result = make_result("vendor/pkg");
assert!(!is_abandoned(&result));
@@ -487,27 +384,6 @@ mod tests {
assert_eq!(parsed["abandoned"], "new/pkg");
}
- #[test]
- fn test_only_vendor_deduplicates_vendor_names() {
- let results = [
- make_result("monolog/monolog"),
- make_result("monolog/handler"),
- make_result("monolog/formatter"),
- ];
- let refs: Vec<&SearchResult> = results.iter().collect();
-
- let mut seen = indexmap::IndexSet::new();
- let mut vendor_names: Vec<String> = Vec::new();
- for r in &refs {
- let vendor = r.name.split('/').next().unwrap_or("").to_string();
- if seen.insert(vendor.clone()) {
- vendor_names.push(vendor);
- }
- }
-
- assert_eq!(vendor_names, vec!["monolog"]);
- }
-
fn make_result(name: &str) -> SearchResult {
SearchResult {
name: name.to_string(),