Skip to main content

deps_composer/
registry.rs

1//! Packagist registry client.
2//!
3//! Provides access to the Packagist registry via:
4//! - Package metadata API (<https://repo.packagist.org/p2/{vendor}/{package}.json>) for version lookups
5//! - Search API (<https://packagist.org/search.json>) for package search
6//!
7//! The Packagist v2 API returns minified metadata where only the first version entry
8//! is complete. Subsequent entries contain only changed fields and must be expanded
9//! by inheriting from the previous complete entry.
10
11use crate::types::{ComposerPackage, ComposerVersion};
12use deps_core::{
13    Deprecation, DepsError, HttpCache, Result, is_dot_segment, lsp_helpers::warn_rejected_value,
14};
15use serde::Deserialize;
16use std::any::Any;
17use std::sync::Arc;
18
19const PACKAGIST_BASE: &str = "https://repo.packagist.org";
20const PACKAGIST_SEARCH: &str = "https://packagist.org/search.json";
21const PACKAGIST_WEB: &str = "https://packagist.org/packages";
22
23/// Returns the URL for a package's page on packagist.org.
24///
25/// Packagist names are `vendor/package`; each segment is percent-encoded
26/// individually so the `/` separator survives while any Markdown/URL-breaking
27/// characters in a segment are escaped.
28///
29/// Display link only, never fetched by this process — unlike the `p2/{vendor}/{package}`
30/// metadata-fetch URL, so it is deliberately not gated against a `.`/`..` segment (see
31/// [`deps_core::is_dot_segment`]'s doc for the fetch-sink-vs-display-link scope split, #379).
32pub fn package_url(name: &str) -> String {
33    if let Some((vendor, package)) = name.split_once('/') {
34        format!(
35            "{PACKAGIST_WEB}/{}/{}",
36            urlencoding::encode(vendor),
37            urlencoding::encode(package)
38        )
39    } else {
40        format!("{PACKAGIST_WEB}/{}", urlencoding::encode(name))
41    }
42}
43
44/// Display name for Packagist used in not-found and API-response error messages.
45pub const REGISTRY: &str = "Packagist";
46
47/// Builds the Packagist v2 API request URL for `name`'s version metadata.
48///
49/// Packagist names are `vendor/package`; each segment is percent-encoded individually.
50/// Callers must run [`reject_dot_segment`] first — a `vendor` of exactly `.`/`..` survives
51/// encoding unchanged (`.` is an RFC 3986 unreserved character) and sits between two real
52/// `/` separators here (`{base}/p2/{vendor}/{package}.json`), so it forms an exact
53/// dot-segment that a URL parser's dot-segment normalization collapses, escaping the `/p2/`
54/// prefix (#365). The unscoped `package` segment is glued directly onto `.json` with no
55/// separator and cannot form an exact dot-segment this way, but is still gated for
56/// consistency with every other ecosystem's blanket per-segment check.
57fn p2_url(base: &str, name: &str) -> String {
58    if let Some((vendor, package)) = name.split_once('/') {
59        format!(
60            "{base}/p2/{}/{}.json",
61            urlencoding::encode(vendor),
62            urlencoding::encode(package)
63        )
64    } else {
65        format!("{base}/p2/{}.json", urlencoding::encode(name))
66    }
67}
68
69/// Whether `name` (a bare package name, or `vendor/package` form) has a path segment that
70/// is exactly `.`/`..`, mirroring `deps-npm`'s identical `has_dot_segment` for the same
71/// vulnerability class.
72fn has_dot_segment(name: &str) -> bool {
73    if let Some((vendor, package)) = name.split_once('/') {
74        return is_dot_segment(vendor) || is_dot_segment(package);
75    }
76    is_dot_segment(name)
77}
78
79/// Rejects a dot-segment `name` before it would reach [`p2_url`], as
80/// `DepsError::PackageNotFound`.
81fn reject_dot_segment(name: &str) -> Result<()> {
82    if has_dot_segment(name) {
83        warn_rejected_value("is_dot_segment", "Packagist p2 metadata request URL", name);
84        return Err(DepsError::PackageNotFound {
85            package: name.to_string(),
86            registry: REGISTRY,
87        });
88    }
89    Ok(())
90}
91
92/// Composer's own wildcard-requirement existence-check ladder (#421 S2).
93///
94/// Deliberately does not reuse [`deps_core::select_latest_for_existence`]: that shared
95/// ladder's rung 1 excludes both a prerelease *and* a flagged (`is_flagged()`) version, but
96/// Composer's `abandoned` flag is package-level advisory data, not a per-version ranking
97/// signal — `select_latest_matching`'s concrete-requirement branch already documents (#347)
98/// that the newest version must resolve as latest regardless of its `abandoned` flag.
99/// Reusing the shared ladder here would silently reintroduce npm's #338 NFR-002
100/// "prefer non-deprecated" preference for Composer, which #347 deliberately opted out of.
101///
102/// So only rung 1 differs from the shared ladder (a stability-rank floor, `minimum_rank`,
103/// rather than a flagged-or-prerelease boolean); rungs 2 and 3 are identical in effect since
104/// `RemovalStatus::blocks_resolution()` is never true for Composer's `AdvisoryDeprecated`
105/// status (see `reports_yanked` below).
106///
107/// This divergence is load-bearing, not a style preference, and must not be collapsed back
108/// into a call to the shared ladder: for an abandoned package whose newest version is itself
109/// below `minimum_rank`, the shared ladder's rung 1 (flagged-or-prerelease) rejects every
110/// entry, and rung 2 (`blocks_resolution` only) then returns index 0 — the too-unstable
111/// version — since `AdvisoryDeprecated` never blocks resolution. That silently reopens #421
112/// for exactly the case this function exists to fix.
113///
114/// `minimum_rank` is the effective stability floor (see
115/// [`effective_minimum_stability_rank`]) — rung 1 keeps only versions ranking at or above it,
116/// rather than the fixed "must be fully stable" rule #421/#422 originally shipped, so a
117/// manifest's `minimum-stability` (#424) can loosen this ladder too, not just the
118/// concrete-requirement branch below.
119fn select_latest_for_existence_composer<T>(
120    versions: &[T],
121    as_version: impl Fn(&T) -> &dyn deps_core::Version,
122    minimum_rank: u8,
123) -> Option<usize> {
124    if versions.is_empty() {
125        return None;
126    }
127    Some(
128        versions
129            .iter()
130            .position(|v| {
131                crate::formatter::composer_version_stability_rank(
132                    as_version(v).version_string().as_str(),
133                ) >= minimum_rank
134            })
135            .or_else(|| {
136                versions
137                    .iter()
138                    .position(|v| !as_version(v).removal_status().blocks_resolution())
139            })
140            .unwrap_or(0),
141    )
142}
143
144/// The loosest (lowest-ranked) per-dependency `@stability` flag found anywhere in a compound
145/// `req_str` (#424 critique M1).
146///
147/// [`crate::formatter::strip_stability_flag`] applies `rfind('@')` to the *whole* string,
148/// which only works for a single unadorned constraint like `^1.0@beta`. A compound
149/// requirement splits into multiple constraint tokens — `||` (OR) and, within a
150/// space-separated range, individual tokens like `>=1.0@dev` — and each token may carry its
151/// own flag. `version_satisfies_requirement` already recurses per token to evaluate the
152/// version range correctly; this mirrors that same split (flattened, since only "is there a
153/// flag" is needed here, not per-branch matching) so `^1.0@beta || ^2.0` and
154/// `>=1.0@dev <2.0` are not silently treated as flag-less.
155///
156/// Returns the *loosest* rank among every token's flag (if more than one token carries one):
157/// this never wrongly excludes a version some branch's flag would admit — the final
158/// `version_satisfies_requirement` call still narrows down to which branch, if any, actually
159/// matches.
160fn compound_stability_flag_rank(req_str: &str) -> Option<u8> {
161    req_str
162        .split("||")
163        .flat_map(str::split_whitespace)
164        .filter_map(|token| {
165            let (_, flag) = crate::formatter::strip_stability_flag(token.trim());
166            flag.map(crate::formatter::composer_stability_rank)
167        })
168        .min()
169}
170
171/// The effective Composer stability floor for one dependency's "latest version" selection,
172/// ranked on [`crate::formatter::composer_stability_rank`]'s `dev < alpha < beta < RC <
173/// stable` scale (#424).
174///
175/// Priority, highest first:
176/// 1. An explicit per-dependency `@stability` flag anywhere in `req_str` (`^1.0@beta`, or
177///    within a compound requirement like `>=1.0@dev <2.0`, see
178///    [`compound_stability_flag_rank`]) — Composer lets a single dependency opt into a looser
179///    (or stricter) floor than the project default.
180/// 2. `req_str` itself naming an explicit prerelease version (an exact pin like
181///    `2.0.0-beta1`, or a range whose bound does, see
182///    [`crate::types::is_prerelease_marker`]) — kept from #421: an explicitly named unstable
183///    version must still resolve, so this returns the loosest rank (`0`, dev) rather than
184///    computing the pinned version's own rank, matching the pre-#424 "allow any prerelease"
185///    behavior for this case exactly.
186/// 3. `manifest_minimum` — the manifest's own `minimum-stability` field, when the caller has
187///    one (`select_latest_matching_for_manifest`/`get_latest_matching_for_manifest`).
188/// 4. [`crate::formatter::COMPOSER_STABLE_RANK`] — Composer's `minimum-stability: stable`
189///    default, unchanged from #421/#422 for every caller with no manifest context.
190pub(crate) fn effective_minimum_stability_rank(
191    req_str: &str,
192    manifest_minimum: Option<&str>,
193) -> u8 {
194    let trimmed = req_str.trim();
195    if let Some(rank) = compound_stability_flag_rank(trimmed) {
196        return rank;
197    }
198    if crate::types::is_prerelease_marker(trimmed) {
199        return 0;
200    }
201    manifest_minimum.map_or(crate::formatter::COMPOSER_STABLE_RANK, |s| {
202        crate::formatter::composer_stability_rank(s)
203    })
204}
205
206/// Client for interacting with the Packagist registry.
207///
208/// Uses the Packagist v2 API for package metadata and search.
209/// All requests are cached via the provided HttpCache.
210#[derive(Clone)]
211pub struct PackagistRegistry {
212    cache: Arc<HttpCache>,
213    base: String,
214}
215
216impl PackagistRegistry {
217    /// Creates a new Packagist registry client with the given HTTP cache.
218    pub fn new(cache: Arc<HttpCache>) -> Self {
219        Self::with_registry_base(cache, PACKAGIST_BASE.to_string())
220    }
221
222    /// Registry base URL — `PACKAGIST_BASE` in production, overridden to a mockito
223    /// server URL in tests (mirrors `deps-npm`'s `with_registry_base`).
224    fn with_registry_base(cache: Arc<HttpCache>, base: String) -> Self {
225        Self { cache, base }
226    }
227
228    /// Fetches all versions for a package from the Packagist v2 API.
229    ///
230    /// Filters out dev versions (starting with `dev-` or ending with `-dev`).
231    /// Returns versions in the order returned by the API (newest first).
232    ///
233    /// # Errors
234    ///
235    /// Returns an error if the HTTP request or JSON parsing fails.
236    pub async fn get_versions(&self, name: &str) -> Result<Vec<ComposerVersion>> {
237        reject_dot_segment(name)?;
238        let url = p2_url(&self.base, name);
239        let data = self.cache.get_cached(&url).await?;
240        parse_package_metadata(name, &data)
241    }
242
243    /// Finds the latest non-abandoned version satisfying the given requirement.
244    ///
245    /// Applies the same `minimum-stability: stable` default as
246    /// [`Registry::select_latest_matching`](deps_core::Registry::select_latest_matching)
247    /// (#421): an alpha/beta/RC release is excluded unless `req_str` itself is
248    /// prerelease-bearing. Under a wildcard/empty `req_str` (see
249    /// [`deps_core::is_existence_wildcard_str`]) this is an existence check, not an upgrade
250    /// recommendation, so it falls back to [`deps_core::select_latest_for_existence`] —
251    /// matching `deps-cargo`/`deps-pypi`/`deps-dart`/`deps-npm` — rather than returning `None`
252    /// for a package whose only releases so far are all prerelease.
253    ///
254    /// Equivalent to
255    /// [`get_latest_matching_for_manifest`](Self::get_latest_matching_for_manifest) with no
256    /// manifest `minimum-stability` (`None`) — use that method instead when the caller has a
257    /// parsed `composer.json` available (#424).
258    ///
259    /// # Errors
260    ///
261    /// Returns an error if the HTTP request fails.
262    pub async fn get_latest_matching(
263        &self,
264        name: &str,
265        req_str: &str,
266    ) -> Result<Option<ComposerVersion>> {
267        self.get_latest_matching_impl(name, req_str, None).await
268    }
269
270    /// `composer.json`-aware counterpart of
271    /// [`get_latest_matching`](Self::get_latest_matching): `minimum_stability` is the
272    /// manifest's own top-level `minimum-stability` field
273    /// ([`ComposerParseResult::minimum_stability`](crate::parser::ComposerParseResult::minimum_stability)),
274    /// used as the default stability floor whenever `req_str` carries neither an explicit
275    /// per-dependency `@stability` flag nor a directly pinned prerelease version — both of
276    /// which still take priority over the manifest default, exactly as they do for
277    /// [`get_latest_matching`](Self::get_latest_matching) (#424).
278    ///
279    /// # Errors
280    ///
281    /// Returns an error if the HTTP request fails.
282    pub async fn get_latest_matching_for_manifest(
283        &self,
284        name: &str,
285        req_str: &str,
286        minimum_stability: Option<&str>,
287    ) -> Result<Option<ComposerVersion>> {
288        self.get_latest_matching_impl(name, req_str, minimum_stability)
289            .await
290    }
291
292    async fn get_latest_matching_impl(
293        &self,
294        name: &str,
295        req_str: &str,
296        manifest_minimum: Option<&str>,
297    ) -> Result<Option<ComposerVersion>> {
298        let versions = self.get_versions(name).await?;
299
300        let minimum_rank = effective_minimum_stability_rank(req_str, manifest_minimum);
301
302        if deps_core::is_existence_wildcard_str(req_str) {
303            let idx = select_latest_for_existence_composer(
304                &versions,
305                |v| v as &dyn deps_core::Version,
306                minimum_rank,
307            );
308            return Ok(idx.and_then(|idx| versions.into_iter().nth(idx)));
309        }
310
311        let formatter = crate::formatter::ComposerFormatter;
312        use deps_core::lsp_helpers::RequirementResolution;
313
314        Ok(versions.into_iter().find(|v| {
315            crate::formatter::composer_version_stability_rank(v.version.as_str()) >= minimum_rank
316                && formatter.version_satisfies_requirement(&v.version, req_str)
317        }))
318    }
319
320    /// `composer.json`-aware counterpart of
321    /// [`Registry::select_latest_matching`](deps_core::Registry::select_latest_matching):
322    /// `minimum_stability` is the manifest's own top-level `minimum-stability` field
323    /// ([`ComposerParseResult::minimum_stability`](crate::parser::ComposerParseResult::minimum_stability)),
324    /// used as the default stability floor whenever `req` carries neither an explicit
325    /// per-dependency `@stability` flag nor a directly pinned prerelease version — both of
326    /// which still take priority over the manifest default, exactly as they do for the plain
327    /// trait method (#424).
328    #[must_use]
329    pub fn select_latest_matching_for_manifest(
330        &self,
331        versions: &[Box<dyn deps_core::Version>],
332        req: &deps_core::VersionReq,
333        minimum_stability: Option<&str>,
334    ) -> Option<usize> {
335        self.select_latest_matching_impl(versions, req, minimum_stability)
336    }
337
338    fn select_latest_matching_impl(
339        &self,
340        versions: &[Box<dyn deps_core::Version>],
341        req: &deps_core::VersionReq,
342        manifest_minimum: Option<&str>,
343    ) -> Option<usize> {
344        let minimum_rank = effective_minimum_stability_rank(req.as_str(), manifest_minimum);
345
346        if deps_core::is_existence_wildcard(req) {
347            return select_latest_for_existence_composer(versions, |v| v.as_ref(), minimum_rank);
348        }
349
350        let formatter = crate::formatter::ComposerFormatter;
351        use deps_core::lsp_helpers::RequirementResolution;
352
353        versions.iter().position(|v| {
354            // Always true for Composer (`abandoned` maps to `AdvisoryDeprecated`, which
355            // never blocks resolution) — kept to document the contract (#347).
356            !v.removal_status().blocks_resolution()
357                && crate::formatter::composer_version_stability_rank(v.version_string().as_str())
358                    >= minimum_rank
359                && formatter.version_satisfies_requirement(v.version_string(), req.as_str())
360        })
361    }
362
363    /// Searches for packages by name/keywords.
364    ///
365    /// Returns up to `limit` results sorted by relevance.
366    ///
367    /// # Errors
368    ///
369    /// Returns an error if the HTTP request or JSON parsing fails.
370    pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<ComposerPackage>> {
371        let url = format!(
372            "{}?q={}&per_page={}",
373            PACKAGIST_SEARCH,
374            urlencoding::encode(query),
375            limit
376        );
377
378        let data = self.cache.get_cached(&url).await?;
379        parse_search_response(&data)
380    }
381}
382
383/// Packagist v2 API response (outer wrapper).
384#[derive(Deserialize)]
385struct PackagistResponse {
386    packages: std::collections::HashMap<String, Vec<MinifiedVersion>>,
387}
388
389/// Minified version entry from Packagist v2 API.
390///
391/// The v2 API returns only the first version as complete. Subsequent entries
392/// contain only fields that changed from the previous entry.
393///
394/// `time` is deliberately excluded from this inheritance scheme (see
395/// [`expand_minified_versions`]): every entry carries its own `time` (87/87
396/// live-verified on `monolog/monolog`), and inheriting it across entries
397/// would attribute one release's publish date to another.
398#[derive(Deserialize, Clone, Default)]
399struct MinifiedVersion {
400    version: Option<String>,
401    version_normalized: Option<String>,
402    abandoned: Option<serde_json::Value>,
403    /// Publish timestamp (RFC 3339, e.g. `"2026-01-02T08:56:05+00:00"`).
404    #[serde(default)]
405    time: Option<String>,
406}
407
408/// Expands minified Packagist v2 versions using field inheritance.
409///
410/// The v2 API compresses responses: only the first entry is complete.
411/// Each subsequent entry inherits fields from the previous one and overrides
412/// only the fields that changed. `time` is the one exception: it is read
413/// only from the entry itself, never inherited, since a missing `time` means
414/// the release genuinely has no known publish date, not that it shares the
415/// previous release's date.
416///
417/// Dev versions (`dev-*` or `*-dev`) are filtered out.
418fn expand_minified_versions(entries: Vec<MinifiedVersion>) -> Vec<ComposerVersion> {
419    let mut result = Vec::new();
420    let mut current = MinifiedVersion::default();
421
422    for entry in entries {
423        // `time` is not part of the inherited state; read it before `entry`
424        // is partially consumed below.
425        let published_at = entry
426            .time
427            .as_deref()
428            .and_then(deps_core::PublishTime::parse_rfc3339);
429
430        // Inherit previous state, then apply overrides
431        if entry.version.is_some() {
432            current.version = entry.version;
433        }
434        if entry.version_normalized.is_some() {
435            current.version_normalized = entry.version_normalized;
436        }
437        if entry.abandoned.is_some() {
438            current.abandoned = entry.abandoned;
439        }
440
441        let Some(ref version) = current.version else {
442            continue;
443        };
444
445        // Filter dev versions
446        if version.starts_with("dev-") || version.ends_with("-dev") {
447            continue;
448        }
449
450        let abandoned = current
451            .abandoned
452            .as_ref()
453            .is_some_and(|v| v.as_bool() == Some(true) || v.is_string());
454        let deprecation = deprecation_from_abandoned(current.abandoned.as_ref());
455
456        result.push(ComposerVersion {
457            version: version.clone().into(),
458            version_normalized: current
459                .version_normalized
460                .clone()
461                .unwrap_or_else(|| version.clone()),
462            abandoned,
463            deprecation,
464            published_at,
465        });
466    }
467
468    result
469}
470
471/// Derives a #205 [`Deprecation`](deps_core::Deprecation) payload from Packagist's
472/// `abandoned` field.
473///
474/// Packagist's `abandoned` is either absent/`false`/`null` (not abandoned), bare `true`
475/// (abandoned, no known successor), or a string naming a replacement package — a
476/// structured, registry-validated field, unlike npm's free-text `deprecated` message,
477/// which is what makes Composer (and only Composer) safe to offer a rename quickfix for
478/// (see `ComposerFormatter::supports_package_rename`).
479///
480/// M2: an all-whitespace replacement string produces a bare `Deprecation` (both fields
481/// `None`) rather than one with an empty `replacement`, mirroring
482/// `deps_npm::deprecation_from_message`'s empty-payload guard — though for Composer this
483/// is defense-in-depth rather than an observed real-world case, since a real
484/// `abandoned: true` already takes the same path.
485fn deprecation_from_abandoned(abandoned: Option<&serde_json::Value>) -> Option<Deprecation> {
486    let value = abandoned?;
487    if value.as_bool() == Some(true) {
488        return Some(Deprecation {
489            reason: None,
490            replacement: None,
491        });
492    }
493    let replacement = value.as_str()?;
494    Some(Deprecation {
495        reason: None,
496        replacement: (!replacement.trim().is_empty()).then(|| replacement.trim().to_string()),
497    })
498}
499
500/// Parses Packagist v2 API response JSON.
501fn parse_package_metadata(name: &str, data: &[u8]) -> Result<Vec<ComposerVersion>> {
502    let response: PackagistResponse =
503        deps_core::parse_json_checked(data).map_err(DepsError::Json)?;
504
505    // Packagist uses lowercase package names as keys
506    let key = name.to_lowercase();
507    let entries = response.packages.get(&key).cloned().unwrap_or_default();
508
509    Ok(expand_minified_versions(entries))
510}
511
512/// Packagist search API response.
513#[derive(Deserialize)]
514struct SearchResponse {
515    results: Vec<SearchResult>,
516}
517
518/// Individual search result.
519#[derive(Deserialize)]
520struct SearchResult {
521    name: String,
522    #[serde(default)]
523    description: Option<String>,
524    #[serde(default)]
525    repository: Option<String>,
526    #[serde(default)]
527    url: Option<String>,
528    #[serde(default)]
529    version: Option<String>,
530}
531
532/// Parses Packagist search API response.
533fn parse_search_response(data: &[u8]) -> Result<Vec<ComposerPackage>> {
534    let response: SearchResponse = deps_core::parse_json_checked(data).map_err(DepsError::Json)?;
535
536    Ok(response
537        .results
538        .into_iter()
539        .map(|r| ComposerPackage {
540            name: r.name.into(),
541            description: r.description,
542            repository: r.repository,
543            homepage: r.url,
544            latest_version: r.version.unwrap_or_default().into(),
545        })
546        .collect())
547}
548
549impl deps_core::Registry for PackagistRegistry {
550    fn get_versions<'a>(
551        &'a self,
552        name: &'a deps_core::PackageName,
553    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Version>>>> {
554        Box::pin(async move {
555            let versions = self.get_versions(name.as_str()).await?;
556            Ok(versions
557                .into_iter()
558                .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
559                .collect())
560        })
561    }
562
563    fn get_latest_matching<'a>(
564        &'a self,
565        name: &'a deps_core::PackageName,
566        req: &'a deps_core::VersionReq,
567    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Option<Box<dyn deps_core::Version>>>> {
568        Box::pin(async move {
569            let version = self
570                .get_latest_matching(name.as_str(), req.as_str())
571                .await?;
572            Ok(version.map(|v| Box::new(v) as Box<dyn deps_core::Version>))
573        })
574    }
575
576    /// Routes to [`PackagistRegistry::get_latest_matching_for_manifest`] — see that method
577    /// for the full priority order. This is the trait-level hook a generic LSP fetch loop
578    /// downcasting `Arc<dyn Registry>` cannot bypass by calling
579    /// [`get_latest_matching`](Self::get_latest_matching) instead (#424 S1).
580    fn get_latest_matching_with_context<'a>(
581        &'a self,
582        name: &'a deps_core::PackageName,
583        req: &'a deps_core::VersionReq,
584        minimum_stability: Option<&'a str>,
585    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Option<Box<dyn deps_core::Version>>>> {
586        Box::pin(async move {
587            let version = self
588                .get_latest_matching_for_manifest(name.as_str(), req.as_str(), minimum_stability)
589                .await?;
590            Ok(version.map(|v| Box::new(v) as Box<dyn deps_core::Version>))
591        })
592    }
593
594    fn search<'a>(
595        &'a self,
596        query: &'a str,
597        limit: usize,
598    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Metadata>>>> {
599        Box::pin(async move {
600            let packages = self.search(query, limit).await?;
601            Ok(packages
602                .into_iter()
603                .map(|p| Box::new(p) as Box<dyn deps_core::Metadata>)
604                .collect())
605        })
606    }
607
608    /// Picks the latest version satisfying `req`, applying Composer's default
609    /// `minimum-stability: stable` semantics (#421): an alpha/beta/RC release is
610    /// excluded from "latest" unless `req` itself names an unstable version (e.g. an
611    /// exact `2.0.0-beta1` pin or a lower bound like `>=2.0.0-beta1`) — mirroring
612    /// `deps-nuget`'s prerelease-bearing-requirement exception (`registry.rs`'s
613    /// `pick_latest_matching`). `dev-*`/`*-dev` branches never reach here at all:
614    /// `expand_minified_versions` already filters them out of every `Registry::get_versions`
615    /// result.
616    ///
617    /// Under a wildcard/empty `req` (see [`deps_core::is_existence_wildcard`]) this is an
618    /// existence check, not an upgrade recommendation, so it defers to
619    /// `select_latest_for_existence_composer` instead — matching the *shape* of
620    /// `deps-cargo`/`deps-pypi`/`deps-dart`/`deps-npm` (a package whose only releases so far
621    /// are all prerelease still resolves to its newest one rather than `None`), while keeping
622    /// Composer's own #347 ranking rule that `abandoned` never demotes a version.
623    ///
624    /// Does not read `composer.json`'s own `minimum-stability` field — this trait method has
625    /// no manifest context to read it from. A caller with a parsed manifest available should
626    /// use [`PackagistRegistry::select_latest_matching_for_manifest`] instead, which this
627    /// method is equivalent to with no manifest `minimum-stability` (`None`) (#424).
628    fn select_latest_matching(
629        &self,
630        versions: &[Box<dyn deps_core::Version>],
631        req: &deps_core::VersionReq,
632    ) -> Option<usize> {
633        self.select_latest_matching_impl(versions, req, None)
634    }
635
636    /// Routes to [`PackagistRegistry::select_latest_matching_for_manifest`] — the trait-level
637    /// hook a generic LSP fetch loop downcasting `Arc<dyn Registry>` cannot bypass by calling
638    /// the plain `select_latest_matching` instead (#424 S1).
639    fn select_latest_matching_with_context(
640        &self,
641        versions: &[Box<dyn deps_core::Version>],
642        req: &deps_core::VersionReq,
643        minimum_stability: Option<&str>,
644    ) -> Option<usize> {
645        self.select_latest_matching_for_manifest(versions, req, minimum_stability)
646    }
647
648    // Packagist's `abandoned` is package-level, not per-version: `removal_status`
649    // reports `AdvisoryDeprecated` for "this package is abandoned", inherited by
650    // every version via the p2 minified-inheritance loop. Enabling the yanked
651    // diagnostic here would fire on nearly every version of an abandoned package
652    // (#233 R2, #205).
653    fn reports_yanked(&self) -> bool {
654        false
655    }
656
657    fn as_any(&self) -> &dyn Any {
658        self
659    }
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    use std::assert_matches;
667
668    #[test]
669    fn test_package_url_preserves_vendor_package() {
670        assert_eq!(
671            package_url("symfony/console"),
672            "https://packagist.org/packages/symfony/console"
673        );
674    }
675
676    #[test]
677    fn test_package_url_encodes_malicious_segments() {
678        let url = package_url("evil)[/pkg](x");
679        assert!(!url.contains('('));
680        assert!(!url.contains(')'));
681        assert!(!url.contains('['));
682        assert!(!url.contains(']'));
683    }
684
685    #[test]
686    fn test_package_url_encodes_newline_autolink_and_percent() {
687        let url = package_url("evil\n<%>/pkg");
688        assert!(!url.contains('\n'));
689        assert!(!url.contains('<'));
690        assert!(!url.contains('>'));
691        assert!(url.contains("%25"));
692    }
693
694    #[test]
695    fn test_package_url_empty_name() {
696        assert_eq!(package_url(""), "https://packagist.org/packages/");
697    }
698
699    #[test]
700    fn test_reject_dot_segment_rejects_bare_dot_dot() {
701        assert!(reject_dot_segment("..").is_err());
702    }
703
704    #[test]
705    fn test_reject_dot_segment_rejects_vendor_dot_dot() {
706        assert!(reject_dot_segment("../evil").is_err());
707    }
708
709    #[test]
710    fn test_reject_dot_segment_rejects_package_dot_dot() {
711        assert!(reject_dot_segment("vendor/..").is_err());
712    }
713
714    #[test]
715    fn test_reject_dot_segment_accepts_normal_names() {
716        assert!(reject_dot_segment("monolog/monolog").is_ok());
717        assert!(reject_dot_segment("symfony").is_ok());
718    }
719
720    /// Demonstrates the vulnerability `reject_dot_segment` exists to prevent: `p2_url`
721    /// alone (with no caller-side guard) builds a URL that, once parsed, has already lost
722    /// the `p2` path component for a `vendor` of exactly `..`.
723    #[test]
724    fn test_p2_url_bare_dot_dot_vendor_normalizes_above_p2_prefix() {
725        let url = p2_url("https://repo.packagist.org", "../evil");
726        let parsed = url::Url::parse(&url).unwrap();
727        assert_eq!(
728            parsed.path(),
729            "/evil.json",
730            "parsed path: {}",
731            parsed.path()
732        );
733    }
734
735    /// #365 regression sweep: exercises the real production `reject_dot_segment` gate and
736    /// `p2_url` sink together against the shared adversarial input set (varying vendor,
737    /// then package), guarding against a 6th recurrence of the dot-segment defect class in
738    /// this crate.
739    #[test]
740    fn test_p2_url_dot_segment_sweep() {
741        deps_core::test_util::assert_dot_segment_gated_or_contained(
742            |seg| {
743                let name = format!("{seg}/package");
744                reject_dot_segment(&name)
745                    .ok()
746                    .map(|()| p2_url("https://repo.packagist.org", &name))
747            },
748            "repo.packagist.org",
749            "/p2/",
750        );
751        deps_core::test_util::assert_dot_segment_gated_or_contained(
752            |seg| {
753                let name = format!("vendor/{seg}");
754                reject_dot_segment(&name)
755                    .ok()
756                    .map(|()| p2_url("https://repo.packagist.org", &name))
757            },
758            "repo.packagist.org",
759            "/p2/",
760        );
761    }
762
763    /// #365 end-to-end coverage (critic S2): exercises the real production
764    /// `get_versions` — not a reimplemented gate+sink pair — proving the gate is actually
765    /// wired into the call path a real completion/hover/diagnostic request would take. No
766    /// mock is needed: the gate must reject before any network request is issued.
767    ///
768    /// Asserts the exact `PackageNotFound` variant (gate rejected before any request), not
769    /// the broader `is_not_found()` (also true for a live 404 `HttpStatus`) — critic R1:
770    /// `repo.packagist.org` 404ing for this path today would make a deleted gate go
771    /// undetected by this test.
772    #[tokio::test]
773    async fn test_get_versions_rejects_vendor_dot_dot_as_not_found() {
774        let registry = PackagistRegistry::new(Arc::new(HttpCache::new()));
775        let err = registry.get_versions("../evil").await.unwrap_err();
776        assert_matches!(err, DepsError::PackageNotFound { .. });
777    }
778
779    #[test]
780    fn test_expand_minified_versions_basic() {
781        let entries = vec![
782            MinifiedVersion {
783                version: Some("3.0.0".into()),
784                version_normalized: Some("3.0.0.0".into()),
785                abandoned: None,
786                time: None,
787            },
788            MinifiedVersion {
789                version: Some("2.0.0".into()),
790                version_normalized: Some("2.0.0.0".into()),
791                abandoned: None,
792                time: None,
793            },
794        ];
795
796        let versions = expand_minified_versions(entries);
797        assert_eq!(versions.len(), 2);
798        assert_eq!(versions[0].version, "3.0.0");
799        assert_eq!(versions[1].version, "2.0.0");
800        assert!(!versions[0].abandoned);
801    }
802
803    #[test]
804    fn test_expand_minified_versions_field_inheritance() {
805        // Second entry inherits version_normalized from first, only version changes
806        let entries = vec![
807            MinifiedVersion {
808                version: Some("3.0.0".into()),
809                version_normalized: Some("3.0.0.0".into()),
810                abandoned: None,
811                time: None,
812            },
813            MinifiedVersion {
814                version: Some("2.9.0".into()),
815                version_normalized: None, // inherited
816                abandoned: None,
817                time: None,
818            },
819        ];
820
821        let versions = expand_minified_versions(entries);
822        assert_eq!(versions.len(), 2);
823        assert_eq!(versions[1].version, "2.9.0");
824        assert_eq!(versions[1].version_normalized, "3.0.0.0"); // inherited
825    }
826
827    #[test]
828    fn test_expand_minified_versions_filters_dev() {
829        let entries = vec![
830            MinifiedVersion {
831                version: Some("3.0.0".into()),
832                version_normalized: Some("3.0.0.0".into()),
833                abandoned: None,
834                time: None,
835            },
836            MinifiedVersion {
837                version: Some("dev-main".into()),
838                version_normalized: None,
839                abandoned: None,
840                time: None,
841            },
842            MinifiedVersion {
843                version: Some("2.0.0-dev".into()),
844                version_normalized: None,
845                abandoned: None,
846                time: None,
847            },
848        ];
849
850        let versions = expand_minified_versions(entries);
851        assert_eq!(versions.len(), 1);
852        assert_eq!(versions[0].version, "3.0.0");
853    }
854
855    #[test]
856    fn test_expand_minified_versions_abandoned() {
857        let entries = vec![MinifiedVersion {
858            version: Some("3.0.0".into()),
859            version_normalized: Some("3.0.0.0".into()),
860            abandoned: Some(serde_json::Value::String("Use other/package".into())),
861            time: None,
862        }];
863
864        let versions = expand_minified_versions(entries);
865        assert_eq!(versions.len(), 1);
866        assert!(versions[0].abandoned);
867        assert_eq!(
868            versions[0].deprecation,
869            Some(Deprecation {
870                reason: None,
871                replacement: Some("Use other/package".to_string()),
872            })
873        );
874    }
875
876    /// #205: a bare `"abandoned": true` still fires — both `Deprecation` fields `None`
877    /// (no known successor) — distinct from `Some` with an empty payload.
878    #[test]
879    fn test_expand_minified_versions_abandoned_true_has_no_replacement() {
880        let entries = vec![MinifiedVersion {
881            version: Some("3.0.0".into()),
882            version_normalized: Some("3.0.0.0".into()),
883            abandoned: Some(serde_json::Value::Bool(true)),
884            time: None,
885        }];
886
887        let versions = expand_minified_versions(entries);
888        assert_eq!(
889            versions[0].deprecation,
890            Some(Deprecation {
891                reason: None,
892                replacement: None,
893            })
894        );
895    }
896
897    /// #205 M2: an all-whitespace replacement string must not leak through as an empty,
898    /// dangling `replacement` — the package is still abandoned (mirrors bare `true`).
899    #[test]
900    fn test_expand_minified_versions_abandoned_whitespace_replacement_is_none() {
901        let entries = vec![MinifiedVersion {
902            version: Some("3.0.0".into()),
903            version_normalized: Some("3.0.0.0".into()),
904            abandoned: Some(serde_json::Value::String("   ".into())),
905            time: None,
906        }];
907
908        let versions = expand_minified_versions(entries);
909        assert_eq!(
910            versions[0].deprecation,
911            Some(Deprecation {
912                reason: None,
913                replacement: None,
914            })
915        );
916    }
917
918    /// Not abandoned at all: no `Deprecation` payload.
919    #[test]
920    fn test_expand_minified_versions_not_abandoned_has_no_deprecation() {
921        let entries = vec![MinifiedVersion {
922            version: Some("3.0.0".into()),
923            version_normalized: Some("3.0.0.0".into()),
924            abandoned: None,
925            time: None,
926        }];
927
928        let versions = expand_minified_versions(entries);
929        assert_eq!(versions[0].deprecation, None);
930    }
931
932    #[test]
933    fn test_expand_minified_versions_with_time() {
934        let entries = vec![MinifiedVersion {
935            version: Some("3.0.0".into()),
936            version_normalized: Some("3.0.0.0".into()),
937            abandoned: None,
938            time: Some("2026-01-02T08:56:05+00:00".into()),
939        }];
940
941        let versions = expand_minified_versions(entries);
942        assert_eq!(versions.len(), 1);
943        assert_eq!(
944            versions[0].published_at,
945            deps_core::PublishTime::parse_rfc3339("2026-01-02T08:56:05+00:00")
946        );
947    }
948
949    #[test]
950    fn test_expand_minified_versions_without_time() {
951        let entries = vec![MinifiedVersion {
952            version: Some("3.0.0".into()),
953            version_normalized: Some("3.0.0.0".into()),
954            abandoned: None,
955            time: None,
956        }];
957
958        let versions = expand_minified_versions(entries);
959        assert_eq!(versions.len(), 1);
960        assert!(versions[0].published_at.is_none());
961    }
962
963    #[test]
964    fn test_expand_minified_versions_with_malformed_time() {
965        let entries = vec![MinifiedVersion {
966            version: Some("3.0.0".into()),
967            version_normalized: Some("3.0.0.0".into()),
968            abandoned: None,
969            time: Some("not-a-timestamp".into()),
970        }];
971
972        let versions = expand_minified_versions(entries);
973        assert_eq!(versions.len(), 1);
974        assert!(
975            versions[0].published_at.is_none(),
976            "malformed time degrades to None, not an error"
977        );
978    }
979
980    #[test]
981    fn test_expand_minified_versions_time_is_not_inherited() {
982        // Correctness requirement: an entry with no `time` must yield `None`
983        // for that entry, never the previous entry's `time` — unlike
984        // `version_normalized`/`abandoned`, which do inherit.
985        let entries = vec![
986            MinifiedVersion {
987                version: Some("3.0.0".into()),
988                version_normalized: Some("3.0.0.0".into()),
989                abandoned: None,
990                time: Some("2026-01-02T08:56:05+00:00".into()),
991            },
992            MinifiedVersion {
993                version: Some("2.9.0".into()),
994                version_normalized: None, // inherited
995                abandoned: None,
996                time: None, // must NOT inherit the previous entry's time
997            },
998        ];
999
1000        let versions = expand_minified_versions(entries);
1001        assert_eq!(versions.len(), 2);
1002        assert!(versions[0].published_at.is_some());
1003        assert!(
1004            versions[1].published_at.is_none(),
1005            "time must not be inherited from the previous entry"
1006        );
1007    }
1008
1009    #[test]
1010    fn test_parse_search_response() {
1011        let json = r#"{
1012  "results": [
1013    {
1014      "name": "symfony/console",
1015      "description": "Symfony Console Component",
1016      "version": "6.0.0",
1017      "url": "https://packagist.org/packages/symfony/console",
1018      "repository": "https://github.com/symfony/console"
1019    }
1020  ],
1021  "total": 1
1022}"#;
1023
1024        let packages = parse_search_response(json.as_bytes()).unwrap();
1025        assert_eq!(packages.len(), 1);
1026
1027        let pkg = &packages[0];
1028        assert_eq!(pkg.name, "symfony/console");
1029        assert_eq!(pkg.description, Some("Symfony Console Component".into()));
1030        assert_eq!(pkg.latest_version, "6.0.0");
1031    }
1032
1033    #[test]
1034    fn test_parse_search_response_nesting_at_max_depth_accepted() {
1035        let depth = deps_core::MAX_JSON_NESTING_DEPTH;
1036        let json = format!(
1037            r#"{{"results": [], "extra": {}1{}}}"#,
1038            "[".repeat(depth - 1),
1039            "]".repeat(depth - 1)
1040        );
1041        assert!(parse_search_response(json.as_bytes()).is_ok());
1042    }
1043
1044    #[test]
1045    fn test_parse_search_response_nesting_over_max_depth_rejected() {
1046        let depth = deps_core::MAX_JSON_NESTING_DEPTH + 1;
1047        let json = format!(
1048            r#"{{"results": [], "extra": {}1{}}}"#,
1049            "[".repeat(depth),
1050            "]".repeat(depth)
1051        );
1052        assert!(parse_search_response(json.as_bytes()).is_err());
1053    }
1054
1055    #[test]
1056    fn test_parse_package_metadata() {
1057        let json = r#"{
1058  "packages": {
1059    "monolog/monolog": [
1060      {
1061        "version": "3.0.0",
1062        "version_normalized": "3.0.0.0",
1063        "abandoned": null
1064      },
1065      {
1066        "version": "2.0.0",
1067        "version_normalized": "2.0.0.0"
1068      }
1069    ]
1070  }
1071}"#;
1072
1073        let versions = parse_package_metadata("monolog/monolog", json.as_bytes()).unwrap();
1074        assert_eq!(versions.len(), 2);
1075        assert_eq!(versions[0].version, "3.0.0");
1076    }
1077
1078    #[test]
1079    fn test_parse_package_metadata_deeply_nested_json_rejected_before_parse() {
1080        // #430: a deeply nested `abandoned` value must be rejected by the
1081        // depth guard rather than handed to `serde_json::from_slice`.
1082        let depth = deps_core::MAX_JSON_NESTING_DEPTH + 1;
1083        let deeply_nested = format!(
1084            r#"{{"packages":{{"monolog/monolog":[{{"version":"3.0.0","abandoned":{}1{}}}]}}}}"#,
1085            "[".repeat(depth),
1086            "]".repeat(depth)
1087        );
1088        assert!(parse_package_metadata("monolog/monolog", deeply_nested.as_bytes()).is_err());
1089    }
1090
1091    #[test]
1092    fn test_select_latest_matching_not_default_none() {
1093        // Regression for #347's mixed case: the newest version is abandoned, an older
1094        // version is clean. Composer has no npm-style ranking preference for a
1095        // non-abandoned version over a newer abandoned one (unlike deps-npm's #338
1096        // NFR-002) — `abandoned` is advisory, not a hard removal from resolution, so
1097        // the newest version resolves as latest regardless of its abandoned flag.
1098        use deps_core::{Registry, VersionReq};
1099
1100        let cache = Arc::new(HttpCache::new());
1101        let registry = PackagistRegistry::new(cache);
1102        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1103            Box::new(ComposerVersion {
1104                version: "2.0.0".into(),
1105                version_normalized: "2.0.0.0".into(),
1106                abandoned: true,
1107                deprecation: None,
1108                published_at: None,
1109            }),
1110            Box::new(ComposerVersion {
1111                version: "1.0.0".into(),
1112                version_normalized: "1.0.0.0".into(),
1113                abandoned: false,
1114                deprecation: None,
1115                published_at: None,
1116            }),
1117        ];
1118        let req = VersionReq::new("*");
1119        assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
1120    }
1121
1122    #[test]
1123    fn test_select_latest_matching_all_abandoned_still_resolves() {
1124        // Regression test for #347: an abandoned package's versions must
1125        // still resolve under a wildcard requirement — `abandoned` is
1126        // advisory, not a hard removal from resolution.
1127        use deps_core::{Registry, VersionReq};
1128
1129        let cache = Arc::new(HttpCache::new());
1130        let registry = PackagistRegistry::new(cache);
1131        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1132            Box::new(ComposerVersion {
1133                version: "2.0.0".into(),
1134                version_normalized: "2.0.0.0".into(),
1135                abandoned: true,
1136                deprecation: None,
1137                published_at: None,
1138            }),
1139            Box::new(ComposerVersion {
1140                version: "1.0.0".into(),
1141                version_normalized: "1.0.0.0".into(),
1142                abandoned: true,
1143                deprecation: None,
1144                published_at: None,
1145            }),
1146        ];
1147        let req = VersionReq::new("*");
1148        assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
1149    }
1150
1151    /// Regression for #347's other half (S2): the inherent `get_latest_matching` —
1152    /// the fetch loop's fallback when the pure list-based `select_latest_matching`
1153    /// pick finds nothing, and the path `diagnostics.rs`'s live-lookup exercises
1154    /// directly — must also resolve an all-abandoned package instead of treating it
1155    /// as non-existent. Mirrors `deps-npm`'s
1156    /// `test_get_latest_matching_wildcard_all_deprecated_returns_newest` shape.
1157    #[tokio::test]
1158    async fn test_get_latest_matching_wildcard_all_abandoned_still_resolves() {
1159        let mut server = mockito::Server::new_async().await;
1160        let base = server.url();
1161        let registry = PackagistRegistry::with_registry_base(Arc::new(HttpCache::new()), base);
1162
1163        server
1164            .mock("GET", "/p2/vendor/abandoned-pkg.json")
1165            .with_status(200)
1166            .with_body(
1167                r#"{"packages": {"vendor/abandoned-pkg": [
1168                    {"version": "2.0.0", "version_normalized": "2.0.0.0", "abandoned": true},
1169                    {"version": "1.0.0", "version_normalized": "1.0.0.0", "abandoned": true}
1170                ]}}"#,
1171            )
1172            .create_async()
1173            .await;
1174
1175        let latest = registry
1176            .get_latest_matching("vendor/abandoned-pkg", "*")
1177            .await
1178            .unwrap();
1179
1180        let version = latest.expect("an all-abandoned package still exists and resolves");
1181        assert_eq!(version.version, "2.0.0");
1182    }
1183
1184    /// Regression for #421: `select_latest_matching` must not surface a real
1185    /// alpha/beta/RC release as "latest" for a loose requirement — Composer's default
1186    /// `minimum-stability: stable` excludes it even though it satisfies `>=1.0`.
1187    #[test]
1188    fn test_select_latest_matching_excludes_prerelease_for_loose_requirement() {
1189        use deps_core::{Registry, VersionReq};
1190
1191        let cache = Arc::new(HttpCache::new());
1192        let registry = PackagistRegistry::new(cache);
1193        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1194            Box::new(ComposerVersion {
1195                version: "2.0.0-beta1".into(),
1196                version_normalized: "2.0.0.0-beta1".into(),
1197                abandoned: false,
1198                deprecation: None,
1199                published_at: None,
1200            }),
1201            Box::new(ComposerVersion {
1202                version: "1.5.0".into(),
1203                version_normalized: "1.5.0.0".into(),
1204                abandoned: false,
1205                deprecation: None,
1206                published_at: None,
1207            }),
1208        ];
1209        let req = VersionReq::new(">=1.0");
1210        assert_eq!(registry.select_latest_matching(&versions, &req), Some(1));
1211    }
1212
1213    /// Regression for #421: an explicit prerelease-bearing requirement (e.g. an exact
1214    /// `2.0.0-beta1` pin) must still resolve to that prerelease — the default stability
1215    /// filter only applies when the requirement itself does not name an unstable version.
1216    #[test]
1217    fn test_select_latest_matching_allows_prerelease_when_requirement_names_it() {
1218        use deps_core::{Registry, VersionReq};
1219
1220        let cache = Arc::new(HttpCache::new());
1221        let registry = PackagistRegistry::new(cache);
1222        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1223            Box::new(ComposerVersion {
1224                version: "2.0.0-beta1".into(),
1225                version_normalized: "2.0.0.0-beta1".into(),
1226                abandoned: false,
1227                deprecation: None,
1228                published_at: None,
1229            }),
1230            Box::new(ComposerVersion {
1231                version: "1.5.0".into(),
1232                version_normalized: "1.5.0.0".into(),
1233                abandoned: false,
1234                deprecation: None,
1235                published_at: None,
1236            }),
1237        ];
1238        let req = VersionReq::new("2.0.0-beta1");
1239        assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
1240    }
1241
1242    /// Regression for #421 (S2): the inherent `get_latest_matching` fetch-loop fallback
1243    /// must apply the same default stability filter as `select_latest_matching`.
1244    #[tokio::test]
1245    async fn test_get_latest_matching_wildcard_excludes_prerelease() {
1246        let mut server = mockito::Server::new_async().await;
1247        let base = server.url();
1248        let registry = PackagistRegistry::with_registry_base(Arc::new(HttpCache::new()), base);
1249
1250        server
1251            .mock("GET", "/p2/vendor/pkg.json")
1252            .with_status(200)
1253            .with_body(
1254                r#"{"packages": {"vendor/pkg": [
1255                    {"version": "2.0.0-beta1", "version_normalized": "2.0.0.0-beta1"},
1256                    {"version": "1.5.0", "version_normalized": "1.5.0.0"}
1257                ]}}"#,
1258            )
1259            .create_async()
1260            .await;
1261
1262        let latest = registry
1263            .get_latest_matching("vendor/pkg", "*")
1264            .await
1265            .unwrap();
1266
1267        let version = latest.expect("a package with a stable release still resolves");
1268        assert_eq!(version.version, "1.5.0");
1269    }
1270
1271    /// Regression for #421 S1: the "is this requirement prerelease-bearing" check must use
1272    /// the same predicate as `Version::is_prerelease()`, including Composer's short `-a`/`-b`
1273    /// stability alias — not just `deps-core`'s default `-alpha`/`-beta`/`-rc` substrings.
1274    /// Before the fix, an exact `2.0.0-a1` pin was not recognized as prerelease-bearing even
1275    /// though the version it names (`2.0.0-a1`) is itself classified as a prerelease, making
1276    /// it impossible to ever satisfy.
1277    #[test]
1278    fn test_select_latest_matching_allows_short_alias_prerelease_when_requirement_names_it() {
1279        use deps_core::{Registry, VersionReq};
1280
1281        let cache = Arc::new(HttpCache::new());
1282        let registry = PackagistRegistry::new(cache);
1283        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1284            Box::new(ComposerVersion {
1285                version: "2.0.0-a1".into(),
1286                version_normalized: "2.0.0.0-alpha1".into(),
1287                abandoned: false,
1288                deprecation: None,
1289                published_at: None,
1290            }),
1291            Box::new(ComposerVersion {
1292                version: "1.5.0".into(),
1293                version_normalized: "1.5.0.0".into(),
1294                abandoned: false,
1295                deprecation: None,
1296                published_at: None,
1297            }),
1298        ];
1299        let req = VersionReq::new("2.0.0-a1");
1300        assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
1301    }
1302
1303    /// Regression for #421 S1's exact measured case: a caret requirement whose lower bound
1304    /// is a short-alias prerelease (`^1.0.0-a1`) must also be recognized as
1305    /// prerelease-bearing, not just an exact pin.
1306    #[test]
1307    fn test_select_latest_matching_allows_short_alias_prerelease_with_caret_requirement() {
1308        use deps_core::{Registry, VersionReq};
1309
1310        let cache = Arc::new(HttpCache::new());
1311        let registry = PackagistRegistry::new(cache);
1312        let versions: Vec<Box<dyn deps_core::Version>> = vec![Box::new(ComposerVersion {
1313            version: "1.0.0-a1".into(),
1314            version_normalized: "1.0.0.0-alpha1".into(),
1315            abandoned: false,
1316            deprecation: None,
1317            published_at: None,
1318        })];
1319        let req = VersionReq::new("^1.0.0-a1");
1320        assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
1321    }
1322
1323    /// Regression for #421 (M2): the async `get_latest_matching` fetch-loop fallback must
1324    /// also honor an explicit prerelease-bearing requirement, mirroring
1325    /// `test_select_latest_matching_allows_prerelease_when_requirement_names_it`.
1326    #[tokio::test]
1327    async fn test_get_latest_matching_allows_prerelease_when_requirement_names_it() {
1328        let mut server = mockito::Server::new_async().await;
1329        let base = server.url();
1330        let registry = PackagistRegistry::with_registry_base(Arc::new(HttpCache::new()), base);
1331
1332        server
1333            .mock("GET", "/p2/vendor/pkg.json")
1334            .with_status(200)
1335            .with_body(
1336                r#"{"packages": {"vendor/pkg": [
1337                    {"version": "2.0.0-beta1", "version_normalized": "2.0.0.0-beta1"},
1338                    {"version": "1.5.0", "version_normalized": "1.5.0.0"}
1339                ]}}"#,
1340            )
1341            .create_async()
1342            .await;
1343
1344        let latest = registry
1345            .get_latest_matching("vendor/pkg", "2.0.0-beta1")
1346            .await
1347            .unwrap();
1348
1349        let version = latest.expect("an explicit prerelease pin resolves to that prerelease");
1350        assert_eq!(version.version, "2.0.0-beta1");
1351    }
1352
1353    /// Regression for #421 (S2): a package whose only releases so far are all prerelease
1354    /// must still resolve under a wildcard requirement — matching
1355    /// `deps-cargo`/`deps-pypi`/`deps-dart`/`deps-npm`'s existence-check behavior — instead
1356    /// of `select_latest_matching` returning `None` and the package appearing unresolvable.
1357    #[test]
1358    fn test_select_latest_matching_wildcard_prerelease_only_still_resolves() {
1359        use deps_core::{Registry, VersionReq};
1360
1361        let cache = Arc::new(HttpCache::new());
1362        let registry = PackagistRegistry::new(cache);
1363        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1364            Box::new(ComposerVersion {
1365                version: "2.0.0-beta2".into(),
1366                version_normalized: "2.0.0.0-beta2".into(),
1367                abandoned: false,
1368                deprecation: None,
1369                published_at: None,
1370            }),
1371            Box::new(ComposerVersion {
1372                version: "2.0.0-beta1".into(),
1373                version_normalized: "2.0.0.0-beta1".into(),
1374                abandoned: false,
1375                deprecation: None,
1376                published_at: None,
1377            }),
1378        ];
1379        let req = VersionReq::new("*");
1380        assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
1381    }
1382
1383    /// Regression for #421 (S2): the async `get_latest_matching` fetch-loop fallback must
1384    /// resolve a prerelease-only package under a wildcard requirement too, mirroring
1385    /// `test_select_latest_matching_wildcard_prerelease_only_still_resolves`.
1386    #[tokio::test]
1387    async fn test_get_latest_matching_wildcard_prerelease_only_still_resolves() {
1388        let mut server = mockito::Server::new_async().await;
1389        let base = server.url();
1390        let registry = PackagistRegistry::with_registry_base(Arc::new(HttpCache::new()), base);
1391
1392        server
1393            .mock("GET", "/p2/vendor/prerelease-only.json")
1394            .with_status(200)
1395            .with_body(
1396                r#"{"packages": {"vendor/prerelease-only": [
1397                    {"version": "2.0.0-beta2", "version_normalized": "2.0.0.0-beta2"},
1398                    {"version": "2.0.0-beta1", "version_normalized": "2.0.0.0-beta1"}
1399                ]}}"#,
1400            )
1401            .create_async()
1402            .await;
1403
1404        let latest = registry
1405            .get_latest_matching("vendor/prerelease-only", "*")
1406            .await
1407            .unwrap();
1408
1409        let version = latest.expect("a prerelease-only package still exists and resolves");
1410        assert_eq!(version.version, "2.0.0-beta2");
1411    }
1412
1413    // --- #424 S1: manifest-level `minimum-stability` threading ---
1414
1415    fn stability_fixture() -> Vec<Box<dyn deps_core::Version>> {
1416        vec![
1417            Box::new(ComposerVersion {
1418                version: "2.0.0-alpha1".into(),
1419                version_normalized: "2.0.0.0-alpha1".into(),
1420                abandoned: false,
1421                deprecation: None,
1422                published_at: None,
1423            }),
1424            Box::new(ComposerVersion {
1425                version: "2.0.0-beta1".into(),
1426                version_normalized: "2.0.0.0-beta1".into(),
1427                abandoned: false,
1428                deprecation: None,
1429                published_at: None,
1430            }),
1431            Box::new(ComposerVersion {
1432                version: "1.5.0".into(),
1433                version_normalized: "1.5.0.0".into(),
1434                abandoned: false,
1435                deprecation: None,
1436                published_at: None,
1437            }),
1438        ]
1439    }
1440
1441    /// #424 S1: a manifest with `minimum-stability: beta` must resolve the newest release at
1442    /// or above beta (excluding alpha) as "latest", not fall back to the hardcoded
1443    /// `minimum-stability: stable` default that would exclude both prereleases.
1444    #[test]
1445    fn test_select_latest_matching_for_manifest_honors_looser_minimum_stability() {
1446        let cache = Arc::new(HttpCache::new());
1447        let registry = PackagistRegistry::new(cache);
1448        let versions = stability_fixture();
1449        let req = deps_core::VersionReq::new("*");
1450
1451        assert_eq!(
1452            registry.select_latest_matching_for_manifest(&versions, &req, Some("beta")),
1453            Some(1),
1454            "beta release should be latest under minimum-stability: beta"
1455        );
1456    }
1457
1458    /// #424 S1: `minimum-stability: alpha` loosens the floor further still, all the way to
1459    /// the newest alpha.
1460    #[test]
1461    fn test_select_latest_matching_for_manifest_honors_alpha_minimum_stability() {
1462        let cache = Arc::new(HttpCache::new());
1463        let registry = PackagistRegistry::new(cache);
1464        let versions = stability_fixture();
1465        let req = deps_core::VersionReq::new("*");
1466
1467        assert_eq!(
1468            registry.select_latest_matching_for_manifest(&versions, &req, Some("alpha")),
1469            Some(0),
1470            "alpha release should be latest under minimum-stability: alpha"
1471        );
1472    }
1473
1474    /// #424 S1: with no manifest `minimum-stability` (`None`), behavior must be byte-identical
1475    /// to the plain trait method — the hardcoded `stable` default from #421/#422.
1476    #[test]
1477    fn test_select_latest_matching_for_manifest_none_matches_default() {
1478        use deps_core::Registry;
1479
1480        let cache = Arc::new(HttpCache::new());
1481        let registry = PackagistRegistry::new(cache);
1482        let versions = stability_fixture();
1483        let req = deps_core::VersionReq::new("*");
1484
1485        assert_eq!(
1486            registry.select_latest_matching_for_manifest(&versions, &req, None),
1487            registry.select_latest_matching(&versions, &req),
1488        );
1489    }
1490
1491    /// #424 S1: `minimum-stability: stable` (explicit, not just absent) must behave exactly
1492    /// like the hardcoded default — Composer's own default value, spelled out.
1493    #[test]
1494    fn test_select_latest_matching_for_manifest_explicit_stable_excludes_prerelease() {
1495        let cache = Arc::new(HttpCache::new());
1496        let registry = PackagistRegistry::new(cache);
1497        let versions = stability_fixture();
1498        let req = deps_core::VersionReq::new("*");
1499
1500        assert_eq!(
1501            registry.select_latest_matching_for_manifest(&versions, &req, Some("stable")),
1502            Some(2),
1503        );
1504    }
1505
1506    /// #424 S1: the async `get_latest_matching_for_manifest` fetch-loop entry point must
1507    /// apply the same manifest stability floor as the pure list-based
1508    /// `select_latest_matching_for_manifest`.
1509    #[tokio::test]
1510    async fn test_get_latest_matching_for_manifest_honors_looser_minimum_stability() {
1511        let mut server = mockito::Server::new_async().await;
1512        let base = server.url();
1513        let registry = PackagistRegistry::with_registry_base(Arc::new(HttpCache::new()), base);
1514
1515        server
1516            .mock("GET", "/p2/vendor/pkg.json")
1517            .with_status(200)
1518            .with_body(
1519                r#"{"packages": {"vendor/pkg": [
1520                    {"version": "2.0.0-beta1", "version_normalized": "2.0.0.0-beta1"},
1521                    {"version": "1.5.0", "version_normalized": "1.5.0.0"}
1522                ]}}"#,
1523            )
1524            .create_async()
1525            .await;
1526
1527        let latest = registry
1528            .get_latest_matching_for_manifest("vendor/pkg", "*", Some("beta"))
1529            .await
1530            .unwrap();
1531
1532        assert_eq!(
1533            latest.expect("beta release resolves").version,
1534            "2.0.0-beta1"
1535        );
1536    }
1537
1538    // --- #424 S2: per-dependency `@stability` flags ---
1539
1540    /// #424 S2: `^1.0@beta` must be recognized as a prerelease-bearing opt-in — a beta
1541    /// release satisfying the range must resolve as latest, not be excluded by the default
1542    /// stable-only filter.
1543    #[test]
1544    fn test_select_latest_matching_at_beta_flag_allows_beta() {
1545        use deps_core::{Registry, VersionReq};
1546
1547        let cache = Arc::new(HttpCache::new());
1548        let registry = PackagistRegistry::new(cache);
1549        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1550            Box::new(ComposerVersion {
1551                version: "1.5.0-beta1".into(),
1552                version_normalized: "1.5.0.0-beta1".into(),
1553                abandoned: false,
1554                deprecation: None,
1555                published_at: None,
1556            }),
1557            Box::new(ComposerVersion {
1558                version: "1.0.0".into(),
1559                version_normalized: "1.0.0.0".into(),
1560                abandoned: false,
1561                deprecation: None,
1562                published_at: None,
1563            }),
1564        ];
1565        let req = VersionReq::new("^1.0@beta");
1566        assert_eq!(
1567            registry.select_latest_matching(&versions, &req),
1568            Some(0),
1569            "beta release matching the range must resolve under an @beta opt-in"
1570        );
1571    }
1572
1573    /// #424 S2: an `@beta` opt-in permits beta but not a *looser* alpha release — the flag
1574    /// sets a floor, not "allow everything unstable".
1575    #[test]
1576    fn test_select_latest_matching_at_beta_flag_excludes_alpha() {
1577        use deps_core::{Registry, VersionReq};
1578
1579        let cache = Arc::new(HttpCache::new());
1580        let registry = PackagistRegistry::new(cache);
1581        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1582            Box::new(ComposerVersion {
1583                version: "1.5.0-alpha1".into(),
1584                version_normalized: "1.5.0.0-alpha1".into(),
1585                abandoned: false,
1586                deprecation: None,
1587                published_at: None,
1588            }),
1589            Box::new(ComposerVersion {
1590                version: "1.0.0".into(),
1591                version_normalized: "1.0.0.0".into(),
1592                abandoned: false,
1593                deprecation: None,
1594                published_at: None,
1595            }),
1596        ];
1597        let req = VersionReq::new("^1.0@beta");
1598        assert_eq!(
1599            registry.select_latest_matching(&versions, &req),
1600            Some(1),
1601            "alpha release must still be excluded under an @beta opt-in"
1602        );
1603    }
1604
1605    /// #424 S2: `@stable` is recognized (parses cleanly, does not corrupt range matching) but
1606    /// is not itself a prerelease-bearing opt-in — it is Composer's own default spelled out
1607    /// explicitly, so an alpha/beta release must still be excluded.
1608    #[test]
1609    fn test_select_latest_matching_at_stable_flag_still_excludes_prerelease() {
1610        use deps_core::{Registry, VersionReq};
1611
1612        let cache = Arc::new(HttpCache::new());
1613        let registry = PackagistRegistry::new(cache);
1614        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1615            Box::new(ComposerVersion {
1616                version: "1.5.0-beta1".into(),
1617                version_normalized: "1.5.0.0-beta1".into(),
1618                abandoned: false,
1619                deprecation: None,
1620                published_at: None,
1621            }),
1622            Box::new(ComposerVersion {
1623                version: "1.0.0".into(),
1624                version_normalized: "1.0.0.0".into(),
1625                abandoned: false,
1626                deprecation: None,
1627                published_at: None,
1628            }),
1629        ];
1630        let req = VersionReq::new("^1.0@stable");
1631        assert_eq!(registry.select_latest_matching(&versions, &req), Some(1));
1632    }
1633
1634    /// #424 tester gap: `@RC` must be exercised end-to-end through `select_latest_matching`,
1635    /// not just unit-tested on `strip_stability_flag`/`composer_stability_rank` in isolation.
1636    /// An RC release matching the range must resolve, but a looser beta release must not.
1637    #[test]
1638    fn test_select_latest_matching_at_rc_flag_allows_rc_excludes_beta() {
1639        use deps_core::{Registry, VersionReq};
1640
1641        let cache = Arc::new(HttpCache::new());
1642        let registry = PackagistRegistry::new(cache);
1643        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1644            Box::new(ComposerVersion {
1645                version: "1.5.0-beta1".into(),
1646                version_normalized: "1.5.0.0-beta1".into(),
1647                abandoned: false,
1648                deprecation: None,
1649                published_at: None,
1650            }),
1651            Box::new(ComposerVersion {
1652                version: "1.4.0-RC1".into(),
1653                version_normalized: "1.4.0.0-RC1".into(),
1654                abandoned: false,
1655                deprecation: None,
1656                published_at: None,
1657            }),
1658            Box::new(ComposerVersion {
1659                version: "1.0.0".into(),
1660                version_normalized: "1.0.0.0".into(),
1661                abandoned: false,
1662                deprecation: None,
1663                published_at: None,
1664            }),
1665        ];
1666        let req = VersionReq::new("^1.0@RC");
1667        assert_eq!(
1668            registry.select_latest_matching(&versions, &req),
1669            Some(1),
1670            "RC release must resolve, but a looser beta release must still be excluded"
1671        );
1672    }
1673
1674    /// #424 tester gap: `@alpha` end-to-end — the loosest non-dev flag, so it must admit an
1675    /// alpha release too (dev-* branches are already filtered out of `get_versions` entirely,
1676    /// so `@alpha` and `@dev` are equivalent in practice for real numbered versions).
1677    #[test]
1678    fn test_select_latest_matching_at_alpha_flag_allows_alpha() {
1679        use deps_core::{Registry, VersionReq};
1680
1681        let cache = Arc::new(HttpCache::new());
1682        let registry = PackagistRegistry::new(cache);
1683        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1684            Box::new(ComposerVersion {
1685                version: "1.5.0-alpha1".into(),
1686                version_normalized: "1.5.0.0-alpha1".into(),
1687                abandoned: false,
1688                deprecation: None,
1689                published_at: None,
1690            }),
1691            Box::new(ComposerVersion {
1692                version: "1.0.0".into(),
1693                version_normalized: "1.0.0.0".into(),
1694                abandoned: false,
1695                deprecation: None,
1696                published_at: None,
1697            }),
1698        ];
1699        let req = VersionReq::new("^1.0@alpha");
1700        assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
1701    }
1702
1703    /// #424 tester gap: `@dev` end-to-end — admits a real numbered alpha/beta release too,
1704    /// since `@dev` ranks loosest (rank 0) and `dev-*` branch versions never reach this list.
1705    #[test]
1706    fn test_select_latest_matching_at_dev_flag_allows_alpha() {
1707        use deps_core::{Registry, VersionReq};
1708
1709        let cache = Arc::new(HttpCache::new());
1710        let registry = PackagistRegistry::new(cache);
1711        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1712            Box::new(ComposerVersion {
1713                version: "1.5.0-alpha1".into(),
1714                version_normalized: "1.5.0.0-alpha1".into(),
1715                abandoned: false,
1716                deprecation: None,
1717                published_at: None,
1718            }),
1719            Box::new(ComposerVersion {
1720                version: "1.0.0".into(),
1721                version_normalized: "1.0.0.0".into(),
1722                abandoned: false,
1723                deprecation: None,
1724                published_at: None,
1725            }),
1726        ];
1727        let req = VersionReq::new("^1.0@dev");
1728        assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
1729    }
1730
1731    // --- #424 critique M1: compound requirements must not drop the `@flag` opt-in ---
1732
1733    /// #424 critique M1: an `@flag` inside the first OR-branch of a compound requirement
1734    /// (`^1.0@beta || ^2.0`) must still be recognized, admitting a beta release matching that
1735    /// branch.
1736    #[test]
1737    fn test_select_latest_matching_at_flag_in_or_branch() {
1738        use deps_core::{Registry, VersionReq};
1739
1740        let cache = Arc::new(HttpCache::new());
1741        let registry = PackagistRegistry::new(cache);
1742        let versions: Vec<Box<dyn deps_core::Version>> = vec![Box::new(ComposerVersion {
1743            version: "1.5.0-beta1".into(),
1744            version_normalized: "1.5.0.0-beta1".into(),
1745            abandoned: false,
1746            deprecation: None,
1747            published_at: None,
1748        })];
1749        let req = VersionReq::new("^1.0@beta || ^2.0");
1750        assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
1751    }
1752
1753    /// #424 critique M1: an `@flag` inside one token of a space-separated AND range
1754    /// (`>=1.0@dev <2.0`) must still be recognized.
1755    #[test]
1756    fn test_select_latest_matching_at_flag_in_and_range() {
1757        use deps_core::{Registry, VersionReq};
1758
1759        let cache = Arc::new(HttpCache::new());
1760        let registry = PackagistRegistry::new(cache);
1761        let versions: Vec<Box<dyn deps_core::Version>> = vec![Box::new(ComposerVersion {
1762            version: "1.5.0-alpha1".into(),
1763            version_normalized: "1.5.0.0-alpha1".into(),
1764            abandoned: false,
1765            deprecation: None,
1766            published_at: None,
1767        })];
1768        let req = VersionReq::new(">=1.0@dev <2.0");
1769        assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
1770    }
1771
1772    /// #424 critique M1: `compound_stability_flag_rank` unit-level — confirms the loosest
1773    /// flag among multiple tokens wins, and that a flag-less compound requirement yields
1774    /// `None` (falling through to the next priority tier).
1775    #[test]
1776    fn test_compound_stability_flag_rank() {
1777        assert_eq!(
1778            compound_stability_flag_rank("^1.0@beta || ^2.0"),
1779            Some(crate::formatter::composer_stability_rank("beta"))
1780        );
1781        assert_eq!(
1782            compound_stability_flag_rank(">=1.0@dev <2.0"),
1783            Some(crate::formatter::composer_stability_rank("dev"))
1784        );
1785        assert_eq!(
1786            compound_stability_flag_rank("^1.0@alpha || ^2.0@RC"),
1787            Some(crate::formatter::composer_stability_rank("alpha")),
1788            "the loosest flag among branches must win"
1789        );
1790        assert_eq!(compound_stability_flag_rank("^1.0 || ^2.0"), None);
1791    }
1792
1793    // --- #424 critique S2: separator-less short-alias (a/b) and dev, end-to-end ---
1794
1795    /// #424 critique S2 gap: separator-less short alias `a1`/`b1` end-to-end through
1796    /// `select_latest_matching`, mirroring the existing separator-less-RC coverage.
1797    #[test]
1798    fn test_select_latest_matching_excludes_separatorless_short_alias() {
1799        use deps_core::{Registry, VersionReq};
1800
1801        let cache = Arc::new(HttpCache::new());
1802        let registry = PackagistRegistry::new(cache);
1803        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1804            Box::new(ComposerVersion {
1805                version: "2.0.0a1".into(),
1806                version_normalized: "2.0.0a1".into(),
1807                abandoned: false,
1808                deprecation: None,
1809                published_at: None,
1810            }),
1811            Box::new(ComposerVersion {
1812                version: "1.5.0".into(),
1813                version_normalized: "1.5.0.0".into(),
1814                abandoned: false,
1815                deprecation: None,
1816                published_at: None,
1817            }),
1818        ];
1819        let req = VersionReq::new(">=1.0");
1820        assert_eq!(registry.select_latest_matching(&versions, &req), Some(1));
1821    }
1822
1823    /// #424 critique S2 gap: an exact separator-less short-alias pin (`2.0.0a1`) must resolve
1824    /// to itself — this is the exact case that was broken pre-fix (classifiers disagreed).
1825    #[test]
1826    fn test_select_latest_matching_allows_separatorless_short_alias_pin_when_requirement_names_it()
1827    {
1828        use deps_core::{Registry, VersionReq};
1829
1830        let cache = Arc::new(HttpCache::new());
1831        let registry = PackagistRegistry::new(cache);
1832        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1833            Box::new(ComposerVersion {
1834                version: "2.0.0a1".into(),
1835                version_normalized: "2.0.0a1".into(),
1836                abandoned: false,
1837                deprecation: None,
1838                published_at: None,
1839            }),
1840            Box::new(ComposerVersion {
1841                version: "1.5.0".into(),
1842                version_normalized: "1.5.0.0".into(),
1843                abandoned: false,
1844                deprecation: None,
1845                published_at: None,
1846            }),
1847        ];
1848        let req = VersionReq::new("2.0.0a1");
1849        assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
1850    }
1851
1852    /// #424 critique S2 gap: separator-less `dev` suffix end-to-end.
1853    #[test]
1854    fn test_select_latest_matching_excludes_separatorless_dev_suffix() {
1855        use deps_core::{Registry, VersionReq};
1856
1857        let cache = Arc::new(HttpCache::new());
1858        let registry = PackagistRegistry::new(cache);
1859        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1860            Box::new(ComposerVersion {
1861                version: "2.0.0dev".into(),
1862                version_normalized: "2.0.0dev".into(),
1863                abandoned: false,
1864                deprecation: None,
1865                published_at: None,
1866            }),
1867            Box::new(ComposerVersion {
1868                version: "1.5.0".into(),
1869                version_normalized: "1.5.0.0".into(),
1870                abandoned: false,
1871                deprecation: None,
1872                published_at: None,
1873            }),
1874        ];
1875        let req = VersionReq::new(">=1.0");
1876        assert_eq!(registry.select_latest_matching(&versions, &req), Some(1));
1877    }
1878
1879    // --- #424 critique C1: v-prefixed prereleases, end-to-end (CRITICAL regression) ---
1880
1881    /// #424 critique C1: reproduces the live `sylius/sylius` regression — a `v`-prefixed
1882    /// alpha release must not be reported as "latest" ahead of an older `v`-prefixed stable
1883    /// release. Before the fix, `composer_version_stability_rank` swallowed the leading `v`
1884    /// as the qualifier word itself and ranked the alpha release as stable.
1885    #[test]
1886    fn test_select_latest_matching_v_prefixed_alpha_excluded_by_default() {
1887        use deps_core::{Registry, VersionReq};
1888
1889        let cache = Arc::new(HttpCache::new());
1890        let registry = PackagistRegistry::new(cache);
1891        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1892            Box::new(ComposerVersion {
1893                version: "v2.3.0-alpha.1".into(),
1894                version_normalized: "2.3.0.0-alpha1".into(),
1895                abandoned: false,
1896                deprecation: None,
1897                published_at: None,
1898            }),
1899            Box::new(ComposerVersion {
1900                version: "v2.2.8".into(),
1901                version_normalized: "2.2.8.0".into(),
1902                abandoned: false,
1903                deprecation: None,
1904                published_at: None,
1905            }),
1906        ];
1907        let req = VersionReq::new("*");
1908        assert_eq!(
1909            registry.select_latest_matching(&versions, &req),
1910            Some(1),
1911            "v2.2.8 (stable) must resolve as latest, not the v-prefixed alpha ahead of it"
1912        );
1913    }
1914
1915    /// #424 critique C1: `symfony/*`-style data — a `v`-prefixed RC release ordered ahead of
1916    /// a `v`-prefixed stable release in the version list must still be excluded by the
1917    /// default stable-only filter for a concrete requirement.
1918    #[test]
1919    fn test_select_latest_matching_v_prefixed_rc_excluded_for_concrete_requirement() {
1920        use deps_core::{Registry, VersionReq};
1921
1922        let cache = Arc::new(HttpCache::new());
1923        let registry = PackagistRegistry::new(cache);
1924        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1925            Box::new(ComposerVersion {
1926                version: "V3.0.0-RC1".into(),
1927                version_normalized: "3.0.0.0-RC1".into(),
1928                abandoned: false,
1929                deprecation: None,
1930                published_at: None,
1931            }),
1932            Box::new(ComposerVersion {
1933                version: "v2.9.0".into(),
1934                version_normalized: "2.9.0.0".into(),
1935                abandoned: false,
1936                deprecation: None,
1937                published_at: None,
1938            }),
1939        ];
1940        let req = VersionReq::new(">=2.0");
1941        assert_eq!(registry.select_latest_matching(&versions, &req), Some(1));
1942    }
1943
1944    // --- #534: uppercase-`V`-prefixed version satisfies a concrete requirement, end-to-end ---
1945
1946    /// #534: reproduces the real end-to-end regression (not just the isolated formatter unit
1947    /// test) — before the fix, an uppercase-`V`-prefixed version like `V3.1.0` sent
1948    /// `compare_versions` down `split_composer_core_and_suffix`'s qualifier-suffix branch with
1949    /// numeric core `0` (only a lowercase `v` was stripped), so `version_satisfies_requirement`
1950    /// silently never matched a concrete requirement, and `select_latest_matching` — the layer
1951    /// that actually drives Composer's "latest matching version" resolution — returned `None`
1952    /// even though the version is a real match.
1953    #[test]
1954    fn test_select_latest_matching_uppercase_v_prefixed_version_satisfies_requirement() {
1955        use deps_core::{Registry, VersionReq};
1956
1957        let cache = Arc::new(HttpCache::new());
1958        let registry = PackagistRegistry::new(cache);
1959        let versions: Vec<Box<dyn deps_core::Version>> = vec![Box::new(ComposerVersion {
1960            version: "V3.1.0".into(),
1961            version_normalized: "3.1.0.0".into(),
1962            abandoned: false,
1963            deprecation: None,
1964            published_at: None,
1965        })];
1966        let req = VersionReq::new(">=3.0");
1967        assert_eq!(
1968            registry.select_latest_matching(&versions, &req),
1969            Some(0),
1970            "V3.1.0 must satisfy >=3.0 through the real select_latest_matching path"
1971        );
1972    }
1973
1974    // --- #424 S3: separator-less prerelease suffix consistency ---
1975
1976    /// #424 S3: `1.0.0RC1` (no separator before `RC`) must be excluded from "latest" by the
1977    /// default stable-only filter exactly like its hyphenated form `1.0.0-RC1` — classified
1978    /// via the primary `is_prerelease_marker` path, independent of `version_normalized`.
1979    #[test]
1980    fn test_select_latest_matching_excludes_separatorless_rc_suffix() {
1981        use deps_core::{Registry, VersionReq};
1982
1983        let cache = Arc::new(HttpCache::new());
1984        let registry = PackagistRegistry::new(cache);
1985        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1986            Box::new(ComposerVersion {
1987                version: "2.0.0RC1".into(),
1988                // `version_normalized` deliberately left un-hyphenated (mirrors a Packagist
1989                // response that never expanded it) so the primary path must catch this alone.
1990                version_normalized: "2.0.0RC1".into(),
1991                abandoned: false,
1992                deprecation: None,
1993                published_at: None,
1994            }),
1995            Box::new(ComposerVersion {
1996                version: "1.5.0".into(),
1997                version_normalized: "1.5.0.0".into(),
1998                abandoned: false,
1999                deprecation: None,
2000                published_at: None,
2001            }),
2002        ];
2003        let req = VersionReq::new(">=1.0");
2004        assert_eq!(registry.select_latest_matching(&versions, &req), Some(1));
2005    }
2006
2007    /// #424 S3: an exact separator-less pin (`2.0.0RC1`) must still be recognized as a
2008    /// prerelease-bearing requirement and resolve to itself — mirroring the hyphenated-pin
2009    /// case `test_select_latest_matching_allows_prerelease_when_requirement_names_it`.
2010    #[test]
2011    fn test_select_latest_matching_allows_separatorless_rc_pin_when_requirement_names_it() {
2012        use deps_core::{Registry, VersionReq};
2013
2014        let cache = Arc::new(HttpCache::new());
2015        let registry = PackagistRegistry::new(cache);
2016        let versions: Vec<Box<dyn deps_core::Version>> = vec![
2017            Box::new(ComposerVersion {
2018                version: "2.0.0RC1".into(),
2019                version_normalized: "2.0.0RC1".into(),
2020                abandoned: false,
2021                deprecation: None,
2022                published_at: None,
2023            }),
2024            Box::new(ComposerVersion {
2025                version: "1.5.0".into(),
2026                version_normalized: "1.5.0.0".into(),
2027                abandoned: false,
2028                deprecation: None,
2029                published_at: None,
2030            }),
2031        ];
2032        let req = VersionReq::new("2.0.0RC1");
2033        assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
2034    }
2035
2036    /// #424 critique N3: a dot-separated qualifier (`2.6.3.alpha`, a live `api-platform/core`
2037    /// tag) must be excluded from "latest" by the default stable-only filter, exactly like the
2038    /// hyphenated/separator-less forms above.
2039    #[test]
2040    fn test_select_latest_matching_excludes_dot_separated_alpha_suffix() {
2041        use deps_core::{Registry, VersionReq};
2042
2043        let cache = Arc::new(HttpCache::new());
2044        let registry = PackagistRegistry::new(cache);
2045        let versions: Vec<Box<dyn deps_core::Version>> = vec![
2046            Box::new(ComposerVersion {
2047                version: "2.6.3.alpha".into(),
2048                version_normalized: "2.6.3.0-alpha".into(),
2049                abandoned: false,
2050                deprecation: None,
2051                published_at: None,
2052            }),
2053            Box::new(ComposerVersion {
2054                version: "2.6.2".into(),
2055                version_normalized: "2.6.2.0".into(),
2056                abandoned: false,
2057                deprecation: None,
2058                published_at: None,
2059            }),
2060        ];
2061        let req = VersionReq::new(">=2.0");
2062        assert_eq!(registry.select_latest_matching(&versions, &req), Some(1));
2063    }
2064
2065    /// #424 critique N3: an exact pin naming the dot-separated prerelease form directly
2066    /// (`2.6.3.alpha`) must still resolve to itself — before the fix, `is_prerelease_marker`
2067    /// disagreed with `composer_version_stability_rank` on this exact shape, which is the
2068    /// #421 S1 failure mode (a pin that can never match its own version).
2069    #[test]
2070    fn test_select_latest_matching_allows_dot_separated_alpha_pin_when_requirement_names_it() {
2071        use deps_core::{Registry, VersionReq};
2072
2073        let cache = Arc::new(HttpCache::new());
2074        let registry = PackagistRegistry::new(cache);
2075        let versions: Vec<Box<dyn deps_core::Version>> = vec![
2076            Box::new(ComposerVersion {
2077                version: "2.6.3.alpha".into(),
2078                version_normalized: "2.6.3.0-alpha".into(),
2079                abandoned: false,
2080                deprecation: None,
2081                published_at: None,
2082            }),
2083            Box::new(ComposerVersion {
2084                version: "2.6.2".into(),
2085                version_normalized: "2.6.2.0".into(),
2086                abandoned: false,
2087                deprecation: None,
2088                published_at: None,
2089            }),
2090        ];
2091        let req = VersionReq::new("2.6.3.alpha");
2092        assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
2093    }
2094
2095    #[tokio::test]
2096    #[ignore]
2097    async fn test_fetch_real_monolog_versions() {
2098        let cache = Arc::new(HttpCache::new());
2099        let registry = PackagistRegistry::new(cache);
2100        let versions = registry.get_versions("monolog/monolog").await.unwrap();
2101
2102        assert!(!versions.is_empty());
2103        assert!(
2104            versions
2105                .iter()
2106                .any(|v| v.version.as_str().starts_with("3."))
2107        );
2108    }
2109
2110    #[tokio::test]
2111    #[ignore]
2112    async fn test_search_real() {
2113        let cache = Arc::new(HttpCache::new());
2114        let registry = PackagistRegistry::new(cache);
2115        let results = registry.search("symfony", 5).await.unwrap();
2116
2117        assert!(!results.is_empty());
2118    }
2119}