diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-08 03:12:05 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-08 03:12:05 +0900 |
| commit | e6f35b567564665c6cb741a06e4c4afcdc5ab317 (patch) | |
| tree | a1ae0e55afab95598ff0f6bec5b6a2e9f4f86cc0 | |
| parent | b3fcdf3b5cf0d6b43109c4bcb3dfcbb6576abce5 (diff) | |
| download | php-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.rs | 47 | ||||
| -rw-r--r-- | crates/mozart-registry/src/packagist.rs | 72 | ||||
| -rw-r--r-- | crates/mozart-registry/src/repository/mod.rs | 53 | ||||
| -rw-r--r-- | crates/mozart-registry/src/repository/packagist_repo.rs | 66 | ||||
| -rw-r--r-- | crates/mozart/src/commands/search.rs | 328 |
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(), |
