Skip to main content

deps_engine/classify/
fetch.rs

1//! Registry fetch fan-out: concurrent version fetching and per-package classification.
2//!
3//! Pure classification helpers extracted from `deps-lsp`'s `document/fetch.rs`: resolving each
4//! dependency occurrence to a fetchable source, fetching and classifying a single package's
5//! latest/yanked/deprecated/license status, and fanning that out concurrently across a
6//! manifest's dependencies. The orchestration around these decisions — marking a document
7//! loading, opening an LSP progress notification, and reacting to a mid-flight document edit —
8//! stays in `deps-lsp`'s `fetch_registry_versions_for_change`, since it owns state this crate
9//! must not know about (issue #1059).
10
11use crate::progress::ProgressSender;
12use deps_core::ConcreteVersion;
13use deps_core::Deprecation;
14use deps_core::FetchFailure;
15use deps_core::PackageName;
16use deps_core::PackageVersions;
17use deps_core::Registry;
18use deps_core::RemovalStatus;
19use deps_core::VersionReq;
20use std::collections::{HashMap, HashSet};
21use std::sync::Arc;
22use std::time::Duration;
23
24/// A dependency name paired with the resolved source to route its registry fetch through
25/// (spec FR-001), as built by [`dedup_dependencies_by_source`].
26pub type DepSources = Vec<(PackageName, deps_core::parser::DependencySource)>;
27
28/// Pairs each distinct dependency name in `parse_result` with the source its occurrence(s)
29/// resolve to.
30///
31/// For the background registry fetch to route through
32/// `Registry::get_versions_from`/`get_latest_matching_from` (spec FR-001).
33///
34/// Two gates, applied in order:
35///
36/// 1. **Resolvability** (closes a review-flagged leak): a dependency whose source is not
37///    resolvable at all (`!formatter.can_resolve_source(source)` — Git, Path, an unresolved
38///    `CustomRegistry` alias, ...) is dropped from the result entirely, never reaching the
39///    fetch. Without this gate, `CargoRegistry`'s (and every other source-aware registry's)
40///    `_ =>` default-to-crates.io arm would silently look up a private/unresolvable name
41///    against the ecosystem's *public* registry — exactly the leak this feature's own
42///    hover/code-actions/diagnostics gating was built to close, just reached through the
43///    highest-traffic path (the background fetch feeding inlay hints and cached
44///    diagnostics) instead.
45/// 2. **Collision** (spec FR-011): when two occurrences of the same name both resolve
46///    (gate 1 passed for both) to two *different* sources — e.g. a genuine resolution bug
47///    producing two distinct index URLs for what should be one registry — both are dropped
48///    from the map and the name is added to the returned collision set instead of being
49///    fetched. The fetch result is shared across every occurrence of a name
50///    (`FetchResult::versions` is name-keyed), so silently picking a source here would
51///    silently apply it to occurrences whose author may have intended a different
52///    registry. A `tracing::warn!` names both resolved sources, using message text
53///    distinguishable from `deps-cargo`'s own FR-003 unresolved-alias warning.
54///
55/// Returns `(sources, collided)`: `sources` is ready to fetch as-is; `collided` must be
56/// merged into `DocumentState::outcomes`' fetch-failure channel by the caller so
57/// `generate_diagnostics_from_cache` reports "lookup could not be determined" rather than
58/// a false "Unknown package" for a dependency that was never actually queried.
59///
60/// # Examples
61///
62/// ```
63/// use deps_core::lsp_helpers::{
64///     DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
65///     RequirementResolution, SourcePolicy,
66/// };
67/// use deps_core::test_util::stub_parse_result_with_dependencies;
68/// use deps_core::{ConcreteVersion, PackageName};
69/// use deps_engine::classify::fetch::dedup_dependencies_by_source;
70///
71/// struct SimpleFormatter;
72/// impl PackageNaming for SimpleFormatter {}
73/// impl PackageRendering for SimpleFormatter {
74///     fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
75///         version.to_string()
76///     }
77///     fn package_url(&self, name: &PackageName) -> String {
78///         name.to_string()
79///     }
80/// }
81/// impl RequirementResolution for SimpleFormatter {}
82/// impl DiagnosticMessages for SimpleFormatter {}
83/// impl DiagnosticPolicy for SimpleFormatter {}
84/// impl SourcePolicy for SimpleFormatter {}
85/// impl OsvNaming for SimpleFormatter {}
86///
87/// let parsed = stub_parse_result_with_dependencies(2);
88/// let (sources, collided) = dedup_dependencies_by_source(parsed.as_ref(), &SimpleFormatter);
89///
90/// assert_eq!(sources.len(), 2, "both distinct names resolve, no collision");
91/// assert!(collided.is_empty());
92/// ```
93pub fn dedup_dependencies_by_source(
94    parse_result: &dyn deps_core::ParseResult,
95    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
96) -> (
97    HashMap<PackageName, deps_core::parser::DependencySource>,
98    HashSet<PackageName>,
99) {
100    use std::collections::hash_map::Entry;
101
102    let mut by_name: HashMap<PackageName, deps_core::parser::DependencySource> = HashMap::new();
103    let mut collided: HashSet<PackageName> = HashSet::new();
104
105    for dep in parse_result
106        .dependencies()
107        .into_iter()
108        .filter(|dep| formatter.can_resolve_source(&dep.source()))
109    {
110        let name = dep.name().clone();
111        let source = dep.source();
112        match by_name.entry(name.clone()) {
113            Entry::Vacant(entry) => {
114                entry.insert(source);
115            }
116            Entry::Occupied(entry) => {
117                if *entry.get() != source && collided.insert(name.clone()) {
118                    tracing::warn!(
119                        package = %name,
120                        source_a = ?entry.get(),
121                        source_b = ?source,
122                        "dependency declared against two different resolved registries; \
123                         skipping version resolution for all occurrences"
124                    );
125                }
126            }
127        }
128    }
129
130    for name in &collided {
131        by_name.remove(name);
132    }
133    (by_name, collided)
134}
135
136/// Composer's own `minimum-stability` manifest setting, when `parse_result` is a parsed
137/// `composer.json` (#424 S1).
138///
139/// Downcasts via [`deps_core::ParseResult::as_any`] rather than widening the generic
140/// `ParseResult`/`Registry` traits with an ecosystem-specific field: every other ecosystem has
141/// no equivalent manifest-level stability floor, so this stays local to the one call site
142/// (`fetch_latest_versions_parallel`'s caller) that needs to bridge a Composer-specific
143/// manifest value into the generic `Registry::*_with_context` trait hook.
144///
145/// Gated on `deps-engine`'s own `composer` feature (T013/N1): under Cargo's workspace
146/// feature-unification, enabling `composer` for *any* crate in the build graph (e.g. another
147/// adapter, or a `--all-features` build) activates it here too, even for a `deps-lsp` build
148/// that itself passed `--no-default-features` and never asked for Composer support. This is a
149/// deliberate, documented behavior change from the pre-move code (where this function lived
150/// directly in `deps-lsp` and was gated only by that one crate's own feature selection), not
151/// an oversight — the alternative (duplicating this function per adapter) would reintroduce
152/// exactly the drift this extraction exists to close.
153#[cfg(feature = "composer")]
154pub fn composer_minimum_stability(parse_result: &dyn deps_core::ParseResult) -> Option<String> {
155    parse_result
156        .as_any()
157        .downcast_ref::<crate::setup::ComposerParseResult>()
158        .and_then(|r| r.minimum_stability.clone())
159}
160
161/// No-op when the `composer` feature is disabled — `crate::setup::ComposerParseResult` does not
162/// exist in that build, so `parse_result` can never downcast to it.
163#[cfg(not(feature = "composer"))]
164pub fn composer_minimum_stability(_parse_result: &dyn deps_core::ParseResult) -> Option<String> {
165    None
166}
167
168/// Result of parallel version fetching.
169#[non_exhaustive]
170pub struct FetchResult {
171    /// Successfully fetched versions (package -> latest + full version list)
172    pub versions: HashMap<PackageName, PackageVersions>,
173    /// Yanked-version findings, keyed by **raw** package name (unlike
174    /// `DocumentState::outcomes`, which is normalized-keyed — see
175    /// §3.1 of the design), to (the version string found yanked, its
176    /// `RemovalStatus`). The status rides alongside so #205's package-level
177    /// deprecation diagnostic can gate its yanked-check suppression on
178    /// `AdvisoryDeprecated` specifically, never a genuine `Yanked` finding.
179    /// Callers must re-key through `EcosystemFormatter::normalize_package_name`
180    /// before merging into document state.
181    pub yanked_versions: HashMap<PackageName, (ConcreteVersion, RemovalStatus)>,
182    /// Package-level deprecation findings (issue #205), keyed by **raw** package name
183    /// (same raw/normalized split as `yanked_versions` above). Derived from the
184    /// `resolved`/"latest" pick in the fetch loop below, not by scanning the full
185    /// `versions` list — see that loop's comments for why.
186    pub deprecations: HashMap<PackageName, Deprecation>,
187    /// Packages whose registry fetch errored or timed out, keyed by **raw**
188    /// package name (same raw/normalized split as `yanked_versions` above).
189    /// Lets diagnostic generation (#267) distinguish "the registry said this
190    /// package doesn't exist" from "the registry couldn't be asked" instead
191    /// of conflating both into a misleading "Unknown package" diagnostic.
192    pub fetch_failed: HashMap<PackageName, FetchFailure>,
193    /// Packages whose registry fetch succeeded but produced zero comparable versions
194    /// (#550), keyed by **raw** package name (same raw/normalized split as
195    /// `yanked_versions` above). Distinct from `fetch_failed`: the registry was
196    /// successfully asked and the package demonstrably exists — it just has nothing a
197    /// version-comparison rule can use — so `generate_diagnostics_from_cache` must
198    /// report neither "Registry lookup failed" nor "Unknown package" for it.
199    pub no_comparable_versions: HashSet<PackageName>,
200    /// Number of packages whose registry fetch did not succeed, counting both a genuine
201    /// fetch failure (timeout, error — recorded in `fetch_failed` above) and a not-found
202    /// lookup (the registry answered "no such package", never recorded in `fetch_failed`,
203    /// see #267 C1). Only the `fetch_failed` subset produces an inline "Registry lookup
204    /// failed" diagnostic, so this count can exceed `fetch_failed.len()` (#276 S2, #490).
205    pub failed_count: usize,
206    /// First actionable error message (shown to user via `window/showMessage`)
207    pub first_error: Option<String>,
208    /// SPDX license identifier(s) for the resolved/"latest" pick, for every package
209    /// whose `Version::license` on the already-fetched version-list entry is
210    /// non-empty (issue #660/#661 tier-1 backfill) — today, only the native-list
211    /// ecosystems (PyPI, Composer) ever populate this; every other ecosystem's
212    /// `Version::license` default is empty, so this map stays empty for them.
213    /// Deliberately *not* threaded into [`PackageVersions`] itself (that type is
214    /// constructed identically across ~40 call sites throughout the workspace,
215    /// including files outside this crate's ownership for this change) —
216    /// `merge_registry_fetch_result` merges this map directly into
217    /// `deps-lsp`'s `DocumentState::licenses` instead, the same map the tier-3
218    /// background pre-fetch (`run_license_prefetch`) already populates for
219    /// Dart/Swift/Gradle/Deno. A merge (not replace), since the two sources are
220    /// always disjoint per document (one ecosystem per document) but run as
221    /// independent, non-ordered background tasks.
222    pub licenses: HashMap<PackageName, Vec<String>>,
223}
224
225impl FetchResult {
226    /// Constructs a `FetchResult` from its already-computed fields.
227    ///
228    /// `#[non_exhaustive]` blocks cross-crate struct-literal construction even with every
229    /// field named, so a caller outside `deps-engine` (e.g. a `deps-lsp` unit test building a
230    /// synthetic fetch outcome) needs this constructor instead.
231    ///
232    /// # Examples
233    ///
234    /// ```
235    /// use deps_engine::classify::fetch::FetchResult;
236    /// use std::collections::{HashMap, HashSet};
237    ///
238    /// let result = FetchResult::new(
239    ///     HashMap::new(),
240    ///     HashMap::new(),
241    ///     HashMap::new(),
242    ///     HashMap::new(),
243    ///     HashSet::new(),
244    ///     0,
245    ///     None,
246    ///     HashMap::new(),
247    /// );
248    /// assert_eq!(result.failed_count, 0);
249    /// ```
250    ///
251    /// Parameters, in declaration order (see each field's own doc above for the full
252    /// rationale — this is a quick cross-check against accidental transposition, several
253    /// share the same `HashMap<PackageName, _>`/`HashSet<PackageName>` shape):
254    /// `versions` (successful fetches), `yanked_versions` (yank findings),
255    /// `deprecations` (package-level deprecation findings), `fetch_failed` (errored/timed-out
256    /// packages), `no_comparable_versions` (fetched clean but nothing to compare),
257    /// `failed_count`, `first_error`, `licenses`.
258    #[must_use]
259    #[allow(clippy::too_many_arguments)]
260    pub fn new(
261        versions: HashMap<PackageName, PackageVersions>,
262        yanked_versions: HashMap<PackageName, (ConcreteVersion, RemovalStatus)>,
263        deprecations: HashMap<PackageName, Deprecation>,
264        fetch_failed: HashMap<PackageName, FetchFailure>,
265        no_comparable_versions: HashSet<PackageName>,
266        failed_count: usize,
267        first_error: Option<String>,
268        licenses: HashMap<PackageName, Vec<String>>,
269    ) -> Self {
270        Self {
271            versions,
272            yanked_versions,
273            deprecations,
274            fetch_failed,
275            no_comparable_versions,
276            failed_count,
277            first_error,
278            licenses,
279        }
280    }
281}
282
283/// Fetches latest versions for multiple packages in parallel with progress reporting.
284///
285/// Returns a [`FetchResult`] containing successfully fetched versions and failure count.
286/// Packages that fail to fetch are omitted from the versions map.
287///
288/// This function executes all registry requests concurrently with per-dependency
289/// timeout isolation, preventing slow packages from blocking others.
290///
291/// Alongside the primary fetch, checks whether the in-use version of a
292/// dependency has been yanked (#233), for registries that [report yank
293/// data](Registry::reports_yanked). Unlike the original design, this is not
294/// a second registry round trip: `registry.get_versions` below already
295/// fetches the full, unfiltered version list once per package (see
296/// [`PackageVersions`]), so the in-use-version check is a zero-cost
297/// in-memory search over a list already in hand, run for every dependency
298/// with a known in-use version rather than only when it differs from
299/// `latest`.
300///
301/// # Arguments
302///
303/// * `registry` - Package registry to fetch from
304/// * `package_names` - List of package names to fetch
305/// * `in_use` - Raw dependency name -> the version(s) this project actually
306///   has (lockfile-resolved or a concrete pin) for every occurrence of that
307///   name in the manifest, checked against the fetched version list for
308///   yank status
309/// * `progress` - Optional progress tracker (will be updated after each fetch)
310/// * `timeout_secs` - Timeout for each individual package fetch (default: 10s)
311/// * `max_concurrent` - Maximum concurrent fetches (default: 20); clamped to `>= 1`
312///   internally, since `buffer_unordered(0)` would hang forever (issue #833)
313///
314/// # Timeout Behavior
315///
316/// Each package fetch is wrapped in an individual timeout. If a package
317/// takes longer than `timeout_secs` to fetch, it fails fast with a warning
318/// and does NOT block other packages.
319///
320/// # Performance
321///
322/// With 50 dependencies and 100ms per request:
323/// - Sequential: 50 × 100ms = 5000ms
324/// - Parallel (no timeout): max(100ms) ≈ 150ms
325/// - Parallel (10s timeout, 1 slow package at 30s): max(10s) ≈ 10s
326///
327/// # Examples
328///
329/// ```
330/// use deps_core::parser::DependencySource;
331/// use deps_core::{ConcreteVersion, Metadata, PackageName, Registry, Version, VersionReq};
332/// use deps_engine::classify::fetch::fetch_latest_versions_parallel;
333/// use std::any::Any;
334/// use std::collections::HashMap;
335/// use std::sync::Arc;
336///
337/// struct SingleVersionRegistry;
338///
339/// #[derive(Clone)]
340/// struct SimpleVersion {
341///     version: ConcreteVersion,
342/// }
343/// impl Version for SimpleVersion {
344///     fn version_string(&self) -> &ConcreteVersion {
345///         &self.version
346///     }
347///     fn as_any(&self) -> &dyn Any {
348///         self
349///     }
350/// }
351///
352/// impl Registry for SingleVersionRegistry {
353///     fn get_versions<'a>(
354///         &'a self,
355///         _name: &'a PackageName,
356///     ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>> {
357///         Box::pin(async move {
358///             Ok(vec![Box::new(SimpleVersion { version: "1.0.0".into() }) as Box<dyn Version>])
359///         })
360///     }
361///
362///     // The default `select_latest_matching` always returns `None` (every real registry
363///     // overrides it with ecosystem-specific comparison), so `fetch_and_classify_package`
364///     // falls back to this method for its pick.
365///     fn get_latest_matching<'a>(
366///         &'a self,
367///         _name: &'a PackageName,
368///         _req: &'a VersionReq,
369///     ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>> {
370///         Box::pin(async move {
371///             Ok(Some(Box::new(SimpleVersion { version: "1.0.0".into() }) as Box<dyn Version>))
372///         })
373///     }
374///
375///     fn search<'a>(
376///         &'a self,
377///         _query: &'a str,
378///         _limit: usize,
379///     ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>> {
380///         Box::pin(async move { Ok(vec![]) })
381///     }
382///
383///     fn as_any(&self) -> &dyn Any {
384///         self
385///     }
386/// }
387///
388/// #[tokio::main]
389/// async fn main() {
390///     let sources = vec![(PackageName::new("time"), DependencySource::Registry)];
391///
392///     let result = fetch_latest_versions_parallel(
393///         Arc::new(SingleVersionRegistry),
394///         sources,
395///         &HashMap::new(),
396///         None,
397///         deps_core::freshness::FreshnessSettings::default(),
398///         5,
399///         10,
400///         None,
401///     )
402///     .await;
403///
404///     assert_eq!(
405///         result.versions.get(&PackageName::new("time")).map(|v| v.latest.to_string()),
406///         Some("1.0.0".to_string())
407///     );
408/// }
409/// ```
410#[allow(
411    clippy::too_many_arguments,
412    reason = "internal (non-pub) call-site-controlled fetch tuning + ecosystem-context \
413              parameters; grouping into a config struct would only move, not reduce, the \
414              per-call-site churn across this module's ~15 production and test call sites"
415)]
416pub async fn fetch_latest_versions_parallel(
417    registry: Arc<dyn Registry>,
418    package_sources: DepSources,
419    in_use: &HashMap<PackageName, Vec<String>>,
420    progress_sender: Option<ProgressSender>,
421    freshness: deps_core::freshness::FreshnessSettings,
422    timeout_secs: u64,
423    max_concurrent: usize,
424    minimum_stability: Option<&str>,
425) -> FetchResult {
426    use futures::stream::{self, StreamExt};
427    use std::time::Duration;
428
429    let fetched = Arc::new(std::sync::atomic::AtomicUsize::new(0));
430    let failed = Arc::new(std::sync::atomic::AtomicUsize::new(0));
431    let first_error: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
432    // Separate from `first_error` (#480): not-found errors are excluded from
433    // `fetch_failed`, but without this a fast not-found could still win the `first_error`
434    // completion race over a slower, more actionable failure (e.g. a rate limit hit by
435    // 20 other dependencies). Any `fetch_failed`-counted error always wins the toast over
436    // a not-found regardless of finishing order; a not-found-only batch falls back to
437    // `first_error`.
438    //
439    // Derived by folding each task's own `(name, message)` return value in completion
440    // order (see the loop below) rather than written from inside the match arms via a
441    // shared `Arc<Mutex>` like `first_error` — keeps `fetch_failed` and the priority error
442    // in sync by construction instead of via two independently hand-maintained writes (#480).
443    let timeout = Duration::from_secs(timeout_secs);
444    let wildcard_req = deps_core::VersionReq::new("*");
445    let check_yanked = registry.reports_yanked();
446
447    let results: Vec<_> = stream::iter(package_sources)
448        .map(|(name, source)| {
449            let registry = Arc::clone(&registry);
450            let fetched = Arc::clone(&fetched);
451            let failed = Arc::clone(&failed);
452            let first_error = Arc::clone(&first_error);
453            let progress_sender = progress_sender.clone();
454            let wildcard_req = &wildcard_req;
455            let in_use_versions = in_use.get(&name).cloned().unwrap_or_default();
456            async move {
457                fetch_and_classify_package(
458                    registry.as_ref(),
459                    name,
460                    source,
461                    in_use_versions,
462                    wildcard_req,
463                    freshness,
464                    timeout,
465                    minimum_stability,
466                    check_yanked,
467                    &fetched,
468                    &failed,
469                    &first_error,
470                    progress_sender.as_ref(),
471                )
472                .await
473            }
474        })
475        // `.max(1)`: defence-in-depth against a direct-field-assignment caller bypassing
476        // `with_max_concurrent_fetches`'s clamp with `0` — `buffer_unordered(0)` never
477        // polls its source stream, hanging every fetch through this document forever (#833).
478        .buffer_unordered(max_concurrent.max(1))
479        .collect()
480        .await;
481
482    let mut versions = HashMap::with_capacity(results.len());
483    let mut yanked_versions = HashMap::new();
484    let mut fetch_failed = HashMap::new();
485    let mut deprecations = HashMap::new();
486    let mut no_comparable_versions = HashSet::new();
487    let mut licenses = HashMap::new();
488    // First actionable failure in completion order — `results` is collected from
489    // `buffer_unordered`, so its order already reflects real finishing order, the same
490    // order a shared `Arc<Mutex>` written from inside each task would have observed.
491    let mut priority_error: Option<String> = None;
492    for (version, yanked, failed_name, deprecation, no_comparable_versions_name, license) in results
493    {
494        if let Some((name, v)) = version {
495            versions.insert(name, v);
496        }
497        if let Some((name, v, status)) = yanked {
498            yanked_versions.insert(name, (v, status));
499        }
500        if let Some((name, failure, message)) = failed_name {
501            fetch_failed.insert(name, failure);
502            if priority_error.is_none() {
503                priority_error = Some(message);
504            }
505        }
506        if let Some((name, d)) = deprecation {
507            deprecations.insert(name, d);
508        }
509        if let Some(name) = no_comparable_versions_name {
510            no_comparable_versions.insert(name);
511        }
512        if let Some((name, license)) = license {
513            licenses.insert(name, license);
514        }
515    }
516
517    // `priority_error` (an actual fetch failure — rate limit, timeout, outage, ...)
518    // always wins the toast over `first_error` (which may be a not-found race winner);
519    // `first_error` is the fallback only for a batch whose only failures were
520    // not-found (#480).
521    let error_message =
522        priority_error.or_else(|| first_error.lock().unwrap_or_else(|p| p.into_inner()).take());
523
524    FetchResult {
525        versions,
526        yanked_versions,
527        fetch_failed,
528        deprecations,
529        no_comparable_versions,
530        failed_count: failed.load(std::sync::atomic::Ordering::Relaxed),
531        first_error: error_message,
532        licenses,
533    }
534}
535
536/// Per-package outcome returned by [`fetch_and_classify_package`]: the resolved
537/// `(name, PackageVersions)` entry, a yanked finding, a fetch failure, a package-level
538/// deprecation finding, a name whose fetch succeeded with no comparable versions
539/// (#550), and the resolved/"latest" pick's license when the ecosystem's already-fetched
540/// version-list entries carry it (issue #660/#661 tier-1 backfill — see
541/// [`FetchResult::licenses`]) — folded into [`fetch_latest_versions_parallel`]'s
542/// aggregate `FetchResult` once every package in the stream has finished.
543///
544/// The license entry specifically comes from `select_latest_matching_with_context`'s
545/// pick below (critic S1: previously documented here as "the resolved version's
546/// license", which is wrong — this function never reads `resolved_versions` at all, it
547/// picks the latest version matching the requirement/stability floor, same as
548/// `PackageVersions.latest`).
549type PackageFetchOutcome = (
550    Option<(PackageName, PackageVersions)>,
551    Option<(PackageName, ConcreteVersion, RemovalStatus)>,
552    Option<(PackageName, FetchFailure, String)>,
553    Option<(PackageName, Deprecation)>,
554    Option<PackageName>,
555    Option<(PackageName, Vec<String>)>,
556);
557
558/// Fetches, classifies, and version-selects a single package within
559/// [`fetch_latest_versions_parallel`]'s concurrent stream: one round trip for the full
560/// version list, an in-memory "latest" pick with a `get_latest_matching_from` fallback
561/// when the list-based pick fails on a non-empty list, yanked/deprecation extraction,
562/// and updates to the shared `fetched`/`failed`/`first_error` counters the stream
563/// aggregates across every package.
564#[allow(
565    clippy::too_many_arguments,
566    reason = "mirrors the per-package async closure this was extracted from — every \
567              parameter is either call-site fetch tuning already threaded through \
568              fetch_latest_versions_parallel or a counter/sender shared across the \
569              whole stream; grouping into a struct would only move, not reduce, churn"
570)]
571async fn fetch_and_classify_package(
572    registry: &dyn Registry,
573    name: PackageName,
574    source: deps_core::parser::DependencySource,
575    in_use_versions: Vec<String>,
576    wildcard_req: &VersionReq,
577    freshness: deps_core::freshness::FreshnessSettings,
578    timeout: Duration,
579    minimum_stability: Option<&str>,
580    check_yanked: bool,
581    fetched: &std::sync::atomic::AtomicUsize,
582    failed: &std::sync::atomic::AtomicUsize,
583    first_error: &std::sync::Mutex<Option<String>>,
584    progress_sender: Option<&ProgressSender>,
585) -> PackageFetchOutcome {
586    // Single round trip: the full version list is fetched once and "latest" is a pure
587    // in-memory pick over it, no second registry call. `get_versions_from` (source-aware,
588    // spec FR-001) over `get_versions`: populates `published_at` where supported (#339) and
589    // routes a resolved `AlternateRegistry` source to its own index — zero extra cost
590    // either way for registries with no override.
591    let result = tokio::time::timeout(
592        timeout,
593        registry.get_versions_from(&name, &source, freshness),
594    )
595    .await;
596
597    let mut yanked: Option<(PackageName, ConcreteVersion, RemovalStatus)> = None;
598    let mut failed_name: Option<(PackageName, FetchFailure, String)> = None;
599    let mut deprecation: Option<(PackageName, Deprecation)> = None;
600    let mut license: Option<(PackageName, Vec<String>)> = None;
601    // Set only when the fetch (and its `get_latest_matching` fallback) both
602    // genuinely succeeded yet resolved to no version at all (#550) — see the
603    // `Ok(Ok(None))` fallback arm below.
604    let mut no_comparable_versions = false;
605    let version = match result {
606        Ok(Ok(versions)) => {
607            let available: Arc<[ConcreteVersion]> = versions
608                .iter()
609                .map(|v| v.version_string().clone())
610                .collect();
611            // Retained alongside `available` so diagnostics can flag a requirement
612            // satisfiable only by a yanked version (`PackageVersions::yanked`). Gated on
613            // `check_yanked`: a registry unable to answer `removal_status()` (§#298) must
614            // not populate this with an untrustworthy always-`Available` signal. Carries
615            // each entry's `RemovalStatus` (#437) so #247's diagnostic path can gate
616            // deprecation suppression on `AdvisoryDeprecated` specifically, not `Yanked`.
617            let yanked_list: Arc<[(ConcreteVersion, RemovalStatus)]> = if check_yanked {
618                versions
619                    .iter()
620                    .filter_map(|v| {
621                        let status = v.removal_status();
622                        status
623                            .is_flagged()
624                            .then(|| (v.version_string().clone(), status))
625                    })
626                    .collect()
627            } else {
628                Arc::from([])
629            };
630            // `.get(idx)` not `versions[idx]`: `select_latest_matching` is a public trait
631            // method, so an out-of-tree impl returning a stale index must not panic this
632            // task. `_with_context` so a registry with manifest-level stability state
633            // (Composer's `minimum-stability`, #424 S1) can apply it.
634            let resolved = if let Some(v) = registry
635                .select_latest_matching_with_context(&versions, wildcard_req, minimum_stability)
636                .and_then(|idx| versions.get(idx))
637            {
638                let latest = v.version_string().clone();
639                tracing::debug!(package = %name, version = %latest, "fetched");
640                Some((
641                    latest,
642                    v.removal_status(),
643                    v.published_at(),
644                    v.deprecation().cloned(),
645                    v.license().to_vec(),
646                ))
647            } else {
648                // The list-based pick found nothing — usually a genuine "no version", but
649                // a registry with an incomplete list endpoint (Go's `/@v/list`, which never
650                // enumerates pseudo-versions) may need the more complete `get_latest_matching`
651                // (Go's `/@latest`). Costs a second network call, only in this rare case.
652                let fallback = tokio::time::timeout(
653                    timeout,
654                    registry.get_latest_matching_from(
655                        &name,
656                        &source,
657                        wildcard_req,
658                        minimum_stability,
659                    ),
660                )
661                .await;
662                match fallback {
663                    Ok(Ok(Some(v))) => {
664                        let latest = v.version_string().clone();
665                        tracing::debug!(
666                            package = %name,
667                            version = %latest,
668                            "fetched via get_latest_matching fallback"
669                        );
670                        Some((
671                            latest,
672                            v.removal_status(),
673                            v.published_at(),
674                            v.deprecation().cloned(),
675                            v.license().to_vec(),
676                        ))
677                    }
678                    Ok(Ok(None)) => {
679                        tracing::debug!(package = %name, "no version found");
680                        // Both the list-based pick and this fallback succeeded and found
681                        // nothing — the package exists but has zero comparable versions
682                        // (#550), e.g. tags that don't parse as full semver. Distinct from
683                        // every branch below that sets `failed_name`.
684                        no_comparable_versions = true;
685                        None
686                    }
687                    Ok(Err(e)) => {
688                        tracing::warn!(
689                            package = %name,
690                            error = %e,
691                            "fetch fallback failed"
692                        );
693                        failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
694                        let mut fe = first_error.lock().unwrap_or_else(|p| p.into_inner());
695                        if fe.is_none() {
696                            *fe = Some(e.to_string());
697                        }
698                        drop(fe);
699                        // A genuine not-found (the registry was
700                        // successfully asked and said "no such
701                        // package") is not a fetch failure — only
702                        // an unanswerable request is (#267 C1).
703                        if !e.is_not_found() {
704                            failed_name = Some((name.clone(), e.fetch_failure(), e.to_string()));
705                        }
706                        None
707                    }
708                    Err(_) => {
709                        tracing::warn!(
710                            package = %name,
711                            "fetch fallback timed out ({}s)",
712                            timeout.as_secs()
713                        );
714                        failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
715                        failed_name = Some((
716                            name.clone(),
717                            FetchFailure::Transient,
718                            format!(
719                                "{name}: registry request timed out after {}s",
720                                timeout.as_secs()
721                            ),
722                        ));
723                        None
724                    }
725                }
726            };
727
728            if check_yanked {
729                // Row 1 (§4.7): the picked "latest" itself yanked — free, already in hand.
730                // Unreachable in production under today's hardcoded wildcard, but stays
731                // correct as a defense-in-depth check.
732                if let Some((latest, status, _, _, _)) = &resolved
733                    && status.is_flagged()
734                {
735                    yanked = Some((name.clone(), latest.clone(), *status));
736                }
737
738                // Row 2/3 (§4.7, revised under #206): `versions` is the full, already-fetched
739                // list — no second registry round trip needed, so this runs for every
740                // dependency with a known in-use version, not just when it differs from
741                // `latest`. A yanked in-use version wins over an already-recorded yanked
742                // `latest` since it's the version the user actually has.
743                //
744                // Multiple occurrences of the same name (#394, e.g. `[dependencies]` +
745                // `[target.*.dependencies]`) can carry different in-use versions — every one
746                // is checked so a yanked pin on any occurrence is never missed. Filters on
747                // `is_flagged()` inside `find` itself (not a separate `.filter()`) so a
748                // response with multiple entries sharing `iv`'s version string still finds a
749                // flagged one if any exists (mirrors the pre-#205 `.any` scan).
750                if let Some((iv, status)) = in_use_versions.iter().find_map(|iv| {
751                    versions
752                        .iter()
753                        .find(|v| {
754                            v.version_string() == iv.as_str() && v.removal_status().is_flagged()
755                        })
756                        .map(|v| (iv, v.removal_status()))
757                }) {
758                    yanked = Some((name.clone(), iv.as_str().into(), status));
759                }
760            }
761
762            // #205: the deprecation finding is derived from `resolved` (already picked as
763            // "latest"), covering the fallback branch too, whose `Version` isn't a member
764            // of `versions` at all — see `FetchResult::deprecations`'s doc for why this
765            // must not scan `versions` instead.
766            if let Some((_, _, _, dep_info, _)) = &resolved
767                && let Some(dep_info) = dep_info
768            {
769                deprecation = Some((name.clone(), dep_info.clone()));
770            }
771
772            // #660/#661 tier-1 backfill: extracted from `resolved` before `.map()` consumes
773            // it. Filtered here so a `Some((name, vec![]))` entry — indistinguishable from
774            // "no data" once merged into `DocumentState::licenses` — never gets inserted.
775            license = resolved
776                .as_ref()
777                .map(|(_, _, _, _, lic)| lic)
778                .filter(|lic| !lic.is_empty())
779                .map(|lic| (name.clone(), lic.clone()));
780
781            resolved.map(|(latest, _, published_at, _, _)| {
782                let mut versions = PackageVersions::new(latest, available).with_yanked(yanked_list);
783                if let Some(published_at) = published_at {
784                    versions = versions.with_published_at(published_at);
785                }
786                (name.clone(), versions)
787            })
788        }
789        Ok(Err(e)) => {
790            // Issue #483: while offline, every fetch fails by design — this
791            // would otherwise log a per-dependency WARNING for every open/edit,
792            // contradicting the toast suppression two call sites away in this
793            // same file for being "unusable".
794            if e.is_offline() {
795                tracing::debug!(package = %name, "fetch skipped: offline");
796            } else {
797                tracing::warn!(package = %name, error = %e, "fetch failed");
798            }
799            failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
800            let mut fe = first_error.lock().unwrap_or_else(|p| p.into_inner());
801            if fe.is_none() {
802                *fe = Some(e.to_string());
803            }
804            drop(fe);
805            // A genuine not-found is not a fetch failure — only an unanswerable request is
806            // (#267 C1). Marking it here would report "Registry lookup failed" for a
807            // typo'd name instead of "Unknown package", inverting the bug this fixes.
808            if !e.is_not_found() {
809                failed_name = Some((name.clone(), e.fetch_failure(), e.to_string()));
810            }
811            None
812        }
813        Err(_) => {
814            tracing::warn!(package = %name, "fetch timed out ({}s)", timeout.as_secs());
815            failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
816            failed_name = Some((
817                name.clone(),
818                FetchFailure::Transient,
819                format!(
820                    "{name}: registry request timed out after {}s",
821                    timeout.as_secs()
822                ),
823            ));
824            None
825        }
826    };
827
828    let count = fetched.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
829    if let Some(sender) = progress_sender {
830        sender.send(count);
831    }
832
833    let no_comparable_versions_name = no_comparable_versions.then(|| name.clone());
834    (
835        version,
836        yanked,
837        failed_name,
838        deprecation,
839        no_comparable_versions_name,
840        license,
841    )
842}
843
844/// Re-keys a completed fetch's yanked/fetch-failure findings from raw to normalized package
845/// names and applies them to `outcomes`.
846///
847/// Also records every collided name (two occurrences resolving to different sources,
848/// [`dedup_dependencies_by_source`]) as not-attempted.
849///
850/// `set_fetch_failure_if_absent` (not `set_fetch_failure`) for `collided_names`: a collided
851/// name normalizing to the same key as a genuine failure just recorded above must not
852/// clobber it (impl-critic M2).
853///
854/// # Examples
855///
856/// ```
857/// use deps_core::lsp_helpers::{
858///     DependencyOutcomes, DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming,
859///     PackageRendering, RequirementResolution, SourcePolicy,
860/// };
861/// use deps_core::{ConcreteVersion, PackageName, RemovalStatus};
862/// use deps_engine::classify::fetch::apply_fetch_outcomes;
863/// use std::collections::{HashMap, HashSet};
864///
865/// struct SimpleFormatter;
866/// impl PackageNaming for SimpleFormatter {}
867/// impl PackageRendering for SimpleFormatter {
868///     fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
869///         version.to_string()
870///     }
871///     fn package_url(&self, name: &PackageName) -> String {
872///         name.to_string()
873///     }
874/// }
875/// impl RequirementResolution for SimpleFormatter {}
876/// impl DiagnosticMessages for SimpleFormatter {}
877/// impl DiagnosticPolicy for SimpleFormatter {}
878/// impl SourcePolicy for SimpleFormatter {}
879/// impl OsvNaming for SimpleFormatter {}
880///
881/// let mut outcomes = DependencyOutcomes::new();
882/// let mut yanked = HashMap::new();
883/// yanked.insert(
884///     PackageName::new("time"),
885///     (ConcreteVersion::from("0.1.43"), RemovalStatus::Yanked),
886/// );
887///
888/// apply_fetch_outcomes(
889///     &mut outcomes,
890///     yanked,
891///     HashMap::new(),
892///     HashSet::new(),
893///     &SimpleFormatter,
894/// );
895///
896/// assert_eq!(
897///     outcomes.yanked("time").map(|(v, _)| v.to_string()),
898///     Some("0.1.43".to_string())
899/// );
900/// ```
901pub fn apply_fetch_outcomes(
902    outcomes: &mut deps_core::lsp_helpers::DependencyOutcomes,
903    yanked_versions: HashMap<PackageName, (ConcreteVersion, RemovalStatus)>,
904    fetch_failed: HashMap<PackageName, FetchFailure>,
905    collided_names: HashSet<PackageName>,
906    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
907) {
908    for (name, version) in yanked_versions {
909        outcomes.set_yanked(formatter.normalize_package_name(&name), version);
910    }
911    for (name, failure) in fetch_failed {
912        outcomes.set_fetch_failure(formatter.normalize_package_name(&name), failure);
913    }
914    for name in collided_names {
915        outcomes.set_fetch_failure_if_absent(
916            formatter.normalize_package_name(&name),
917            FetchFailure::NotAttempted,
918        );
919    }
920}
921
922#[cfg(test)]
923mod tests {
924    use super::*;
925    use deps_core::parser::DependencySource;
926
927    /// Pairs every name with the plain `Registry` source — the shape every pre-existing
928    /// `fetch_latest_versions_parallel` test used before that function became source-aware
929    /// (spec FR-001). Production call sites build real `(name, source)` pairs from a
930    /// parsed manifest via `dedup_dependencies_by_source` instead.
931    fn with_registry_source(names: Vec<PackageName>) -> Vec<(PackageName, DependencySource)> {
932        names
933            .into_iter()
934            .map(|name| (name, DependencySource::Registry))
935            .collect()
936    }
937
938    #[cfg(feature = "cargo")]
939    mod apply_fetch_outcomes_tests {
940        use super::*;
941        use crate::setup::CargoFormatter;
942        use deps_core::lsp_helpers::DependencyOutcomes;
943
944        #[test]
945        fn apply_fetch_outcomes_sets_yanked_re_keyed_to_normalized_name() {
946            let mut outcomes = DependencyOutcomes::new();
947            let mut yanked_versions = HashMap::new();
948            yanked_versions.insert(
949                PackageName::new("time"),
950                ("0.1.43".into(), RemovalStatus::Yanked),
951            );
952
953            apply_fetch_outcomes(
954                &mut outcomes,
955                yanked_versions,
956                HashMap::new(),
957                HashSet::new(),
958                &CargoFormatter,
959            );
960
961            assert_eq!(
962                outcomes.yanked("time"),
963                Some(&("0.1.43".into(), RemovalStatus::Yanked))
964            );
965        }
966
967        /// Loop order (yanked → fetch_failed → collided) and `set_fetch_failure_if_absent`
968        /// (impl-critic M2): a collided name that normalizes to the same key as a genuine
969        /// failure recorded just before it must not clobber that failure.
970        #[test]
971        fn apply_fetch_outcomes_collided_name_does_not_clobber_existing_fetch_failure() {
972            let mut outcomes = DependencyOutcomes::new();
973            let mut fetch_failed = HashMap::new();
974            fetch_failed.insert(PackageName::new("serde"), FetchFailure::Transient);
975            let mut collided_names = HashSet::new();
976            collided_names.insert(PackageName::new("serde"));
977
978            apply_fetch_outcomes(
979                &mut outcomes,
980                HashMap::new(),
981                fetch_failed,
982                collided_names,
983                &CargoFormatter,
984            );
985
986            assert_eq!(
987                outcomes.fetch_failure("serde"),
988                Some(&FetchFailure::Transient),
989                "a genuine fetch failure must survive a collided name normalizing to the same key"
990            );
991        }
992
993        /// The other half of the precedence rule: a collided name with no pre-existing
994        /// failure under its normalized key must still be recorded as not-attempted.
995        #[test]
996        fn apply_fetch_outcomes_collided_name_alone_is_recorded_as_not_attempted() {
997            let mut outcomes = DependencyOutcomes::new();
998            let mut collided_names = HashSet::new();
999            collided_names.insert(PackageName::new("serde"));
1000
1001            apply_fetch_outcomes(
1002                &mut outcomes,
1003                HashMap::new(),
1004                HashMap::new(),
1005                collided_names,
1006                &CargoFormatter,
1007            );
1008
1009            assert_eq!(
1010                outcomes.fetch_failure("serde"),
1011                Some(&FetchFailure::NotAttempted)
1012            );
1013        }
1014    }
1015
1016    mod dedup_by_source_collision_tests {
1017        use super::*;
1018        use deps_core::Dependency;
1019        use deps_core::lsp_helpers::{
1020            DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
1021            RequirementResolution, SourcePolicy,
1022        };
1023        use deps_core::position::{Position, Range};
1024        use std::any::Any;
1025
1026        /// Unlike the real `CargoFormatter`, treats *both* `Registry` and
1027        /// `AlternateRegistry` as resolvable — needed so two distinct source values can
1028        /// both pass gate 1 (resolvability) and reach gate 2 (collision) in the same test.
1029        struct AlternateAwareFormatter;
1030        impl PackageNaming for AlternateAwareFormatter {}
1031
1032        impl PackageRendering for AlternateAwareFormatter {
1033            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
1034                version.to_string()
1035            }
1036
1037            fn package_url(&self, name: &PackageName) -> String {
1038                format!("https://example.com/{name}")
1039            }
1040        }
1041
1042        impl RequirementResolution for AlternateAwareFormatter {}
1043
1044        impl DiagnosticMessages for AlternateAwareFormatter {}
1045
1046        impl DiagnosticPolicy for AlternateAwareFormatter {}
1047
1048        impl SourcePolicy for AlternateAwareFormatter {
1049            fn can_resolve_source(&self, source: &DependencySource) -> bool {
1050                matches!(
1051                    source,
1052                    DependencySource::Registry | DependencySource::AlternateRegistry { .. }
1053                )
1054            }
1055        }
1056
1057        impl OsvNaming for AlternateAwareFormatter {}
1058
1059        struct MockDep {
1060            name: PackageName,
1061            source: DependencySource,
1062            addr_tag: u32,
1063        }
1064
1065        impl Dependency for MockDep {
1066            fn name(&self) -> &PackageName {
1067                &self.name
1068            }
1069            fn name_range(&self) -> Range {
1070                Range::new(
1071                    Position::new(0, self.addr_tag),
1072                    Position::new(0, self.addr_tag + 1),
1073                )
1074            }
1075            fn version_requirement(&self) -> Option<&VersionReq> {
1076                None
1077            }
1078            fn version_range(&self) -> Option<Range> {
1079                None
1080            }
1081            fn source(&self) -> DependencySource {
1082                self.source.clone()
1083            }
1084            fn as_any(&self) -> &dyn Any {
1085                self
1086            }
1087        }
1088
1089        struct MockParseResult {
1090            deps: Vec<MockDep>,
1091        }
1092
1093        impl deps_core::ParseResult for MockParseResult {
1094            fn dependencies(&self) -> Vec<&dyn Dependency> {
1095                self.deps.iter().map(|d| d as &dyn Dependency).collect()
1096            }
1097            fn workspace_root(&self) -> Option<&std::path::Path> {
1098                None
1099            }
1100            fn uri(&self) -> &url::Url {
1101                static URI: std::sync::OnceLock<url::Url> = std::sync::OnceLock::new();
1102                URI.get_or_init(|| deps_core::test_util::test_uri("/test/Cargo.toml"))
1103            }
1104            fn as_any(&self) -> &dyn Any {
1105                self
1106            }
1107        }
1108
1109        #[test]
1110        fn test_two_different_resolvable_sources_collide_and_are_dropped() {
1111            let parse_result = MockParseResult {
1112                deps: vec![
1113                    MockDep {
1114                        name: PackageName::new("shared-name"),
1115                        source: DependencySource::Registry,
1116                        addr_tag: 0,
1117                    },
1118                    MockDep {
1119                        name: PackageName::new("shared-name"),
1120                        source: DependencySource::AlternateRegistry {
1121                            index: "https://index.mycorp.dev".into(),
1122                            mirrors_crates_io: false,
1123                        },
1124                        addr_tag: 1,
1125                    },
1126                ],
1127            };
1128
1129            let (sources, collided) =
1130                dedup_dependencies_by_source(&parse_result, &AlternateAwareFormatter);
1131
1132            assert!(
1133                !sources.contains_key(&PackageName::new("shared-name")),
1134                "a colliding name must not be fetched under either source"
1135            );
1136            assert!(
1137                collided.contains(&PackageName::new("shared-name")),
1138                "the collision must be recorded so the caller can mark it fetch_failed"
1139            );
1140        }
1141
1142        #[test]
1143        fn test_identical_sources_do_not_collide() {
1144            let parse_result = MockParseResult {
1145                deps: vec![
1146                    MockDep {
1147                        name: PackageName::new("shared-name"),
1148                        source: DependencySource::Registry,
1149                        addr_tag: 0,
1150                    },
1151                    MockDep {
1152                        name: PackageName::new("shared-name"),
1153                        source: DependencySource::Registry,
1154                        addr_tag: 1,
1155                    },
1156                ],
1157            };
1158
1159            let (sources, collided) =
1160                dedup_dependencies_by_source(&parse_result, &AlternateAwareFormatter);
1161
1162            assert!(collided.is_empty());
1163            assert_eq!(
1164                sources.get(&PackageName::new("shared-name")),
1165                Some(&DependencySource::Registry)
1166            );
1167        }
1168
1169        /// Gate 1 (Critical review finding #1): a non-resolvable source is dropped
1170        /// entirely, never reaching the fetch — this is what prevents a Git/Path
1171        /// dependency's name from being looked up against the ecosystem's default
1172        /// registry via the background fetch's routing default arm.
1173        #[test]
1174        fn test_non_resolvable_source_is_dropped_not_fetched() {
1175            let parse_result = MockParseResult {
1176                deps: vec![MockDep {
1177                    name: PackageName::new("local-fork"),
1178                    source: DependencySource::Path {
1179                        path: "../local-fork".into(),
1180                    },
1181                    addr_tag: 0,
1182                }],
1183            };
1184
1185            let (sources, collided) =
1186                dedup_dependencies_by_source(&parse_result, &AlternateAwareFormatter);
1187
1188            assert!(sources.is_empty());
1189            assert!(collided.is_empty());
1190        }
1191
1192        /// #935/#936 sink-level regression test, mirroring the repo's precedent for this bug
1193        /// class (`deps-cargo/src/parser.rs`'s `test_parse_registry_index_env_collision_...`
1194        /// tests, and cache.rs #756): pinning the type-level fix (`DependencySource`'s
1195        /// hand-written `Debug`) alone leaves the actual `tracing::warn!(?source, ...)` call
1196        /// site in this function untested. Two `AlternateRegistry` sources, each carrying a
1197        /// distinct query-string credential, collide — this is the exact `tracing::warn!`
1198        /// this module emits with `source_a`/`source_b` via `?` (Debug) formatting.
1199        #[test]
1200        fn test_collision_warning_redacts_credentials_in_alternate_registry_debug_output() {
1201            let parse_result = MockParseResult {
1202                deps: vec![
1203                    MockDep {
1204                        name: PackageName::new("shared-name"),
1205                        source: DependencySource::AlternateRegistry {
1206                            index: "https://index-a.mycorp.dev/api?api_key=SECRET_A".into(),
1207                            mirrors_crates_io: false,
1208                        },
1209                        addr_tag: 0,
1210                    },
1211                    MockDep {
1212                        name: PackageName::new("shared-name"),
1213                        source: DependencySource::AlternateRegistry {
1214                            index: "https://index-b.mycorp.dev/api?api_key=SECRET_B".into(),
1215                            mirrors_crates_io: false,
1216                        },
1217                        addr_tag: 1,
1218                    },
1219                ],
1220            };
1221
1222            let log = deps_core::test_util::capture_tracing_output(|| {
1223                let (sources, collided) =
1224                    dedup_dependencies_by_source(&parse_result, &AlternateAwareFormatter);
1225                assert!(!sources.contains_key(&PackageName::new("shared-name")));
1226                assert!(collided.contains(&PackageName::new("shared-name")));
1227            });
1228
1229            assert!(
1230                log.contains("two different resolved registries"),
1231                "expected the collision WARN to fire: {log:?}"
1232            );
1233            assert!(
1234                !log.contains("SECRET_A") && !log.contains("SECRET_B"),
1235                "tracing output leaked a query-string credential: {log:?}"
1236            );
1237            assert!(
1238                log.contains("index-a.mycorp.dev") && log.contains("index-b.mycorp.dev"),
1239                "host should survive redaction: {log:?}"
1240            );
1241        }
1242    }
1243
1244    #[tokio::test]
1245    async fn test_fetch_latest_versions_parallel_with_timeout() {
1246        use deps_core::{Metadata, Registry, Version};
1247        use std::any::Any;
1248        use std::time::Duration;
1249
1250        struct TimeoutRegistry;
1251
1252        impl Registry for TimeoutRegistry {
1253            fn get_versions<'a>(
1254                &'a self,
1255                _name: &'a deps_core::PackageName,
1256            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1257            {
1258                Box::pin(async move {
1259                    tokio::time::sleep(Duration::from_secs(10)).await;
1260                    Ok(vec![])
1261                })
1262            }
1263
1264            fn get_latest_matching<'a>(
1265                &'a self,
1266                _name: &'a deps_core::PackageName,
1267                _req: &'a deps_core::VersionReq,
1268            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1269            {
1270                Box::pin(async move {
1271                    tokio::time::sleep(Duration::from_secs(10)).await;
1272                    Ok(None)
1273                })
1274            }
1275
1276            fn search<'a>(
1277                &'a self,
1278                _query: &'a str,
1279                _limit: usize,
1280            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1281            {
1282                Box::pin(async move { Ok(vec![]) })
1283            }
1284
1285            fn as_any(&self) -> &dyn Any {
1286                self
1287            }
1288        }
1289
1290        let registry: Arc<dyn Registry> = Arc::new(TimeoutRegistry);
1291        let packages = vec![PackageName::new("slow-package")];
1292
1293        let result = fetch_latest_versions_parallel(
1294            registry,
1295            with_registry_source(packages),
1296            &HashMap::new(),
1297            None,
1298            deps_core::freshness::FreshnessSettings::default(),
1299            1,
1300            10,
1301            None,
1302        )
1303        .await;
1304
1305        assert!(result.versions.is_empty(), "Slow package should timeout");
1306        assert_eq!(result.failed_count, 1, "Should track 1 failed package");
1307        // #267: a timeout is also a fetch failure, not a "not found" — must
1308        // be recorded the same way as a hard registry error.
1309        assert_eq!(
1310            result.fetch_failed,
1311            HashMap::from([(PackageName::new("slow-package"), FetchFailure::Transient)]),
1312            "timed-out package must be recorded in fetch_failed"
1313        );
1314    }
1315
1316    #[tokio::test]
1317    async fn test_fetch_latest_versions_parallel_fast_packages_not_blocked() {
1318        use deps_core::{Metadata, Registry, Version};
1319        use std::any::Any;
1320        use std::time::Duration;
1321
1322        struct MixedRegistry;
1323
1324        impl Registry for MixedRegistry {
1325            fn get_versions<'a>(
1326                &'a self,
1327                name: &'a deps_core::PackageName,
1328            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1329            {
1330                Box::pin(async move {
1331                    if name == "slow-package" {
1332                        tokio::time::sleep(Duration::from_secs(10)).await;
1333                    }
1334                    Ok(vec![])
1335                })
1336            }
1337
1338            fn get_latest_matching<'a>(
1339                &'a self,
1340                name: &'a deps_core::PackageName,
1341                _req: &'a deps_core::VersionReq,
1342            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1343            {
1344                Box::pin(async move {
1345                    if name == "slow-package" {
1346                        tokio::time::sleep(Duration::from_secs(10)).await;
1347                    }
1348                    Ok(None)
1349                })
1350            }
1351
1352            fn search<'a>(
1353                &'a self,
1354                _query: &'a str,
1355                _limit: usize,
1356            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1357            {
1358                Box::pin(async move { Ok(vec![]) })
1359            }
1360
1361            fn as_any(&self) -> &dyn Any {
1362                self
1363            }
1364        }
1365
1366        let registry: Arc<dyn Registry> = Arc::new(MixedRegistry);
1367        let packages = vec![
1368            PackageName::new("slow-package"),
1369            PackageName::new("fast-package"),
1370        ];
1371
1372        let start = std::time::Instant::now();
1373        let result = fetch_latest_versions_parallel(
1374            registry,
1375            with_registry_source(packages),
1376            &HashMap::new(),
1377            None,
1378            deps_core::freshness::FreshnessSettings::default(),
1379            1,
1380            10,
1381            None,
1382        )
1383        .await;
1384        let elapsed = start.elapsed();
1385
1386        assert!(
1387            elapsed < Duration::from_secs(3),
1388            "Should not wait for slow package: {:?}",
1389            elapsed
1390        );
1391
1392        assert!(
1393            result.versions.is_empty(),
1394            "No versions returned (test registry returns empty)"
1395        );
1396        assert_eq!(
1397            result.failed_count, 1,
1398            "Slow package should be marked as failed"
1399        );
1400    }
1401
1402    #[tokio::test]
1403    async fn test_fetch_latest_versions_parallel_concurrency_limit() {
1404        use deps_core::{Metadata, Registry, Version};
1405        use std::any::Any;
1406        use std::sync::atomic::{AtomicUsize, Ordering};
1407        use std::time::Duration;
1408
1409        struct ConcurrencyTrackingRegistry {
1410            current: Arc<AtomicUsize>,
1411            max_seen: Arc<AtomicUsize>,
1412        }
1413
1414        impl Registry for ConcurrencyTrackingRegistry {
1415            fn get_versions<'a>(
1416                &'a self,
1417                _name: &'a deps_core::PackageName,
1418            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1419            {
1420                Box::pin(async move {
1421                    let current = self.current.fetch_add(1, Ordering::SeqCst) + 1;
1422                    self.max_seen.fetch_max(current, Ordering::SeqCst);
1423                    tokio::time::sleep(Duration::from_millis(50)).await;
1424                    self.current.fetch_sub(1, Ordering::SeqCst);
1425
1426                    Ok(vec![])
1427                })
1428            }
1429
1430            fn get_latest_matching<'a>(
1431                &'a self,
1432                _name: &'a deps_core::PackageName,
1433                _req: &'a deps_core::VersionReq,
1434            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1435            {
1436                Box::pin(async move {
1437                    let current = self.current.fetch_add(1, Ordering::SeqCst) + 1;
1438                    self.max_seen.fetch_max(current, Ordering::SeqCst);
1439                    tokio::time::sleep(Duration::from_millis(50)).await;
1440                    self.current.fetch_sub(1, Ordering::SeqCst);
1441
1442                    Ok(None)
1443                })
1444            }
1445
1446            fn search<'a>(
1447                &'a self,
1448                _query: &'a str,
1449                _limit: usize,
1450            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1451            {
1452                Box::pin(async move { Ok(vec![]) })
1453            }
1454
1455            fn as_any(&self) -> &dyn Any {
1456                self
1457            }
1458        }
1459
1460        let current = Arc::new(AtomicUsize::new(0));
1461        let max_seen = Arc::new(AtomicUsize::new(0));
1462
1463        let registry: Arc<dyn Registry> = Arc::new(ConcurrencyTrackingRegistry {
1464            current: Arc::clone(&current),
1465            max_seen: Arc::clone(&max_seen),
1466        });
1467
1468        let packages: Vec<PackageName> = (0..50)
1469            .map(|i| PackageName::new(format!("package-{}", i)))
1470            .collect();
1471
1472        fetch_latest_versions_parallel(
1473            registry,
1474            with_registry_source(packages),
1475            &HashMap::new(),
1476            None,
1477            deps_core::freshness::FreshnessSettings::default(),
1478            5,
1479            20,
1480            None,
1481        )
1482        .await;
1483
1484        // +2 margin for timing noise around the limit of 20.
1485        let max = max_seen.load(Ordering::SeqCst);
1486        assert!(
1487            max <= 22,
1488            "Concurrency limit violated: {} concurrent requests (limit: 20)",
1489            max
1490        );
1491    }
1492
1493    /// Regression test for issue #833: `buffer_unordered(0)` never polls its source
1494    /// stream and returns `Pending` forever, so a `max_concurrent` of `0` reaching this
1495    /// call previously hung the fetch indefinitely instead of completing or erroring.
1496    /// Wrapped in a short outer timeout so a regression fails fast instead of hanging
1497    /// the test suite.
1498    #[tokio::test]
1499    async fn test_fetch_latest_versions_parallel_zero_max_concurrent_still_completes() {
1500        use deps_core::{Metadata, Registry, Version};
1501        use std::any::Any;
1502
1503        struct InstantRegistry;
1504
1505        impl Registry for InstantRegistry {
1506            fn get_versions<'a>(
1507                &'a self,
1508                _name: &'a deps_core::PackageName,
1509            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1510            {
1511                Box::pin(async move { Ok(vec![]) })
1512            }
1513
1514            fn get_latest_matching<'a>(
1515                &'a self,
1516                _name: &'a deps_core::PackageName,
1517                _req: &'a deps_core::VersionReq,
1518            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1519            {
1520                Box::pin(async move { Ok(None) })
1521            }
1522
1523            fn search<'a>(
1524                &'a self,
1525                _query: &'a str,
1526                _limit: usize,
1527            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1528            {
1529                Box::pin(async move { Ok(vec![]) })
1530            }
1531
1532            fn as_any(&self) -> &dyn Any {
1533                self
1534            }
1535        }
1536
1537        let registry: Arc<dyn Registry> = Arc::new(InstantRegistry);
1538        let packages = vec![PackageName::new("some-package")];
1539
1540        let result = tokio::time::timeout(
1541            std::time::Duration::from_secs(5),
1542            fetch_latest_versions_parallel(
1543                registry,
1544                with_registry_source(packages),
1545                &HashMap::new(),
1546                None,
1547                deps_core::freshness::FreshnessSettings::default(),
1548                5,
1549                0,
1550                None,
1551            ),
1552        )
1553        .await
1554        .expect("fetch with max_concurrent=0 must not hang forever");
1555
1556        assert!(
1557            result
1558                .no_comparable_versions
1559                .contains(&PackageName::new("some-package")),
1560            "fetch must still run to completion when max_concurrent is 0"
1561        );
1562    }
1563
1564    #[tokio::test]
1565    async fn test_fetch_partial_success_with_mixed_outcomes() {
1566        use deps_core::{Metadata, Registry, Version};
1567        use std::any::Any;
1568        use std::time::Duration;
1569
1570        #[derive(Debug)]
1571        struct MockVersion {
1572            version: ConcreteVersion,
1573        }
1574
1575        impl Version for MockVersion {
1576            fn version_string(&self) -> &ConcreteVersion {
1577                &self.version
1578            }
1579
1580            fn is_prerelease(&self) -> bool {
1581                false
1582            }
1583
1584            fn as_any(&self) -> &dyn Any {
1585                self
1586            }
1587        }
1588
1589        struct MixedOutcomeRegistry;
1590
1591        impl Registry for MixedOutcomeRegistry {
1592            fn get_versions<'a>(
1593                &'a self,
1594                name: &'a deps_core::PackageName,
1595            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1596            {
1597                Box::pin(async move {
1598                    match name.as_str() {
1599                        "package-fast" => Ok(vec![Box::new(MockVersion {
1600                            version: "1.0.0".into(),
1601                        }) as Box<dyn Version>]),
1602                        "package-slow" => {
1603                            tokio::time::sleep(Duration::from_secs(10)).await;
1604                            Ok(vec![])
1605                        }
1606                        "package-error" => Err(deps_core::error::DepsError::CacheError(
1607                            "Mock registry error".to_string(),
1608                        )),
1609                        _ => Ok(vec![]),
1610                    }
1611                })
1612            }
1613
1614            fn get_latest_matching<'a>(
1615                &'a self,
1616                name: &'a deps_core::PackageName,
1617                _req: &'a deps_core::VersionReq,
1618            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1619            {
1620                Box::pin(async move {
1621                    match name.as_str() {
1622                        "package-fast" => Ok(Some(Box::new(MockVersion {
1623                            version: "1.0.0".into(),
1624                        }) as Box<dyn Version>)),
1625                        "package-slow" => {
1626                            tokio::time::sleep(Duration::from_secs(10)).await;
1627                            Ok(None)
1628                        }
1629                        "package-error" => Err(deps_core::error::DepsError::CacheError(
1630                            "Mock registry error".to_string(),
1631                        )),
1632                        _ => Ok(None),
1633                    }
1634                })
1635            }
1636
1637            fn search<'a>(
1638                &'a self,
1639                _query: &'a str,
1640                _limit: usize,
1641            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1642            {
1643                Box::pin(async move { Ok(vec![]) })
1644            }
1645
1646            fn select_latest_matching(
1647                &self,
1648                versions: &[Box<dyn Version>],
1649                _req: &deps_core::VersionReq,
1650            ) -> Option<usize> {
1651                // The fetch loop derives "latest" from `get_versions` via this method, not
1652                // `get_latest_matching` — must override it (not rely on the `None` default)
1653                // to keep exercising "package-fast" as a successful fetch.
1654                if versions.is_empty() { None } else { Some(0) }
1655            }
1656
1657            fn as_any(&self) -> &dyn Any {
1658                self
1659            }
1660        }
1661
1662        let registry: Arc<dyn Registry> = Arc::new(MixedOutcomeRegistry);
1663        let packages = vec![
1664            PackageName::new("package-fast"),
1665            PackageName::new("package-slow"),
1666            PackageName::new("package-error"),
1667        ];
1668
1669        let result = fetch_latest_versions_parallel(
1670            registry,
1671            with_registry_source(packages),
1672            &HashMap::new(),
1673            None,
1674            deps_core::freshness::FreshnessSettings::default(),
1675            1,
1676            10,
1677            None,
1678        )
1679        .await;
1680
1681        assert_eq!(
1682            result.versions.len(),
1683            1,
1684            "Should have exactly 1 successful package"
1685        );
1686        assert_eq!(
1687            result
1688                .versions
1689                .get("package-fast")
1690                .map(|v| v.latest.as_str()),
1691            Some("1.0.0"),
1692            "Fast package should have correct version"
1693        );
1694        assert!(
1695            !result.versions.contains_key("package-slow"),
1696            "Slow package should not be in results (timeout)"
1697        );
1698        assert!(
1699            !result.versions.contains_key("package-error"),
1700            "Error package should not be in results"
1701        );
1702    }
1703
1704    /// Issue #247: the per-version yanked flag from `get_versions` must survive into
1705    /// `PackageVersions.yanked`, not be discarded — this is what lets
1706    /// `generate_diagnostics_from_cache` (via `requirement_matches_only_yanked`) detect a
1707    /// requirement that is satisfiable only by a yanked version.
1708    #[tokio::test]
1709    async fn test_fetch_latest_versions_parallel_carries_yanked_flag_into_cache() {
1710        use deps_core::{Metadata, Registry, Version};
1711        use std::any::Any;
1712
1713        #[derive(Debug)]
1714        struct MockVersion {
1715            version: ConcreteVersion,
1716            yanked: bool,
1717        }
1718
1719        impl Version for MockVersion {
1720            fn version_string(&self) -> &ConcreteVersion {
1721                &self.version
1722            }
1723            fn removal_status(&self) -> deps_core::RemovalStatus {
1724                deps_core::RemovalStatus::from_yanked(self.yanked)
1725            }
1726            fn as_any(&self) -> &dyn Any {
1727                self
1728            }
1729        }
1730
1731        struct YankedRegistry;
1732
1733        impl Registry for YankedRegistry {
1734            fn get_versions<'a>(
1735                &'a self,
1736                _name: &'a deps_core::PackageName,
1737            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1738            {
1739                Box::pin(async move {
1740                    Ok(vec![
1741                        Box::new(MockVersion {
1742                            version: "1.0.214".into(),
1743                            yanked: false,
1744                        }) as Box<dyn Version>,
1745                        Box::new(MockVersion {
1746                            version: "1.0.213".into(),
1747                            yanked: true,
1748                        }) as Box<dyn Version>,
1749                    ])
1750                })
1751            }
1752
1753            fn get_latest_matching<'a>(
1754                &'a self,
1755                _name: &'a deps_core::PackageName,
1756                _req: &'a deps_core::VersionReq,
1757            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1758            {
1759                Box::pin(async move { Ok(None) })
1760            }
1761
1762            fn search<'a>(
1763                &'a self,
1764                _query: &'a str,
1765                _limit: usize,
1766            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1767            {
1768                Box::pin(async move { Ok(vec![]) })
1769            }
1770
1771            fn select_latest_matching(
1772                &self,
1773                versions: &[Box<dyn Version>],
1774                _req: &deps_core::VersionReq,
1775            ) -> Option<usize> {
1776                versions
1777                    .iter()
1778                    .position(|v| !v.removal_status().blocks_resolution())
1779            }
1780
1781            fn as_any(&self) -> &dyn Any {
1782                self
1783            }
1784        }
1785
1786        let registry: Arc<dyn Registry> = Arc::new(YankedRegistry);
1787        let packages = vec![PackageName::new("serde")];
1788
1789        let result = fetch_latest_versions_parallel(
1790            registry,
1791            with_registry_source(packages),
1792            &HashMap::new(),
1793            None,
1794            deps_core::freshness::FreshnessSettings::default(),
1795            10,
1796            10,
1797            None,
1798        )
1799        .await;
1800
1801        let serde = result
1802            .versions
1803            .get("serde")
1804            .expect("serde should be fetched");
1805        assert_eq!(serde.latest, "1.0.214", "latest must skip the yanked entry");
1806        assert_eq!(
1807            &*serde.available,
1808            &[
1809                ConcreteVersion::new("1.0.214"),
1810                ConcreteVersion::new("1.0.213")
1811            ],
1812            "available must remain unfiltered"
1813        );
1814        assert_eq!(
1815            &*serde.yanked,
1816            &[(
1817                ConcreteVersion::new("1.0.213"),
1818                deps_core::RemovalStatus::Yanked
1819            )],
1820            "yanked must carry only the entries reported as yanked, paired with their status"
1821        );
1822    }
1823
1824    /// Issue #227 C3: `PackageVersions.published_at` must be the publish time of
1825    /// `latest` specifically, not of some other entry in `available` — a risk the old
1826    /// two-parallel-map design (a separate `HashMap<String, PublishTime>` alongside the
1827    /// version map) could not structurally rule out. Bundling `published_at` onto the
1828    /// same struct as `latest`/`available`/`yanked` makes that desync impossible: both
1829    /// are set from the same `Box<dyn Version>` in the same match arm.
1830    #[tokio::test]
1831    async fn test_fetch_latest_versions_parallel_carries_published_at_for_latest_only() {
1832        use deps_core::freshness::PublishTime;
1833        use deps_core::{Metadata, Registry, Version};
1834        use std::any::Any;
1835
1836        #[derive(Debug)]
1837        struct MockVersion {
1838            version: ConcreteVersion,
1839            yanked: bool,
1840            published_at: Option<PublishTime>,
1841        }
1842
1843        impl Version for MockVersion {
1844            fn version_string(&self) -> &ConcreteVersion {
1845                &self.version
1846            }
1847            fn removal_status(&self) -> deps_core::RemovalStatus {
1848                deps_core::RemovalStatus::from_yanked(self.yanked)
1849            }
1850            fn published_at(&self) -> Option<PublishTime> {
1851                self.published_at
1852            }
1853            fn as_any(&self) -> &dyn Any {
1854                self
1855            }
1856        }
1857
1858        struct DatedRegistry;
1859
1860        impl Registry for DatedRegistry {
1861            fn get_versions<'a>(
1862                &'a self,
1863                _name: &'a deps_core::PackageName,
1864            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1865            {
1866                Box::pin(async move {
1867                    Ok(vec![
1868                        Box::new(MockVersion {
1869                            version: "1.0.214".into(),
1870                            yanked: false,
1871                            published_at: Some(PublishTime::from_unix_secs(2_000)),
1872                        }) as Box<dyn Version>,
1873                        Box::new(MockVersion {
1874                            version: "1.0.213".into(),
1875                            yanked: true,
1876                            // Deliberately a different timestamp — proves the fetch loop
1877                            // never accidentally attaches this entry's age to `latest`.
1878                            published_at: Some(PublishTime::from_unix_secs(1_000)),
1879                        }) as Box<dyn Version>,
1880                    ])
1881                })
1882            }
1883
1884            fn get_latest_matching<'a>(
1885                &'a self,
1886                _name: &'a deps_core::PackageName,
1887                _req: &'a deps_core::VersionReq,
1888            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1889            {
1890                Box::pin(async move { Ok(None) })
1891            }
1892
1893            fn search<'a>(
1894                &'a self,
1895                _query: &'a str,
1896                _limit: usize,
1897            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1898            {
1899                Box::pin(async move { Ok(vec![]) })
1900            }
1901
1902            fn select_latest_matching(
1903                &self,
1904                versions: &[Box<dyn Version>],
1905                _req: &deps_core::VersionReq,
1906            ) -> Option<usize> {
1907                versions
1908                    .iter()
1909                    .position(|v| !v.removal_status().blocks_resolution())
1910            }
1911
1912            fn as_any(&self) -> &dyn Any {
1913                self
1914            }
1915        }
1916
1917        let registry: Arc<dyn Registry> = Arc::new(DatedRegistry);
1918        let packages = vec![PackageName::new("serde")];
1919
1920        let result = fetch_latest_versions_parallel(
1921            registry,
1922            with_registry_source(packages),
1923            &HashMap::new(),
1924            None,
1925            deps_core::freshness::FreshnessSettings::default(),
1926            10,
1927            10,
1928            None,
1929        )
1930        .await;
1931
1932        let serde = result
1933            .versions
1934            .get("serde")
1935            .expect("serde should be fetched");
1936        assert_eq!(serde.latest, "1.0.214");
1937        assert_eq!(
1938            serde.published_at,
1939            Some(PublishTime::from_unix_secs(2_000)),
1940            "published_at must be 1.0.214's own timestamp, not the yanked 1.0.213 entry's"
1941        );
1942    }
1943
1944    /// Issue #660/#661 tier-1 backfill: a `Version::license()` override on the
1945    /// already-fetched version-list entry (today, only Composer's `impl_version!`
1946    /// includes one — `deps-composer/src/types.rs`) must flow into
1947    /// `FetchResult::licenses`, keyed by package name — this is what
1948    /// `merge_registry_fetch_result` then merges into `DocumentState::licenses`,
1949    /// letting #661's policy diagnostics see it without a second, ecosystem-specific
1950    /// fetch. An empty `license()` (every other ecosystem's default) must produce no
1951    /// entry at all, not an empty-vec one — `merge_licenses` relies on this to never
1952    /// accidentally overwrite real data with a spurious empty entry.
1953    #[tokio::test]
1954    async fn test_fetch_latest_versions_parallel_carries_license_into_fetch_result() {
1955        use deps_core::{Metadata, Registry, Version};
1956        use std::any::Any;
1957
1958        #[derive(Debug)]
1959        struct MockVersion {
1960            version: ConcreteVersion,
1961            license: Vec<String>,
1962        }
1963
1964        impl Version for MockVersion {
1965            fn version_string(&self) -> &ConcreteVersion {
1966                &self.version
1967            }
1968            fn as_any(&self) -> &dyn Any {
1969                self
1970            }
1971            fn license(&self) -> &[String] {
1972                &self.license
1973            }
1974        }
1975
1976        struct LicensedRegistry;
1977
1978        impl Registry for LicensedRegistry {
1979            fn get_versions<'a>(
1980                &'a self,
1981                name: &'a PackageName,
1982            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1983            {
1984                let license = if name.as_str() == "licensed-pkg" {
1985                    vec!["MIT".to_string()]
1986                } else {
1987                    vec![]
1988                };
1989                Box::pin(async move {
1990                    Ok(vec![Box::new(MockVersion {
1991                        version: "1.0.0".into(),
1992                        license,
1993                    }) as Box<dyn Version>])
1994                })
1995            }
1996
1997            fn get_latest_matching<'a>(
1998                &'a self,
1999                _name: &'a PackageName,
2000                _req: &'a deps_core::VersionReq,
2001            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2002            {
2003                Box::pin(async move { Ok(None) })
2004            }
2005
2006            fn search<'a>(
2007                &'a self,
2008                _query: &'a str,
2009                _limit: usize,
2010            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2011            {
2012                Box::pin(async move { Ok(vec![]) })
2013            }
2014
2015            fn select_latest_matching(
2016                &self,
2017                versions: &[Box<dyn Version>],
2018                _req: &deps_core::VersionReq,
2019            ) -> Option<usize> {
2020                (!versions.is_empty()).then_some(0)
2021            }
2022
2023            fn as_any(&self) -> &dyn Any {
2024                self
2025            }
2026        }
2027
2028        let registry: Arc<dyn Registry> = Arc::new(LicensedRegistry);
2029        let packages = vec![
2030            PackageName::new("licensed-pkg"),
2031            PackageName::new("unlicensed-pkg"),
2032        ];
2033
2034        let result = fetch_latest_versions_parallel(
2035            registry,
2036            with_registry_source(packages),
2037            &HashMap::new(),
2038            None,
2039            deps_core::freshness::FreshnessSettings::default(),
2040            10,
2041            10,
2042            None,
2043        )
2044        .await;
2045
2046        assert_eq!(
2047            result.licenses.get(&PackageName::new("licensed-pkg")),
2048            Some(&vec!["MIT".to_string()])
2049        );
2050        assert!(
2051            !result
2052                .licenses
2053                .contains_key(&PackageName::new("unlicensed-pkg")),
2054            "an empty Version::license() must produce no entry, not an empty-vec one"
2055        );
2056    }
2057
2058    /// #339 regression guard: the bulk diagnostics-cache-population pass must call the
2059    /// freshness-aware `Registry::get_versions_with`, not the freshness-blind `get_versions`,
2060    /// for a registry that implements the override — otherwise `published_at` (and the
2061    /// cooldown-context diagnostic message it drives) is silently always `None` in
2062    /// production even though hover's separate call path gets it right.
2063    #[tokio::test]
2064    async fn test_fetch_latest_versions_parallel_uses_get_versions_with_for_freshness() {
2065        use deps_core::freshness::{FreshnessSettings, PublishTime};
2066        use deps_core::{Metadata, Registry, Version};
2067        use std::any::Any;
2068
2069        #[derive(Debug)]
2070        struct MockVersion {
2071            version: ConcreteVersion,
2072            published_at: Option<PublishTime>,
2073        }
2074
2075        impl Version for MockVersion {
2076            fn version_string(&self) -> &ConcreteVersion {
2077                &self.version
2078            }
2079            fn published_at(&self) -> Option<PublishTime> {
2080                self.published_at
2081            }
2082            fn as_any(&self) -> &dyn Any {
2083                self
2084            }
2085        }
2086
2087        struct FreshnessAwareRegistry;
2088
2089        impl Registry for FreshnessAwareRegistry {
2090            fn get_versions<'a>(
2091                &'a self,
2092                _name: &'a deps_core::PackageName,
2093            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2094            {
2095                // Deliberately returns no `published_at` — if the fetch loop ever calls
2096                // this instead of `get_versions_with`, the assertion below catches it.
2097                Box::pin(async move {
2098                    Ok(vec![Box::new(MockVersion {
2099                        version: "1.0.0".into(),
2100                        published_at: None,
2101                    }) as Box<dyn Version>])
2102                })
2103            }
2104
2105            fn get_versions_with<'a>(
2106                &'a self,
2107                _name: &'a deps_core::PackageName,
2108                freshness: FreshnessSettings,
2109            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2110            {
2111                Box::pin(async move {
2112                    Ok(vec![Box::new(MockVersion {
2113                        version: "1.0.0".into(),
2114                        published_at: freshness
2115                            .enabled
2116                            .then(|| PublishTime::from_unix_secs(5_000)),
2117                    }) as Box<dyn Version>])
2118                })
2119            }
2120
2121            fn get_latest_matching<'a>(
2122                &'a self,
2123                _name: &'a deps_core::PackageName,
2124                _req: &'a deps_core::VersionReq,
2125            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2126            {
2127                Box::pin(async move { Ok(None) })
2128            }
2129
2130            fn search<'a>(
2131                &'a self,
2132                _query: &'a str,
2133                _limit: usize,
2134            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2135            {
2136                Box::pin(async move { Ok(vec![]) })
2137            }
2138
2139            fn select_latest_matching(
2140                &self,
2141                versions: &[Box<dyn Version>],
2142                _req: &deps_core::VersionReq,
2143            ) -> Option<usize> {
2144                if versions.is_empty() { None } else { Some(0) }
2145            }
2146
2147            fn as_any(&self) -> &dyn Any {
2148                self
2149            }
2150        }
2151
2152        let registry: Arc<dyn Registry> = Arc::new(FreshnessAwareRegistry);
2153        let packages = vec![PackageName::new("widget")];
2154
2155        let result = fetch_latest_versions_parallel(
2156            registry,
2157            with_registry_source(packages),
2158            &HashMap::new(),
2159            None,
2160            FreshnessSettings::default(),
2161            10,
2162            10,
2163            None,
2164        )
2165        .await;
2166
2167        let widget = result
2168            .versions
2169            .get("widget")
2170            .expect("widget should be fetched");
2171        assert_eq!(
2172            widget.published_at,
2173            Some(PublishTime::from_unix_secs(5_000)),
2174            "published_at must come from get_versions_with, not the freshness-blind \
2175             get_versions (#339)"
2176        );
2177    }
2178
2179    /// #424 S1: `fetch_latest_versions_parallel` must call `select_latest_matching_with_context`
2180    /// with the `minimum_stability` value it was given, not the plain `select_latest_matching`
2181    /// — otherwise a registry with manifest-level stability state (e.g. Composer's
2182    /// `minimum-stability`) never actually sees it, and #424's S1 fix stays unreachable dead
2183    /// code from the live LSP fetch path's perspective (critic S3/tester's reachability gap).
2184    #[tokio::test]
2185    async fn test_fetch_latest_versions_parallel_threads_minimum_stability_into_select_latest_matching_with_context()
2186     {
2187        use deps_core::{Metadata, Registry, Version};
2188        use std::any::Any;
2189        use std::sync::Mutex;
2190
2191        #[derive(Debug)]
2192        struct MockVersion {
2193            version: ConcreteVersion,
2194        }
2195
2196        impl Version for MockVersion {
2197            fn version_string(&self) -> &ConcreteVersion {
2198                &self.version
2199            }
2200            fn as_any(&self) -> &dyn Any {
2201                self
2202            }
2203        }
2204
2205        struct ContextAwareRegistry {
2206            // Records every `minimum_stability` value observed, in call order — an empty
2207            // `Vec` after the fetch means the `_with_context` method was never invoked.
2208            seen_minimum_stability: Mutex<Vec<Option<String>>>,
2209        }
2210
2211        impl Registry for ContextAwareRegistry {
2212            fn get_versions<'a>(
2213                &'a self,
2214                _name: &'a deps_core::PackageName,
2215            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2216            {
2217                Box::pin(async move {
2218                    Ok(vec![Box::new(MockVersion {
2219                        version: "1.0.0".into(),
2220                    }) as Box<dyn Version>])
2221                })
2222            }
2223
2224            fn get_latest_matching<'a>(
2225                &'a self,
2226                _name: &'a deps_core::PackageName,
2227                _req: &'a deps_core::VersionReq,
2228            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2229            {
2230                Box::pin(async move { Ok(None) })
2231            }
2232
2233            fn search<'a>(
2234                &'a self,
2235                _query: &'a str,
2236                _limit: usize,
2237            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2238            {
2239                Box::pin(async move { Ok(vec![]) })
2240            }
2241
2242            // Deliberately NOT overridden: if the fetch loop ever calls the plain
2243            // `select_latest_matching` instead of the `_with_context` variant, this default
2244            // (`None`) makes the pick fail, which the fallback below records as "not found" —
2245            // distinguishable from the success path this test asserts on.
2246            fn select_latest_matching_with_context(
2247                &self,
2248                versions: &[Box<dyn Version>],
2249                _req: &deps_core::VersionReq,
2250                minimum_stability: Option<&str>,
2251            ) -> Option<usize> {
2252                self.seen_minimum_stability
2253                    .lock()
2254                    .unwrap_or_else(|p| p.into_inner())
2255                    .push(minimum_stability.map(str::to_string));
2256                if versions.is_empty() { None } else { Some(0) }
2257            }
2258
2259            fn as_any(&self) -> &dyn Any {
2260                self
2261            }
2262        }
2263
2264        let registry = Arc::new(ContextAwareRegistry {
2265            seen_minimum_stability: Mutex::new(Vec::new()),
2266        });
2267        let packages = vec![PackageName::new("vendor/pkg")];
2268
2269        let result = fetch_latest_versions_parallel(
2270            Arc::clone(&registry) as Arc<dyn Registry>,
2271            with_registry_source(packages),
2272            &HashMap::new(),
2273            None,
2274            deps_core::freshness::FreshnessSettings::default(),
2275            10,
2276            10,
2277            Some("beta"),
2278        )
2279        .await;
2280
2281        assert_eq!(
2282            *registry
2283                .seen_minimum_stability
2284                .lock()
2285                .unwrap_or_else(|p| p.into_inner()),
2286            vec![Some("beta".to_string())],
2287            "select_latest_matching_with_context must receive the caller's minimum_stability"
2288        );
2289        assert!(
2290            result.versions.contains_key("vendor/pkg"),
2291            "the pick must still succeed via the _with_context path"
2292        );
2293    }
2294
2295    /// #424 S1: the `get_latest_matching` fallback path (used when the pure list-based pick
2296    /// finds nothing) must also thread `minimum_stability` through its own `_with_context`
2297    /// variant.
2298    #[tokio::test]
2299    async fn test_fetch_latest_versions_parallel_threads_minimum_stability_into_get_latest_matching_with_context()
2300     {
2301        use deps_core::{Metadata, Registry, Version};
2302        use std::any::Any;
2303        use std::sync::Mutex;
2304
2305        #[derive(Debug)]
2306        struct MockVersion {
2307            version: ConcreteVersion,
2308        }
2309
2310        impl Version for MockVersion {
2311            fn version_string(&self) -> &ConcreteVersion {
2312                &self.version
2313            }
2314            fn as_any(&self) -> &dyn Any {
2315                self
2316            }
2317        }
2318
2319        struct FallbackContextAwareRegistry {
2320            // Records every `minimum_stability` value observed, in call order — an empty
2321            // `Vec` after the fetch means the `_with_context` method was never invoked.
2322            seen_minimum_stability: Mutex<Vec<Option<String>>>,
2323        }
2324
2325        impl Registry for FallbackContextAwareRegistry {
2326            fn get_versions<'a>(
2327                &'a self,
2328                _name: &'a deps_core::PackageName,
2329            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2330            {
2331                // Empty list forces the fetch loop's `get_latest_matching_with_context`
2332                // fallback (the pure list-based pick over an empty list finds nothing).
2333                Box::pin(async move { Ok(vec![]) })
2334            }
2335
2336            fn get_latest_matching<'a>(
2337                &'a self,
2338                _name: &'a deps_core::PackageName,
2339                _req: &'a deps_core::VersionReq,
2340            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2341            {
2342                Box::pin(async move { Ok(None) })
2343            }
2344
2345            fn get_latest_matching_with_context<'a>(
2346                &'a self,
2347                _name: &'a deps_core::PackageName,
2348                _req: &'a deps_core::VersionReq,
2349                minimum_stability: Option<&'a str>,
2350            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2351            {
2352                self.seen_minimum_stability
2353                    .lock()
2354                    .unwrap_or_else(|p| p.into_inner())
2355                    .push(minimum_stability.map(str::to_string));
2356                Box::pin(async move {
2357                    Ok(Some(Box::new(MockVersion {
2358                        version: "2.0.0-beta1".into(),
2359                    }) as Box<dyn Version>))
2360                })
2361            }
2362
2363            fn search<'a>(
2364                &'a self,
2365                _query: &'a str,
2366                _limit: usize,
2367            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2368            {
2369                Box::pin(async move { Ok(vec![]) })
2370            }
2371
2372            fn as_any(&self) -> &dyn Any {
2373                self
2374            }
2375        }
2376
2377        let registry = Arc::new(FallbackContextAwareRegistry {
2378            seen_minimum_stability: Mutex::new(Vec::new()),
2379        });
2380        let packages = vec![PackageName::new("vendor/pkg")];
2381
2382        let result = fetch_latest_versions_parallel(
2383            Arc::clone(&registry) as Arc<dyn Registry>,
2384            with_registry_source(packages),
2385            &HashMap::new(),
2386            None,
2387            deps_core::freshness::FreshnessSettings::default(),
2388            10,
2389            10,
2390            Some("beta"),
2391        )
2392        .await;
2393
2394        assert_eq!(
2395            *registry
2396                .seen_minimum_stability
2397                .lock()
2398                .unwrap_or_else(|p| p.into_inner()),
2399            vec![Some("beta".to_string())],
2400            "get_latest_matching_with_context must receive the caller's minimum_stability"
2401        );
2402        let widget = result
2403            .versions
2404            .get("vendor/pkg")
2405            .expect("fallback pick should succeed");
2406        assert_eq!(widget.latest, "2.0.0-beta1");
2407    }
2408
2409    /// S3 regression: a registry whose `get_versions` list is incomplete (e.g. Go's
2410    /// `/@v/list`, which never enumerates pseudo-versions and can be entirely empty for an
2411    /// untagged module) must not render the package as "no version found" just because
2412    /// `select_latest_matching`'s pure list-based pick came up empty — the fetch loop must
2413    /// fall back to the registry's own `get_latest_matching`.
2414    #[tokio::test]
2415    async fn test_fetch_falls_back_to_get_latest_matching_when_list_based_pick_finds_nothing() {
2416        use deps_core::{Metadata, Registry, Version};
2417        use std::any::Any;
2418
2419        #[derive(Debug)]
2420        struct MockVersion {
2421            version: ConcreteVersion,
2422        }
2423
2424        impl Version for MockVersion {
2425            fn version_string(&self) -> &ConcreteVersion {
2426                &self.version
2427            }
2428            fn as_any(&self) -> &dyn Any {
2429                self
2430            }
2431        }
2432
2433        /// Mimics an untagged Go module: `get_versions` (the list endpoint) is empty, but
2434        /// `get_latest_matching` (a different, more complete endpoint) still resolves a
2435        /// pseudo-version. `select_latest_matching` deliberately relies on the trait
2436        /// default (`None`), matching a real registry whose list-based pick has nothing to
2437        /// work with.
2438        struct UntaggedModuleRegistry;
2439
2440        impl Registry for UntaggedModuleRegistry {
2441            fn get_versions<'a>(
2442                &'a self,
2443                _name: &'a deps_core::PackageName,
2444            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2445            {
2446                Box::pin(async move { Ok(vec![]) })
2447            }
2448
2449            fn get_latest_matching<'a>(
2450                &'a self,
2451                _name: &'a deps_core::PackageName,
2452                _req: &'a deps_core::VersionReq,
2453            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2454            {
2455                Box::pin(async move {
2456                    Ok(Some(Box::new(MockVersion {
2457                        version: "v0.0.0-20191109021931-daa7c04131f5".into(),
2458                    }) as Box<dyn Version>))
2459                })
2460            }
2461
2462            fn search<'a>(
2463                &'a self,
2464                _query: &'a str,
2465                _limit: usize,
2466            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2467            {
2468                Box::pin(async move { Ok(vec![]) })
2469            }
2470
2471            fn as_any(&self) -> &dyn Any {
2472                self
2473            }
2474        }
2475
2476        let registry: Arc<dyn Registry> = Arc::new(UntaggedModuleRegistry);
2477        let packages = vec![PackageName::new("golang.org/x/exp")];
2478
2479        let result = fetch_latest_versions_parallel(
2480            registry,
2481            with_registry_source(packages),
2482            &HashMap::new(),
2483            None,
2484            deps_core::freshness::FreshnessSettings::default(),
2485            5,
2486            10,
2487            None,
2488        )
2489        .await;
2490
2491        assert_eq!(
2492            result
2493                .versions
2494                .get("golang.org/x/exp")
2495                .map(|v| v.latest.as_str()),
2496            Some("v0.0.0-20191109021931-daa7c04131f5"),
2497            "must fall back to get_latest_matching instead of reporting no version found"
2498        );
2499    }
2500
2501    #[tokio::test]
2502    async fn test_fetch_registry_error_handled() {
2503        use deps_core::{Metadata, Registry, Version};
2504        use std::any::Any;
2505
2506        struct ErrorRegistry;
2507
2508        impl Registry for ErrorRegistry {
2509            fn get_versions<'a>(
2510                &'a self,
2511                name: &'a deps_core::PackageName,
2512            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2513            {
2514                Box::pin(async move {
2515                    Err(deps_core::error::DepsError::CacheError(format!(
2516                        "Failed to fetch package: {}",
2517                        name
2518                    )))
2519                })
2520            }
2521
2522            fn get_latest_matching<'a>(
2523                &'a self,
2524                name: &'a deps_core::PackageName,
2525                _req: &'a deps_core::VersionReq,
2526            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2527            {
2528                Box::pin(async move {
2529                    Err(deps_core::error::DepsError::CacheError(format!(
2530                        "Failed to fetch package: {}",
2531                        name
2532                    )))
2533                })
2534            }
2535
2536            fn search<'a>(
2537                &'a self,
2538                _query: &'a str,
2539                _limit: usize,
2540            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2541            {
2542                Box::pin(async move { Ok(vec![]) })
2543            }
2544
2545            fn as_any(&self) -> &dyn Any {
2546                self
2547            }
2548        }
2549
2550        let registry: Arc<dyn Registry> = Arc::new(ErrorRegistry);
2551        let packages = vec![
2552            PackageName::new("package-1"),
2553            PackageName::new("package-2"),
2554            PackageName::new("package-3"),
2555        ];
2556
2557        let result = fetch_latest_versions_parallel(
2558            registry,
2559            with_registry_source(packages),
2560            &HashMap::new(),
2561            None,
2562            deps_core::freshness::FreshnessSettings::default(),
2563            5,
2564            10,
2565            None,
2566        )
2567        .await;
2568
2569        assert!(
2570            result.versions.is_empty(),
2571            "All packages with errors should be omitted from results"
2572        );
2573        assert_eq!(
2574            result.failed_count, 3,
2575            "All 3 packages should be marked as failed"
2576        );
2577        // #267: a fetch error must be recorded per-package, not just counted,
2578        // so diagnostic generation can tell "fetch failed" apart from
2579        // "genuinely not found" instead of reporting "Unknown package".
2580        assert_eq!(
2581            result.fetch_failed,
2582            HashMap::from([
2583                (PackageName::new("package-1"), FetchFailure::Transient),
2584                (PackageName::new("package-2"), FetchFailure::Transient),
2585                (PackageName::new("package-3"), FetchFailure::Transient),
2586            ]),
2587            "every errored package must be recorded in fetch_failed"
2588        );
2589    }
2590
2591    #[tokio::test]
2592    async fn test_fetch_not_found_is_not_recorded_as_fetch_failed() {
2593        // #267 C1: a genuine not-found (`DepsError::PackageNotFound`, the
2594        // variant npm/PyPI/Go/Swift map a 404 to) means the registry was
2595        // successfully asked and answered "no such package" — recording it
2596        // in `fetch_failed` would make `generate_diagnostics_from_cache`
2597        // report "Registry lookup failed" instead of "Unknown package" for
2598        // the common typo'd-dependency case, inverting the bug this field
2599        // exists to fix.
2600        use deps_core::{Metadata, Registry, Version};
2601        use std::any::Any;
2602
2603        struct NotFoundRegistry;
2604
2605        impl Registry for NotFoundRegistry {
2606            fn get_versions<'a>(
2607                &'a self,
2608                name: &'a deps_core::PackageName,
2609            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2610            {
2611                Box::pin(async move {
2612                    Err(deps_core::error::DepsError::PackageNotFound {
2613                        package: name.to_string(),
2614                        registry: "mock",
2615                    })
2616                })
2617            }
2618
2619            fn get_latest_matching<'a>(
2620                &'a self,
2621                name: &'a deps_core::PackageName,
2622                _req: &'a deps_core::VersionReq,
2623            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2624            {
2625                Box::pin(async move {
2626                    Err(deps_core::error::DepsError::PackageNotFound {
2627                        package: name.to_string(),
2628                        registry: "mock",
2629                    })
2630                })
2631            }
2632
2633            fn search<'a>(
2634                &'a self,
2635                _query: &'a str,
2636                _limit: usize,
2637            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2638            {
2639                Box::pin(async move { Ok(vec![]) })
2640            }
2641
2642            fn as_any(&self) -> &dyn Any {
2643                self
2644            }
2645        }
2646
2647        let registry: Arc<dyn Registry> = Arc::new(NotFoundRegistry);
2648        let packages = vec![PackageName::new("typo-pkg")];
2649
2650        let result = fetch_latest_versions_parallel(
2651            registry,
2652            with_registry_source(packages),
2653            &HashMap::new(),
2654            None,
2655            deps_core::freshness::FreshnessSettings::default(),
2656            5,
2657            10,
2658            None,
2659        )
2660        .await;
2661
2662        assert!(result.versions.is_empty());
2663        assert!(
2664            result.fetch_failed.is_empty(),
2665            "a genuine not-found must not be recorded in fetch_failed, or \
2666             generate_diagnostics_from_cache would report it as a registry \
2667             error instead of Unknown package"
2668        );
2669    }
2670
2671    /// Regression for #550: a registry fetch that genuinely succeeds (no error at
2672    /// either the list-based pick or the `get_latest_matching` fallback) but resolves
2673    /// to zero versions must be recorded in `no_comparable_versions`, distinct from
2674    /// both a normal successful fetch (`versions`) and a real failure
2675    /// (`fetch_failed`). Mirrors `GithubActionsRegistry::get_versions("dtolnay/rust-toolchain")`,
2676    /// whose sole tag `v1` doesn't parse as full semver, so `tags_to_versions` filters
2677    /// it out and returns `Ok(vec![])`.
2678    #[tokio::test]
2679    async fn test_fetch_success_with_zero_versions_is_recorded_as_no_comparable_versions() {
2680        use deps_core::{Metadata, Registry, Version};
2681        use std::any::Any;
2682
2683        struct EmptyButRealRegistry;
2684
2685        impl Registry for EmptyButRealRegistry {
2686            fn get_versions<'a>(
2687                &'a self,
2688                _name: &'a deps_core::PackageName,
2689            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2690            {
2691                Box::pin(async move { Ok(vec![]) })
2692            }
2693
2694            fn get_latest_matching<'a>(
2695                &'a self,
2696                _name: &'a deps_core::PackageName,
2697                _req: &'a deps_core::VersionReq,
2698            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2699            {
2700                Box::pin(async move { Ok(None) })
2701            }
2702
2703            fn search<'a>(
2704                &'a self,
2705                _query: &'a str,
2706                _limit: usize,
2707            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2708            {
2709                Box::pin(async move { Ok(vec![]) })
2710            }
2711
2712            fn as_any(&self) -> &dyn Any {
2713                self
2714            }
2715        }
2716
2717        let registry: Arc<dyn Registry> = Arc::new(EmptyButRealRegistry);
2718        let packages = vec![PackageName::new("dtolnay/rust-toolchain")];
2719
2720        let result = fetch_latest_versions_parallel(
2721            registry,
2722            with_registry_source(packages),
2723            &HashMap::new(),
2724            None,
2725            deps_core::freshness::FreshnessSettings::default(),
2726            5,
2727            10,
2728            None,
2729        )
2730        .await;
2731
2732        assert!(result.versions.is_empty());
2733        assert!(
2734            result.fetch_failed.is_empty(),
2735            "a genuine empty-but-successful fetch must not be recorded as a fetch \
2736             failure, or generate_diagnostics_from_cache would report a registry \
2737             error instead of nothing"
2738        );
2739        assert!(
2740            result
2741                .no_comparable_versions
2742                .contains(&PackageName::new("dtolnay/rust-toolchain")),
2743            "a package whose fetch succeeded with zero comparable versions must be \
2744             recorded in no_comparable_versions, or R5 would misreport it as Unknown \
2745             package; got: {:?}",
2746            result.no_comparable_versions
2747        );
2748    }
2749
2750    #[tokio::test]
2751    async fn test_fetch_http_404_is_not_recorded_as_fetch_failed() {
2752        // Same as `test_fetch_not_found_is_not_recorded_as_fetch_failed`, for
2753        // the ecosystems (Cargo, Maven, Gradle, Bundler, Dart, Composer,
2754        // NuGet) that propagate a raw `DepsError::HttpStatus { status: 404 }`
2755        // instead of mapping it to `PackageNotFound`.
2756        use deps_core::{Metadata, Registry, Version};
2757        use std::any::Any;
2758
2759        struct Http404Registry;
2760
2761        impl Registry for Http404Registry {
2762            fn get_versions<'a>(
2763                &'a self,
2764                name: &'a deps_core::PackageName,
2765            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2766            {
2767                Box::pin(async move {
2768                    Err(deps_core::error::DepsError::HttpStatus {
2769                        url: format!("https://example.com/{name}").into(),
2770                        status: 404,
2771                    })
2772                })
2773            }
2774
2775            fn get_latest_matching<'a>(
2776                &'a self,
2777                name: &'a deps_core::PackageName,
2778                _req: &'a deps_core::VersionReq,
2779            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2780            {
2781                Box::pin(async move {
2782                    Err(deps_core::error::DepsError::HttpStatus {
2783                        url: format!("https://example.com/{name}").into(),
2784                        status: 404,
2785                    })
2786                })
2787            }
2788
2789            fn search<'a>(
2790                &'a self,
2791                _query: &'a str,
2792                _limit: usize,
2793            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2794            {
2795                Box::pin(async move { Ok(vec![]) })
2796            }
2797
2798            fn as_any(&self) -> &dyn Any {
2799                self
2800            }
2801        }
2802
2803        let registry: Arc<dyn Registry> = Arc::new(Http404Registry);
2804        let packages = vec![PackageName::new("typo-pkg")];
2805
2806        let result = fetch_latest_versions_parallel(
2807            registry,
2808            with_registry_source(packages),
2809            &HashMap::new(),
2810            None,
2811            deps_core::freshness::FreshnessSettings::default(),
2812            5,
2813            10,
2814            None,
2815        )
2816        .await;
2817
2818        assert!(result.versions.is_empty());
2819        assert!(
2820            result.fetch_failed.is_empty(),
2821            "a bare HTTP 404 must not be recorded in fetch_failed either"
2822        );
2823    }
2824
2825    #[tokio::test]
2826    async fn test_fetch_fallback_error_recorded_as_fetch_failed_unless_not_found() {
2827        // Go-shaped path: `get_versions` returns an empty list (nothing for
2828        // `select_latest_matching` to pick), so `fetch_latest_versions_parallel`
2829        // falls back to `get_latest_matching`. Exercises the fallback's own
2830        // error/timeout arms (previously zero test coverage — tester gap),
2831        // and confirms the same not-found-vs-failure gating (#267 C1) applies
2832        // there too, per-package via the `not_found` name.
2833        use deps_core::{Metadata, Registry, Version};
2834        use std::any::Any;
2835
2836        struct FallbackErrorRegistry;
2837
2838        impl Registry for FallbackErrorRegistry {
2839            fn get_versions<'a>(
2840                &'a self,
2841                _name: &'a deps_core::PackageName,
2842            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2843            {
2844                Box::pin(async move { Ok(vec![]) })
2845            }
2846
2847            fn get_latest_matching<'a>(
2848                &'a self,
2849                name: &'a deps_core::PackageName,
2850                _req: &'a deps_core::VersionReq,
2851            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2852            {
2853                let name = name.clone();
2854                Box::pin(async move {
2855                    if name.as_str() == "not-found" {
2856                        Err(deps_core::error::DepsError::PackageNotFound {
2857                            package: name.to_string(),
2858                            registry: "mock",
2859                        })
2860                    } else {
2861                        Err(deps_core::error::DepsError::CacheError(
2862                            "mock fallback failure".to_string(),
2863                        ))
2864                    }
2865                })
2866            }
2867
2868            fn search<'a>(
2869                &'a self,
2870                _query: &'a str,
2871                _limit: usize,
2872            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2873            {
2874                Box::pin(async move { Ok(vec![]) })
2875            }
2876
2877            fn as_any(&self) -> &dyn Any {
2878                self
2879            }
2880        }
2881
2882        let registry: Arc<dyn Registry> = Arc::new(FallbackErrorRegistry);
2883        let packages = vec![PackageName::new("flaky"), PackageName::new("not-found")];
2884
2885        let result = fetch_latest_versions_parallel(
2886            registry,
2887            with_registry_source(packages),
2888            &HashMap::new(),
2889            None,
2890            deps_core::freshness::FreshnessSettings::default(),
2891            5,
2892            10,
2893            None,
2894        )
2895        .await;
2896
2897        assert!(result.versions.is_empty());
2898        assert_eq!(
2899            result.fetch_failed,
2900            HashMap::from([(PackageName::new("flaky"), FetchFailure::Transient)]),
2901            "the fallback's own non-not-found error must be recorded in fetch_failed, \
2902             but its not-found error must not"
2903        );
2904        assert_eq!(
2905            result.failed_count, 2,
2906            "both fallback failures count toward failed_count regardless of cause (S2)"
2907        );
2908    }
2909
2910    #[tokio::test]
2911    async fn test_fetch_fallback_timeout_recorded_as_fetch_failed() {
2912        // Timeout coverage for the `get_latest_matching` fallback path — a
2913        // timeout is never a "not found", so it must always land in
2914        // `fetch_failed` (and count toward `failed_count`, S2).
2915        use deps_core::{Metadata, Registry, Version};
2916        use std::any::Any;
2917        use std::time::Duration;
2918
2919        struct FallbackTimeoutRegistry;
2920
2921        impl Registry for FallbackTimeoutRegistry {
2922            fn get_versions<'a>(
2923                &'a self,
2924                _name: &'a deps_core::PackageName,
2925            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2926            {
2927                Box::pin(async move { Ok(vec![]) })
2928            }
2929
2930            fn get_latest_matching<'a>(
2931                &'a self,
2932                _name: &'a deps_core::PackageName,
2933                _req: &'a deps_core::VersionReq,
2934            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2935            {
2936                Box::pin(async move {
2937                    tokio::time::sleep(Duration::from_secs(10)).await;
2938                    Ok(None)
2939                })
2940            }
2941
2942            fn search<'a>(
2943                &'a self,
2944                _query: &'a str,
2945                _limit: usize,
2946            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2947            {
2948                Box::pin(async move { Ok(vec![]) })
2949            }
2950
2951            fn as_any(&self) -> &dyn Any {
2952                self
2953            }
2954        }
2955
2956        let registry: Arc<dyn Registry> = Arc::new(FallbackTimeoutRegistry);
2957        let packages = vec![PackageName::new("slow-fallback")];
2958
2959        let result = fetch_latest_versions_parallel(
2960            registry,
2961            with_registry_source(packages),
2962            &HashMap::new(),
2963            None,
2964            deps_core::freshness::FreshnessSettings::default(),
2965            1,
2966            10,
2967            None,
2968        )
2969        .await;
2970
2971        assert!(result.versions.is_empty());
2972        assert_eq!(
2973            result.fetch_failed,
2974            HashMap::from([(PackageName::new("slow-fallback"), FetchFailure::Transient)])
2975        );
2976        assert_eq!(result.failed_count, 1);
2977    }
2978
2979    #[tokio::test]
2980    async fn test_first_error_prefers_actionable_error_over_not_found_regardless_of_race_order() {
2981        // #480: before this fix, `first_error` was simply whichever concurrent fetch
2982        // finished first, so a fast not-found could outrank a slower but more actionable
2983        // error. Here not-found resolves immediately and the actionable error resolves
2984        // after a delay, winning the race — `priority_error` must still make it win.
2985        use deps_core::{Metadata, Registry, Version};
2986        use std::any::Any;
2987        use std::time::Duration;
2988
2989        struct MixedErrorRegistry;
2990
2991        impl Registry for MixedErrorRegistry {
2992            fn get_versions<'a>(
2993                &'a self,
2994                name: &'a deps_core::PackageName,
2995            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2996            {
2997                Box::pin(async move {
2998                    if name.as_str() == "typo-pkg" {
2999                        Err(deps_core::error::DepsError::PackageNotFound {
3000                            package: name.to_string(),
3001                            registry: "mock",
3002                        })
3003                    } else {
3004                        tokio::time::sleep(Duration::from_millis(50)).await;
3005                        Err(deps_core::error::DepsError::CacheError(
3006                            "rate limit exceeded".to_string(),
3007                        ))
3008                    }
3009                })
3010            }
3011
3012            fn get_latest_matching<'a>(
3013                &'a self,
3014                _name: &'a deps_core::PackageName,
3015                _req: &'a deps_core::VersionReq,
3016            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3017            {
3018                Box::pin(async move { Ok(None) })
3019            }
3020
3021            fn search<'a>(
3022                &'a self,
3023                _query: &'a str,
3024                _limit: usize,
3025            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3026            {
3027                Box::pin(async move { Ok(vec![]) })
3028            }
3029
3030            fn as_any(&self) -> &dyn Any {
3031                self
3032            }
3033        }
3034
3035        let registry: Arc<dyn Registry> = Arc::new(MixedErrorRegistry);
3036        let packages = vec![
3037            PackageName::new("typo-pkg"),
3038            PackageName::new("rate-limited"),
3039        ];
3040
3041        let result = fetch_latest_versions_parallel(
3042            registry,
3043            with_registry_source(packages),
3044            &HashMap::new(),
3045            None,
3046            deps_core::freshness::FreshnessSettings::default(),
3047            5,
3048            10,
3049            None,
3050        )
3051        .await;
3052
3053        let err = result
3054            .first_error
3055            .expect("an actionable failure occurred and must be reported");
3056        assert!(
3057            err.contains("rate limit exceeded"),
3058            "the actionable error must win the toast over the faster-finishing not-found, \
3059             got: {err}"
3060        );
3061        assert!(
3062            !err.contains("not found"),
3063            "a not-found error must never outrank an actionable error, got: {err}"
3064        );
3065    }
3066
3067    #[tokio::test]
3068    async fn test_first_error_falls_back_to_not_found_when_no_actionable_error_occurred() {
3069        // #480 fallback path: `priority_error` is only populated by errors that also
3070        // count toward `fetch_failed` (non-not-found). A batch whose only failures are
3071        // not-found ones must still surface one via `first_error` instead of silently
3072        // reporting nothing.
3073        use deps_core::{Metadata, Registry, Version};
3074        use std::any::Any;
3075
3076        struct AllNotFoundRegistry;
3077
3078        impl Registry for AllNotFoundRegistry {
3079            fn get_versions<'a>(
3080                &'a self,
3081                name: &'a deps_core::PackageName,
3082            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3083            {
3084                Box::pin(async move {
3085                    Err(deps_core::error::DepsError::PackageNotFound {
3086                        package: name.to_string(),
3087                        registry: "mock",
3088                    })
3089                })
3090            }
3091
3092            fn get_latest_matching<'a>(
3093                &'a self,
3094                _name: &'a deps_core::PackageName,
3095                _req: &'a deps_core::VersionReq,
3096            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3097            {
3098                Box::pin(async move { Ok(None) })
3099            }
3100
3101            fn search<'a>(
3102                &'a self,
3103                _query: &'a str,
3104                _limit: usize,
3105            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3106            {
3107                Box::pin(async move { Ok(vec![]) })
3108            }
3109
3110            fn as_any(&self) -> &dyn Any {
3111                self
3112            }
3113        }
3114
3115        let registry: Arc<dyn Registry> = Arc::new(AllNotFoundRegistry);
3116        let packages = vec![
3117            PackageName::new("typo-pkg-1"),
3118            PackageName::new("typo-pkg-2"),
3119        ];
3120
3121        let result = fetch_latest_versions_parallel(
3122            registry,
3123            with_registry_source(packages),
3124            &HashMap::new(),
3125            None,
3126            deps_core::freshness::FreshnessSettings::default(),
3127            5,
3128            10,
3129            None,
3130        )
3131        .await;
3132
3133        assert!(
3134            result.fetch_failed.is_empty(),
3135            "not-found errors must never be recorded in fetch_failed"
3136        );
3137        let err = result
3138            .first_error
3139            .expect("a not-found-only batch must still fall back to reporting one via first_error");
3140        assert!(err.contains("not found"), "got: {err}");
3141    }
3142
3143    #[tokio::test]
3144    async fn test_timeout_only_batch_reports_first_error_alongside_failed_count() {
3145        // #480 S1: the toast used to special-case a populated `first_error`, dropping
3146        // `failed_count` from the message — a timeout batch silently lost its count. Now
3147        // built unconditionally from both fields (#490): asserts `FetchResult` reports
3148        // `failed_count` equal to batch size *and* a populated `first_error` together.
3149        use deps_core::{Metadata, Registry, Version};
3150        use std::any::Any;
3151        use std::time::Duration;
3152
3153        struct AlwaysTimesOutRegistry;
3154
3155        impl Registry for AlwaysTimesOutRegistry {
3156            fn get_versions<'a>(
3157                &'a self,
3158                _name: &'a deps_core::PackageName,
3159            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3160            {
3161                Box::pin(async move {
3162                    tokio::time::sleep(Duration::from_secs(10)).await;
3163                    Ok(vec![])
3164                })
3165            }
3166
3167            fn get_latest_matching<'a>(
3168                &'a self,
3169                _name: &'a deps_core::PackageName,
3170                _req: &'a deps_core::VersionReq,
3171            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3172            {
3173                Box::pin(async move {
3174                    tokio::time::sleep(Duration::from_secs(10)).await;
3175                    Ok(None)
3176                })
3177            }
3178
3179            fn search<'a>(
3180                &'a self,
3181                _query: &'a str,
3182                _limit: usize,
3183            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3184            {
3185                Box::pin(async move { Ok(vec![]) })
3186            }
3187
3188            fn as_any(&self) -> &dyn Any {
3189                self
3190            }
3191        }
3192
3193        let registry: Arc<dyn Registry> = Arc::new(AlwaysTimesOutRegistry);
3194        let packages = vec![
3195            PackageName::new("slow-1"),
3196            PackageName::new("slow-2"),
3197            PackageName::new("slow-3"),
3198        ];
3199
3200        let result = fetch_latest_versions_parallel(
3201            registry,
3202            with_registry_source(packages),
3203            &HashMap::new(),
3204            None,
3205            deps_core::freshness::FreshnessSettings::default(),
3206            1,
3207            10,
3208            None,
3209        )
3210        .await;
3211
3212        assert_eq!(
3213            result.failed_count, 3,
3214            "all 3 packages must count toward failed_count"
3215        );
3216        let err = result
3217            .first_error
3218            .expect("a timeout is actionable and must populate first_error, not just failed_count");
3219        assert!(
3220            err.contains("timed out"),
3221            "first_error must be the actionable timeout message, got: {err}"
3222        );
3223    }
3224
3225    // Composer-specific tests
3226    #[cfg(feature = "composer")]
3227    mod composer_tests {
3228        use super::*;
3229
3230        /// #424 S1: `composer_minimum_stability` must extract the manifest's
3231        /// `minimum-stability` field via the real `deps_composer::parser::parse_composer_json`
3232        /// → `ComposerParseResult` → `deps_core::ParseResult` downcast path, not just a
3233        /// hand-built fixture — this is the actual production call path from the fetch task.
3234        #[tokio::test]
3235        async fn test_composer_minimum_stability_extracts_from_real_parse_result() {
3236            let json = r#"{
3237  "minimum-stability": "beta",
3238  "require": {
3239    "symfony/console": "^6.0"
3240  }
3241}"#;
3242            let uri = deps_core::test_util::test_uri("/test/composer.json");
3243            let parse_result = crate::setup::parse_composer_json(json, &uri).unwrap();
3244
3245            assert_eq!(
3246                composer_minimum_stability(&parse_result as &dyn deps_core::ParseResult),
3247                Some("beta".to_string())
3248            );
3249        }
3250
3251        /// #424 S1: a `composer.json` with no `minimum-stability` field extracts to `None`,
3252        /// not a fabricated `"stable"`.
3253        #[tokio::test]
3254        async fn test_composer_minimum_stability_none_when_absent() {
3255            let json = r#"{"require": {"symfony/console": "^6.0"}}"#;
3256            let uri = deps_core::test_util::test_uri("/test/composer.json");
3257            let parse_result = crate::setup::parse_composer_json(json, &uri).unwrap();
3258
3259            assert_eq!(
3260                composer_minimum_stability(&parse_result as &dyn deps_core::ParseResult),
3261                None
3262            );
3263        }
3264
3265        /// #424 S1: a non-Composer `ParseResult` (the downcast target type mismatches) must
3266        /// extract to `None` rather than panicking — this is what every other ecosystem's
3267        /// document hits on every fetch cycle.
3268        #[test]
3269        fn test_composer_minimum_stability_none_for_non_composer_parse_result() {
3270            struct OtherParseResult;
3271            impl deps_core::ParseResult for OtherParseResult {
3272                fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
3273                    vec![]
3274                }
3275                fn workspace_root(&self) -> Option<&std::path::Path> {
3276                    None
3277                }
3278                fn uri(&self) -> &url::Url {
3279                    unimplemented!("not exercised by this test")
3280                }
3281                fn as_any(&self) -> &dyn std::any::Any {
3282                    self
3283                }
3284            }
3285
3286            assert_eq!(
3287                composer_minimum_stability(&OtherParseResult as &dyn deps_core::ParseResult),
3288                None
3289            );
3290        }
3291    }
3292    mod yanked_check_tests {
3293        use super::*;
3294        use deps_core::{Metadata, Version};
3295        use std::any::Any;
3296        use std::sync::atomic::{AtomicUsize, Ordering};
3297
3298        #[derive(Debug, Clone)]
3299        struct MockYankVersion {
3300            version: ConcreteVersion,
3301            yanked: bool,
3302        }
3303
3304        impl Version for MockYankVersion {
3305            fn version_string(&self) -> &ConcreteVersion {
3306                &self.version
3307            }
3308            fn removal_status(&self) -> deps_core::RemovalStatus {
3309                deps_core::RemovalStatus::from_yanked(self.yanked)
3310            }
3311            fn as_any(&self) -> &dyn Any {
3312                self
3313            }
3314        }
3315
3316        /// Per-package outcome for the primary (and, under #206, only)
3317        /// `get_versions` fetch.
3318        enum FetchOutcome {
3319            Versions(Vec<(&'static str, bool)>),
3320            Error,
3321            Timeout,
3322        }
3323
3324        /// Configurable mock registry for exercising the yanked-check wiring
3325        /// in `fetch_latest_versions_parallel`. Under #206's single-fetch
3326        /// design, `get_versions` is both the source of "latest" (via
3327        /// `select_latest_matching`, mirrored here by picking the first
3328        /// non-yanked entry) and, in the same in-memory list, the source of
3329        /// the yanked check — there is no second registry call to mock.
3330        /// `latest_fallback` only feeds the `get_latest_matching` fallback
3331        /// path, exercised when `select_latest_matching` finds nothing (all
3332        /// yanked, or an empty list).
3333        struct MockRegistry {
3334            reports_yanked: bool,
3335            versions: HashMap<&'static str, FetchOutcome>,
3336            latest_fallback: HashMap<&'static str, (&'static str, bool)>,
3337            fetch_calls: Arc<AtomicUsize>,
3338        }
3339
3340        impl Registry for MockRegistry {
3341            fn get_versions<'a>(
3342                &'a self,
3343                name: &'a PackageName,
3344            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3345            {
3346                self.fetch_calls.fetch_add(1, Ordering::Relaxed);
3347                let outcome = self.versions.get(name.as_str());
3348                Box::pin(async move {
3349                    match outcome {
3350                        Some(FetchOutcome::Versions(vs)) => Ok(vs
3351                            .iter()
3352                            .map(|(v, y)| {
3353                                Box::new(MockYankVersion {
3354                                    version: (*v).into(),
3355                                    yanked: *y,
3356                                }) as Box<dyn Version>
3357                            })
3358                            .collect()),
3359                        Some(FetchOutcome::Error) => Err(deps_core::error::DepsError::CacheError(
3360                            "mock fetch error".to_string(),
3361                        )),
3362                        Some(FetchOutcome::Timeout) => {
3363                            tokio::time::sleep(std::time::Duration::from_secs(10)).await;
3364                            Ok(vec![])
3365                        }
3366                        None => Ok(vec![]),
3367                    }
3368                })
3369            }
3370
3371            fn select_latest_matching(
3372                &self,
3373                versions: &[Box<dyn Version>],
3374                _req: &VersionReq,
3375            ) -> Option<usize> {
3376                versions
3377                    .iter()
3378                    .position(|v| !v.removal_status().blocks_resolution())
3379            }
3380
3381            fn get_latest_matching<'a>(
3382                &'a self,
3383                name: &'a PackageName,
3384                _req: &'a VersionReq,
3385            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3386            {
3387                let outcome = self.latest_fallback.get(name.as_str()).copied();
3388                Box::pin(async move {
3389                    Ok(outcome.map(|(v, y)| {
3390                        Box::new(MockYankVersion {
3391                            version: v.into(),
3392                            yanked: y,
3393                        }) as Box<dyn Version>
3394                    }))
3395                })
3396            }
3397
3398            fn search<'a>(
3399                &'a self,
3400                _query: &'a str,
3401                _limit: usize,
3402            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3403            {
3404                Box::pin(async move { Ok(vec![]) })
3405            }
3406
3407            fn reports_yanked(&self) -> bool {
3408                self.reports_yanked
3409            }
3410
3411            fn as_any(&self) -> &dyn Any {
3412                self
3413            }
3414        }
3415
3416        #[tokio::test]
3417        async fn reports_yanked_false_never_recorded() {
3418            // The fetched list carries a yanked in-use entry, but
3419            // `reports_yanked() == false` means `removal_status()` must never be
3420            // trusted, even though the data is already in hand for free.
3421            let fetch_calls = Arc::new(AtomicUsize::new(0));
3422            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3423                reports_yanked: false,
3424                versions: HashMap::from([(
3425                    "pkg",
3426                    FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", true)]),
3427                )]),
3428                latest_fallback: HashMap::new(),
3429                fetch_calls: Arc::clone(&fetch_calls),
3430            });
3431            let mut in_use = HashMap::new();
3432            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3433
3434            let result = fetch_latest_versions_parallel(
3435                registry,
3436                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3437                &in_use,
3438                None,
3439                deps_core::freshness::FreshnessSettings::default(),
3440                5,
3441                10,
3442                None,
3443            )
3444            .await;
3445
3446            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3447            assert!(result.yanked_versions.is_empty());
3448        }
3449
3450        #[tokio::test]
3451        async fn in_use_equal_to_latest_not_yanked() {
3452            let fetch_calls = Arc::new(AtomicUsize::new(0));
3453            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3454                reports_yanked: true,
3455                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", false)]))]),
3456                latest_fallback: HashMap::new(),
3457                fetch_calls: Arc::clone(&fetch_calls),
3458            });
3459            let mut in_use = HashMap::new();
3460            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3461
3462            let result = fetch_latest_versions_parallel(
3463                registry,
3464                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3465                &in_use,
3466                None,
3467                deps_core::freshness::FreshnessSettings::default(),
3468                5,
3469                10,
3470                None,
3471            )
3472            .await;
3473
3474            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3475            assert!(result.yanked_versions.is_empty());
3476        }
3477
3478        #[tokio::test]
3479        async fn no_known_in_use_version_skips_the_check() {
3480            let fetch_calls = Arc::new(AtomicUsize::new(0));
3481            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3482                reports_yanked: true,
3483                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("2.0.0", false)]))]),
3484                latest_fallback: HashMap::new(),
3485                fetch_calls: Arc::clone(&fetch_calls),
3486            });
3487
3488            let result = fetch_latest_versions_parallel(
3489                registry,
3490                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3491                &HashMap::new(),
3492                None,
3493                deps_core::freshness::FreshnessSettings::default(),
3494                5,
3495                10,
3496                None,
3497            )
3498            .await;
3499
3500            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3501            assert!(result.yanked_versions.is_empty());
3502        }
3503
3504        #[tokio::test]
3505        async fn in_use_differs_and_yanked_is_recorded() {
3506            // No second registry call under #206: the in-use check is a
3507            // search over the same `versions` list already fetched for
3508            // "latest" — `fetch_calls` stays at 1.
3509            let fetch_calls = Arc::new(AtomicUsize::new(0));
3510            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3511                reports_yanked: true,
3512                versions: HashMap::from([(
3513                    "pkg",
3514                    FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", true)]),
3515                )]),
3516                latest_fallback: HashMap::new(),
3517                fetch_calls: Arc::clone(&fetch_calls),
3518            });
3519            let mut in_use = HashMap::new();
3520            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3521
3522            let result = fetch_latest_versions_parallel(
3523                registry,
3524                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3525                &in_use,
3526                None,
3527                deps_core::freshness::FreshnessSettings::default(),
3528                5,
3529                10,
3530                None,
3531            )
3532            .await;
3533
3534            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3535            assert_eq!(
3536                result.yanked_versions.get(&PackageName::new("pkg")),
3537                Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
3538            );
3539        }
3540
3541        #[tokio::test]
3542        async fn in_use_differs_and_not_yanked_is_not_recorded() {
3543            let fetch_calls = Arc::new(AtomicUsize::new(0));
3544            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3545                reports_yanked: true,
3546                versions: HashMap::from([(
3547                    "pkg",
3548                    FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", false)]),
3549                )]),
3550                latest_fallback: HashMap::new(),
3551                fetch_calls: Arc::clone(&fetch_calls),
3552            });
3553            let mut in_use = HashMap::new();
3554            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3555
3556            let result = fetch_latest_versions_parallel(
3557                registry,
3558                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3559                &in_use,
3560                None,
3561                deps_core::freshness::FreshnessSettings::default(),
3562                5,
3563                10,
3564                None,
3565            )
3566            .await;
3567
3568            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3569            assert!(result.yanked_versions.is_empty());
3570        }
3571
3572        #[tokio::test]
3573        async fn every_version_yanked_still_checks_in_use() {
3574            // Critique M2: every version filtered out by the wildcard
3575            // requirement (here, all yanked) is the most severe case, not a
3576            // silent skip. `select_latest_matching` finds nothing, the
3577            // `get_latest_matching` fallback also finds nothing (no entry in
3578            // `latest_fallback`), so `result.versions` stays empty — but the
3579            // yanked check still runs against the originally fetched list.
3580            let fetch_calls = Arc::new(AtomicUsize::new(0));
3581            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3582                reports_yanked: true,
3583                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
3584                latest_fallback: HashMap::new(),
3585                fetch_calls: Arc::clone(&fetch_calls),
3586            });
3587            let mut in_use = HashMap::new();
3588            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3589
3590            let result = fetch_latest_versions_parallel(
3591                registry,
3592                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3593                &in_use,
3594                None,
3595                deps_core::freshness::FreshnessSettings::default(),
3596                5,
3597                10,
3598                None,
3599            )
3600            .await;
3601
3602            assert_eq!(
3603                result.yanked_versions.get(&PackageName::new("pkg")),
3604                Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
3605            );
3606            assert!(result.versions.is_empty());
3607        }
3608
3609        #[tokio::test]
3610        async fn latest_pick_needs_fallback_in_use_yanked_still_found() {
3611            // When the list-based pick fails (all yanked) and the
3612            // `get_latest_matching` fallback succeeds with a *different*,
3613            // non-yanked version, `result.versions` is populated from the
3614            // fallback — but the in-use yanked check still searches the
3615            // originally fetched list, not the fallback's single version.
3616            let fetch_calls = Arc::new(AtomicUsize::new(0));
3617            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3618                reports_yanked: true,
3619                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
3620                latest_fallback: HashMap::from([("pkg", ("2.0.0", false))]),
3621                fetch_calls: Arc::clone(&fetch_calls),
3622            });
3623            let mut in_use = HashMap::new();
3624            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3625
3626            let result = fetch_latest_versions_parallel(
3627                registry,
3628                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3629                &in_use,
3630                None,
3631                deps_core::freshness::FreshnessSettings::default(),
3632                5,
3633                10,
3634                None,
3635            )
3636            .await;
3637
3638            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3639            assert_eq!(
3640                result
3641                    .versions
3642                    .get(&PackageName::new("pkg"))
3643                    .map(|v| v.latest.as_str()),
3644                Some("2.0.0")
3645            );
3646            assert_eq!(
3647                result.yanked_versions.get(&PackageName::new("pkg")),
3648                Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
3649            );
3650        }
3651
3652        #[tokio::test]
3653        async fn in_use_checks_every_occurrence_of_a_duplicate_name() {
3654            // Regression guard for #394: a package can appear more than once
3655            // in a manifest under the same name (e.g. `[dependencies]` +
3656            // `[dev-dependencies]`), each pinned to a different in-use
3657            // version. Only one occurrence ("2.0.0") is yanked; the other
3658            // ("3.0.0", not fetched here, not yanked) must not shadow it.
3659            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3660                reports_yanked: true,
3661                versions: HashMap::from([(
3662                    "pkg",
3663                    FetchOutcome::Versions(vec![
3664                        ("3.0.0", false),
3665                        ("2.0.0", true),
3666                        ("1.0.0", false),
3667                    ]),
3668                )]),
3669                latest_fallback: HashMap::new(),
3670                fetch_calls: Arc::new(AtomicUsize::new(0)),
3671            });
3672            let mut in_use = HashMap::new();
3673            in_use.insert(
3674                PackageName::new("pkg"),
3675                vec!["1.0.0".to_string(), "2.0.0".to_string()],
3676            );
3677
3678            let result = fetch_latest_versions_parallel(
3679                registry,
3680                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3681                &in_use,
3682                None,
3683                deps_core::freshness::FreshnessSettings::default(),
3684                5,
3685                10,
3686                None,
3687            )
3688            .await;
3689
3690            assert_eq!(
3691                result.yanked_versions.get(&PackageName::new("pkg")),
3692                Some(&(ConcreteVersion::new("2.0.0"), RemovalStatus::Yanked)),
3693                "the yanked occurrence must be found even though a name-keyed \
3694                 single-value map could have kept only the non-yanked \"1.0.0\" pin"
3695            );
3696        }
3697
3698        #[tokio::test]
3699        async fn latest_is_yanked_recorded_as_defense_in_depth() {
3700            // §4.7 row 1: a contract-violating registry (its wildcard
3701            // `get_latest_matching` fallback returns a yanked version) still
3702            // gets recorded, at zero extra cost. `select_latest_matching`
3703            // filters yanked entries by construction, so the list-based pick
3704            // finds nothing here and the fallback is what "lies".
3705            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3706                reports_yanked: true,
3707                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
3708                latest_fallback: HashMap::from([("pkg", ("1.0.0", true))]),
3709                fetch_calls: Arc::new(AtomicUsize::new(0)),
3710            });
3711
3712            let result = fetch_latest_versions_parallel(
3713                registry,
3714                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3715                &HashMap::new(),
3716                None,
3717                deps_core::freshness::FreshnessSettings::default(),
3718                5,
3719                10,
3720                None,
3721            )
3722            .await;
3723
3724            assert_eq!(
3725                result.yanked_versions.get(&PackageName::new("pkg")),
3726                Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
3727            );
3728        }
3729
3730        #[tokio::test]
3731        async fn latest_is_yanked_not_recorded_when_reports_yanked_false() {
3732            // impl-critic M1: row 1 must respect the same `reports_yanked()`
3733            // gate as the in-memory in-use check. Harmless today only
3734            // because every opt-out registry also hardcodes `removal_status`
3735            // to `Available` — this guards against a follow-up (§8.2/§8.3)
3736            // making an opt-out registry's `removal_status()` real without
3737            // also flipping `reports_yanked()`, which would otherwise
3738            // silently reintroduce a #233-class bug through this exact row.
3739            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3740                reports_yanked: false,
3741                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
3742                latest_fallback: HashMap::from([("pkg", ("1.0.0", true))]),
3743                fetch_calls: Arc::new(AtomicUsize::new(0)),
3744            });
3745
3746            let result = fetch_latest_versions_parallel(
3747                registry,
3748                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3749                &HashMap::new(),
3750                None,
3751                deps_core::freshness::FreshnessSettings::default(),
3752                5,
3753                10,
3754                None,
3755            )
3756            .await;
3757
3758            assert!(
3759                result.yanked_versions.is_empty(),
3760                "a `reports_yanked() == false` registry's `removal_status()` must never be \
3761                 trusted, even on the zero-cost row-1 path"
3762            );
3763            assert!(
3764                result
3765                    .versions
3766                    .get(&PackageName::new("pkg"))
3767                    .expect("pkg was fetched")
3768                    .yanked
3769                    .is_empty(),
3770                "`PackageVersions::yanked` must stay empty for a `reports_yanked() == false` \
3771                 registry, even though the fetched version is itself flagged"
3772            );
3773        }
3774
3775        #[tokio::test]
3776        async fn primary_fetch_error_counts_as_failed_no_yanked_data() {
3777            // Under #206's single-fetch design there is no separate "probe"
3778            // that can fail independently of the primary fetch — a
3779            // `get_versions` failure loses both the "latest" and the yanked
3780            // data together, and is counted as a real fetch failure (unlike
3781            // the pre-#206 best-effort probe).
3782            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3783                reports_yanked: true,
3784                versions: HashMap::from([("pkg", FetchOutcome::Error)]),
3785                latest_fallback: HashMap::new(),
3786                fetch_calls: Arc::new(AtomicUsize::new(0)),
3787            });
3788            let mut in_use = HashMap::new();
3789            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3790
3791            let result = fetch_latest_versions_parallel(
3792                registry,
3793                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3794                &in_use,
3795                None,
3796                deps_core::freshness::FreshnessSettings::default(),
3797                5,
3798                10,
3799                None,
3800            )
3801            .await;
3802
3803            assert!(result.yanked_versions.is_empty());
3804            assert_eq!(result.failed_count, 1);
3805            assert!(result.versions.is_empty());
3806        }
3807
3808        #[tokio::test]
3809        async fn primary_fetch_timeout_counts_as_failed_no_yanked_data() {
3810            // Same reasoning as the error case above, for the timeout path.
3811            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3812                reports_yanked: true,
3813                versions: HashMap::from([("pkg", FetchOutcome::Timeout)]),
3814                latest_fallback: HashMap::new(),
3815                fetch_calls: Arc::new(AtomicUsize::new(0)),
3816            });
3817            let mut in_use = HashMap::new();
3818            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3819
3820            let result = fetch_latest_versions_parallel(
3821                registry,
3822                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3823                &in_use,
3824                None,
3825                deps_core::freshness::FreshnessSettings::default(),
3826                1,
3827                10,
3828                None,
3829            )
3830            .await;
3831
3832            assert!(result.yanked_versions.is_empty());
3833            assert_eq!(result.failed_count, 1);
3834            assert!(result.versions.is_empty());
3835        }
3836    }
3837
3838    /// #205: the `fetch_latest_versions_parallel` wiring that derives `FetchResult::deprecations`
3839    /// from the `resolved`/"latest" pick, self-contained rather than extending
3840    /// `yanked_check_tests`'s shared `MockYankVersion`/`FetchOutcome` (whose tuple shape has
3841    /// no room for a per-version `Deprecation` payload without touching its many existing
3842    /// call sites).
3843    mod deprecation_derivation_tests {
3844        use super::*;
3845        use deps_core::{Metadata, Version};
3846        use std::any::Any;
3847
3848        struct MockDeprecatedVersion {
3849            version: ConcreteVersion,
3850            deprecation: Option<Deprecation>,
3851        }
3852
3853        impl Version for MockDeprecatedVersion {
3854            fn version_string(&self) -> &ConcreteVersion {
3855                &self.version
3856            }
3857            fn removal_status(&self) -> RemovalStatus {
3858                RemovalStatus::from_advisory(self.deprecation.is_some())
3859            }
3860            fn deprecation(&self) -> Option<&Deprecation> {
3861                self.deprecation.as_ref()
3862            }
3863            fn as_any(&self) -> &dyn Any {
3864                self
3865            }
3866        }
3867
3868        /// Always resolves to its single configured version.
3869        struct SingleVersionRegistry {
3870            deprecation: Option<Deprecation>,
3871        }
3872
3873        impl Registry for SingleVersionRegistry {
3874            fn get_versions<'a>(
3875                &'a self,
3876                _name: &'a PackageName,
3877            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3878            {
3879                let deprecation = self.deprecation.clone();
3880                Box::pin(async move {
3881                    Ok(vec![Box::new(MockDeprecatedVersion {
3882                        version: "1.0.0".into(),
3883                        deprecation,
3884                    }) as Box<dyn Version>])
3885                })
3886            }
3887
3888            fn select_latest_matching(
3889                &self,
3890                versions: &[Box<dyn Version>],
3891                _req: &VersionReq,
3892            ) -> Option<usize> {
3893                (!versions.is_empty()).then_some(0)
3894            }
3895
3896            fn get_latest_matching<'a>(
3897                &'a self,
3898                _name: &'a PackageName,
3899                _req: &'a VersionReq,
3900            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3901            {
3902                Box::pin(async move { Ok(None) })
3903            }
3904
3905            fn search<'a>(
3906                &'a self,
3907                _query: &'a str,
3908                _limit: usize,
3909            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3910            {
3911                Box::pin(async move { Ok(vec![]) })
3912            }
3913
3914            fn as_any(&self) -> &dyn Any {
3915                self
3916            }
3917        }
3918
3919        #[tokio::test]
3920        async fn fetch_result_carries_deprecation_from_resolved_pick() {
3921            let registry: Arc<dyn Registry> = Arc::new(SingleVersionRegistry {
3922                deprecation: Some(Deprecation {
3923                    reason: Some("archived".to_string()),
3924                    replacement: Some("other/pkg".to_string()),
3925                }),
3926            });
3927
3928            let result = fetch_latest_versions_parallel(
3929                registry,
3930                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3931                &HashMap::new(),
3932                None,
3933                deps_core::freshness::FreshnessSettings::default(),
3934                5,
3935                10,
3936                None,
3937            )
3938            .await;
3939
3940            assert_eq!(
3941                result.deprecations.get(&PackageName::new("pkg")),
3942                Some(&Deprecation {
3943                    reason: Some("archived".to_string()),
3944                    replacement: Some("other/pkg".to_string()),
3945                })
3946            );
3947        }
3948
3949        #[tokio::test]
3950        async fn fetch_result_has_no_deprecation_when_resolved_pick_is_clean() {
3951            let registry: Arc<dyn Registry> = Arc::new(SingleVersionRegistry { deprecation: None });
3952
3953            let result = fetch_latest_versions_parallel(
3954                registry,
3955                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3956                &HashMap::new(),
3957                None,
3958                deps_core::freshness::FreshnessSettings::default(),
3959                5,
3960                10,
3961                None,
3962            )
3963            .await;
3964
3965            assert!(result.deprecations.is_empty());
3966        }
3967    }
3968}