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.as_str().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.for_tracing(),
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_raw<'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.for_tracing(), 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.for_tracing(),
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.for_tracing(), "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.for_tracing(),
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.for_tracing(),
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                                "{}: registry request timed out after {}s",
720                                name.for_tracing(),
721                                timeout.as_secs()
722                            ),
723                        ));
724                        None
725                    }
726                }
727            };
728
729            if check_yanked {
730                // Row 1 (§4.7): the picked "latest" itself yanked — free, already in hand.
731                // Unreachable in production under today's hardcoded wildcard, but stays
732                // correct as a defense-in-depth check.
733                if let Some((latest, status, _, _, _)) = &resolved
734                    && status.is_flagged()
735                {
736                    yanked = Some((name.clone(), latest.clone(), *status));
737                }
738
739                // Row 2/3 (§4.7, revised under #206): `versions` is the full, already-fetched
740                // list — no second registry round trip needed, so this runs for every
741                // dependency with a known in-use version, not just when it differs from
742                // `latest`. A yanked in-use version wins over an already-recorded yanked
743                // `latest` since it's the version the user actually has.
744                //
745                // Multiple occurrences of the same name (#394, e.g. `[dependencies]` +
746                // `[target.*.dependencies]`) can carry different in-use versions — every one
747                // is checked so a yanked pin on any occurrence is never missed. Filters on
748                // `is_flagged()` inside `find` itself (not a separate `.filter()`) so a
749                // response with multiple entries sharing `iv`'s version string still finds a
750                // flagged one if any exists (mirrors the pre-#205 `.any` scan).
751                if let Some((iv, status)) = in_use_versions.iter().find_map(|iv| {
752                    versions
753                        .iter()
754                        .find(|v| {
755                            v.version_string() == iv.as_str() && v.removal_status().is_flagged()
756                        })
757                        .map(|v| (iv, v.removal_status()))
758                }) {
759                    yanked = Some((name.clone(), iv.as_str().into(), status));
760                }
761            }
762
763            // #205: the deprecation finding is derived from `resolved` (already picked as
764            // "latest"), covering the fallback branch too, whose `Version` isn't a member
765            // of `versions` at all — see `FetchResult::deprecations`'s doc for why this
766            // must not scan `versions` instead.
767            if let Some((_, _, _, dep_info, _)) = &resolved
768                && let Some(dep_info) = dep_info
769            {
770                deprecation = Some((name.clone(), dep_info.clone()));
771            }
772
773            // #660/#661 tier-1 backfill: extracted from `resolved` before `.map()` consumes
774            // it. Filtered here so a `Some((name, vec![]))` entry — indistinguishable from
775            // "no data" once merged into `DocumentState::licenses` — never gets inserted.
776            license = resolved
777                .as_ref()
778                .map(|(_, _, _, _, lic)| lic)
779                .filter(|lic| !lic.is_empty())
780                .map(|lic| (name.clone(), lic.clone()));
781
782            resolved.map(|(latest, _, published_at, _, _)| {
783                let mut versions = PackageVersions::new(latest, available).with_yanked(yanked_list);
784                if let Some(published_at) = published_at {
785                    versions = versions.with_published_at(published_at);
786                }
787                (name.clone(), versions)
788            })
789        }
790        Ok(Err(e)) => {
791            // Issue #483: while offline, every fetch fails by design — this
792            // would otherwise log a per-dependency WARNING for every open/edit,
793            // contradicting the toast suppression two call sites away in this
794            // same file for being "unusable".
795            if e.is_offline() {
796                tracing::debug!(package = %name.for_tracing(), "fetch skipped: offline");
797            } else {
798                tracing::warn!(package = %name.for_tracing(), error = %e, "fetch failed");
799            }
800            failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
801            let mut fe = first_error.lock().unwrap_or_else(|p| p.into_inner());
802            if fe.is_none() {
803                *fe = Some(e.to_string());
804            }
805            drop(fe);
806            // A genuine not-found is not a fetch failure — only an unanswerable request is
807            // (#267 C1). Marking it here would report "Registry lookup failed" for a
808            // typo'd name instead of "Unknown package", inverting the bug this fixes.
809            if !e.is_not_found() {
810                failed_name = Some((name.clone(), e.fetch_failure(), e.to_string()));
811            }
812            None
813        }
814        Err(_) => {
815            tracing::warn!(package = %name.for_tracing(), "fetch timed out ({}s)", timeout.as_secs());
816            failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
817            failed_name = Some((
818                name.clone(),
819                FetchFailure::Transient,
820                format!(
821                    "{}: registry request timed out after {}s",
822                    name.for_tracing(),
823                    timeout.as_secs()
824                ),
825            ));
826            None
827        }
828    };
829
830    let count = fetched.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
831    if let Some(sender) = progress_sender {
832        sender.send(count);
833    }
834
835    let no_comparable_versions_name = no_comparable_versions.then(|| name.clone());
836    (
837        version,
838        yanked,
839        failed_name,
840        deprecation,
841        no_comparable_versions_name,
842        license,
843    )
844}
845
846/// Re-keys a completed fetch's yanked/fetch-failure findings from raw to normalized package
847/// names and applies them to `outcomes`.
848///
849/// Also records every collided name (two occurrences resolving to different sources,
850/// [`dedup_dependencies_by_source`]) as not-attempted.
851///
852/// `set_fetch_failure_if_absent` (not `set_fetch_failure`) for `collided_names`: a collided
853/// name normalizing to the same key as a genuine failure just recorded above must not
854/// clobber it (impl-critic M2).
855///
856/// # Examples
857///
858/// ```
859/// use deps_core::lsp_helpers::{
860///     DependencyOutcomes, DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming,
861///     PackageRendering, RequirementResolution, SourcePolicy,
862/// };
863/// use deps_core::{ConcreteVersion, PackageName, RemovalStatus};
864/// use deps_engine::classify::fetch::apply_fetch_outcomes;
865/// use std::collections::{HashMap, HashSet};
866///
867/// struct SimpleFormatter;
868/// impl PackageNaming for SimpleFormatter {}
869/// impl PackageRendering for SimpleFormatter {
870///     fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
871///         version.to_string()
872///     }
873///     fn package_url(&self, name: &PackageName) -> String {
874///         name.as_str().to_string()
875///     }
876/// }
877/// impl RequirementResolution for SimpleFormatter {}
878/// impl DiagnosticMessages for SimpleFormatter {}
879/// impl DiagnosticPolicy for SimpleFormatter {}
880/// impl SourcePolicy for SimpleFormatter {}
881/// impl OsvNaming for SimpleFormatter {}
882///
883/// let mut outcomes = DependencyOutcomes::new();
884/// let mut yanked = HashMap::new();
885/// yanked.insert(
886///     PackageName::new("time"),
887///     (ConcreteVersion::from("0.1.43"), RemovalStatus::Yanked),
888/// );
889///
890/// apply_fetch_outcomes(
891///     &mut outcomes,
892///     yanked,
893///     HashMap::new(),
894///     HashSet::new(),
895///     &SimpleFormatter,
896/// );
897///
898/// assert_eq!(
899///     outcomes.yanked("time").map(|(v, _)| v.to_string()),
900///     Some("0.1.43".to_string())
901/// );
902/// ```
903pub fn apply_fetch_outcomes(
904    outcomes: &mut deps_core::lsp_helpers::DependencyOutcomes,
905    yanked_versions: HashMap<PackageName, (ConcreteVersion, RemovalStatus)>,
906    fetch_failed: HashMap<PackageName, FetchFailure>,
907    collided_names: HashSet<PackageName>,
908    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
909) {
910    for (name, version) in yanked_versions {
911        outcomes.set_yanked(formatter.normalize_package_name(&name), version);
912    }
913    for (name, failure) in fetch_failed {
914        outcomes.set_fetch_failure(formatter.normalize_package_name(&name), failure);
915    }
916    for name in collided_names {
917        outcomes.set_fetch_failure_if_absent(
918            formatter.normalize_package_name(&name),
919            FetchFailure::NotAttempted,
920        );
921    }
922}
923
924#[cfg(test)]
925mod tests {
926    use super::*;
927    use deps_core::parser::DependencySource;
928
929    /// Pairs every name with the plain `Registry` source — the shape every pre-existing
930    /// `fetch_latest_versions_parallel` test used before that function became source-aware
931    /// (spec FR-001). Production call sites build real `(name, source)` pairs from a
932    /// parsed manifest via `dedup_dependencies_by_source` instead.
933    fn with_registry_source(names: Vec<PackageName>) -> Vec<(PackageName, DependencySource)> {
934        names
935            .into_iter()
936            .map(|name| (name, DependencySource::Registry))
937            .collect()
938    }
939
940    #[cfg(feature = "cargo")]
941    mod apply_fetch_outcomes_tests {
942        use super::*;
943        use crate::setup::CargoFormatter;
944        use deps_core::lsp_helpers::DependencyOutcomes;
945
946        #[test]
947        fn apply_fetch_outcomes_sets_yanked_re_keyed_to_normalized_name() {
948            let mut outcomes = DependencyOutcomes::new();
949            let mut yanked_versions = HashMap::new();
950            yanked_versions.insert(
951                PackageName::new("time"),
952                ("0.1.43".into(), RemovalStatus::Yanked),
953            );
954
955            apply_fetch_outcomes(
956                &mut outcomes,
957                yanked_versions,
958                HashMap::new(),
959                HashSet::new(),
960                &CargoFormatter,
961            );
962
963            assert_eq!(
964                outcomes.yanked("time"),
965                Some(&("0.1.43".into(), RemovalStatus::Yanked))
966            );
967        }
968
969        /// Loop order (yanked → fetch_failed → collided) and `set_fetch_failure_if_absent`
970        /// (impl-critic M2): a collided name that normalizes to the same key as a genuine
971        /// failure recorded just before it must not clobber that failure.
972        #[test]
973        fn apply_fetch_outcomes_collided_name_does_not_clobber_existing_fetch_failure() {
974            let mut outcomes = DependencyOutcomes::new();
975            let mut fetch_failed = HashMap::new();
976            fetch_failed.insert(PackageName::new("serde"), FetchFailure::Transient);
977            let mut collided_names = HashSet::new();
978            collided_names.insert(PackageName::new("serde"));
979
980            apply_fetch_outcomes(
981                &mut outcomes,
982                HashMap::new(),
983                fetch_failed,
984                collided_names,
985                &CargoFormatter,
986            );
987
988            assert_eq!(
989                outcomes.fetch_failure("serde"),
990                Some(&FetchFailure::Transient),
991                "a genuine fetch failure must survive a collided name normalizing to the same key"
992            );
993        }
994
995        /// The other half of the precedence rule: a collided name with no pre-existing
996        /// failure under its normalized key must still be recorded as not-attempted.
997        #[test]
998        fn apply_fetch_outcomes_collided_name_alone_is_recorded_as_not_attempted() {
999            let mut outcomes = DependencyOutcomes::new();
1000            let mut collided_names = HashSet::new();
1001            collided_names.insert(PackageName::new("serde"));
1002
1003            apply_fetch_outcomes(
1004                &mut outcomes,
1005                HashMap::new(),
1006                HashMap::new(),
1007                collided_names,
1008                &CargoFormatter,
1009            );
1010
1011            assert_eq!(
1012                outcomes.fetch_failure("serde"),
1013                Some(&FetchFailure::NotAttempted)
1014            );
1015        }
1016    }
1017
1018    mod dedup_by_source_collision_tests {
1019        use super::*;
1020        use deps_core::Dependency;
1021        use deps_core::lsp_helpers::{
1022            DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
1023            RequirementResolution, SourcePolicy,
1024        };
1025        use deps_core::position::{Position, Range};
1026        use std::any::Any;
1027
1028        /// Unlike the real `CargoFormatter`, treats *both* `Registry` and
1029        /// `AlternateRegistry` as resolvable — needed so two distinct source values can
1030        /// both pass gate 1 (resolvability) and reach gate 2 (collision) in the same test.
1031        struct AlternateAwareFormatter;
1032        impl PackageNaming for AlternateAwareFormatter {}
1033
1034        impl PackageRendering for AlternateAwareFormatter {
1035            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
1036                version.to_string()
1037            }
1038
1039            fn package_url(&self, name: &PackageName) -> String {
1040                format!("https://example.com/{}", name.as_str())
1041            }
1042        }
1043
1044        impl RequirementResolution for AlternateAwareFormatter {}
1045
1046        impl DiagnosticMessages for AlternateAwareFormatter {}
1047
1048        impl DiagnosticPolicy for AlternateAwareFormatter {}
1049
1050        impl SourcePolicy for AlternateAwareFormatter {
1051            fn can_resolve_source(&self, source: &DependencySource) -> bool {
1052                matches!(
1053                    source,
1054                    DependencySource::Registry | DependencySource::AlternateRegistry { .. }
1055                )
1056            }
1057        }
1058
1059        impl OsvNaming for AlternateAwareFormatter {}
1060
1061        struct MockDep {
1062            name: PackageName,
1063            source: DependencySource,
1064            addr_tag: u32,
1065        }
1066
1067        impl Dependency for MockDep {
1068            fn name(&self) -> &PackageName {
1069                &self.name
1070            }
1071            fn name_range(&self) -> Range {
1072                Range::new(
1073                    Position::new(0, self.addr_tag),
1074                    Position::new(0, self.addr_tag + 1),
1075                )
1076            }
1077            fn version_requirement(&self) -> Option<&VersionReq> {
1078                None
1079            }
1080            fn version_range(&self) -> Option<Range> {
1081                None
1082            }
1083            fn source(&self) -> DependencySource {
1084                self.source.clone()
1085            }
1086            fn as_any(&self) -> &dyn Any {
1087                self
1088            }
1089        }
1090
1091        struct MockParseResult {
1092            deps: Vec<MockDep>,
1093        }
1094
1095        impl deps_core::ParseResult for MockParseResult {
1096            fn dependencies(&self) -> Vec<&dyn Dependency> {
1097                self.deps.iter().map(|d| d as &dyn Dependency).collect()
1098            }
1099            fn workspace_root(&self) -> Option<&std::path::Path> {
1100                None
1101            }
1102            fn uri(&self) -> &url::Url {
1103                static URI: std::sync::OnceLock<url::Url> = std::sync::OnceLock::new();
1104                URI.get_or_init(|| deps_core::test_util::test_uri("/test/Cargo.toml"))
1105            }
1106            fn as_any(&self) -> &dyn Any {
1107                self
1108            }
1109        }
1110
1111        #[test]
1112        fn test_two_different_resolvable_sources_collide_and_are_dropped() {
1113            let parse_result = MockParseResult {
1114                deps: vec![
1115                    MockDep {
1116                        name: PackageName::new("shared-name"),
1117                        source: DependencySource::Registry,
1118                        addr_tag: 0,
1119                    },
1120                    MockDep {
1121                        name: PackageName::new("shared-name"),
1122                        source: DependencySource::AlternateRegistry {
1123                            index: "https://index.mycorp.dev".into(),
1124                            mirrors_crates_io: false,
1125                        },
1126                        addr_tag: 1,
1127                    },
1128                ],
1129            };
1130
1131            let (sources, collided) =
1132                dedup_dependencies_by_source(&parse_result, &AlternateAwareFormatter);
1133
1134            assert!(
1135                !sources.contains_key(&PackageName::new("shared-name")),
1136                "a colliding name must not be fetched under either source"
1137            );
1138            assert!(
1139                collided.contains(&PackageName::new("shared-name")),
1140                "the collision must be recorded so the caller can mark it fetch_failed"
1141            );
1142        }
1143
1144        #[test]
1145        fn test_identical_sources_do_not_collide() {
1146            let parse_result = MockParseResult {
1147                deps: vec![
1148                    MockDep {
1149                        name: PackageName::new("shared-name"),
1150                        source: DependencySource::Registry,
1151                        addr_tag: 0,
1152                    },
1153                    MockDep {
1154                        name: PackageName::new("shared-name"),
1155                        source: DependencySource::Registry,
1156                        addr_tag: 1,
1157                    },
1158                ],
1159            };
1160
1161            let (sources, collided) =
1162                dedup_dependencies_by_source(&parse_result, &AlternateAwareFormatter);
1163
1164            assert!(collided.is_empty());
1165            assert_eq!(
1166                sources.get(&PackageName::new("shared-name")),
1167                Some(&DependencySource::Registry)
1168            );
1169        }
1170
1171        /// Gate 1 (Critical review finding #1): a non-resolvable source is dropped
1172        /// entirely, never reaching the fetch — this is what prevents a Git/Path
1173        /// dependency's name from being looked up against the ecosystem's default
1174        /// registry via the background fetch's routing default arm.
1175        #[test]
1176        fn test_non_resolvable_source_is_dropped_not_fetched() {
1177            let parse_result = MockParseResult {
1178                deps: vec![MockDep {
1179                    name: PackageName::new("local-fork"),
1180                    source: DependencySource::Path {
1181                        path: "../local-fork".into(),
1182                    },
1183                    addr_tag: 0,
1184                }],
1185            };
1186
1187            let (sources, collided) =
1188                dedup_dependencies_by_source(&parse_result, &AlternateAwareFormatter);
1189
1190            assert!(sources.is_empty());
1191            assert!(collided.is_empty());
1192        }
1193
1194        /// #935/#936 sink-level regression test, mirroring the repo's precedent for this bug
1195        /// class (`deps-cargo/src/parser.rs`'s `test_parse_registry_index_env_collision_...`
1196        /// tests, and cache.rs #756): pinning the type-level fix (`DependencySource`'s
1197        /// hand-written `Debug`) alone leaves the actual `tracing::warn!(?source, ...)` call
1198        /// site in this function untested. Two `AlternateRegistry` sources, each carrying a
1199        /// distinct query-string credential, collide — this is the exact `tracing::warn!`
1200        /// this module emits with `source_a`/`source_b` via `?` (Debug) formatting.
1201        #[test]
1202        fn test_collision_warning_redacts_credentials_in_alternate_registry_debug_output() {
1203            let parse_result = MockParseResult {
1204                deps: vec![
1205                    MockDep {
1206                        name: PackageName::new("shared-name"),
1207                        source: DependencySource::AlternateRegistry {
1208                            index: "https://index-a.mycorp.dev/api?api_key=SECRET_A".into(),
1209                            mirrors_crates_io: false,
1210                        },
1211                        addr_tag: 0,
1212                    },
1213                    MockDep {
1214                        name: PackageName::new("shared-name"),
1215                        source: DependencySource::AlternateRegistry {
1216                            index: "https://index-b.mycorp.dev/api?api_key=SECRET_B".into(),
1217                            mirrors_crates_io: false,
1218                        },
1219                        addr_tag: 1,
1220                    },
1221                ],
1222            };
1223
1224            let log = deps_core::test_util::capture_tracing_output(|| {
1225                let (sources, collided) =
1226                    dedup_dependencies_by_source(&parse_result, &AlternateAwareFormatter);
1227                assert!(!sources.contains_key(&PackageName::new("shared-name")));
1228                assert!(collided.contains(&PackageName::new("shared-name")));
1229            });
1230
1231            assert!(
1232                log.contains("two different resolved registries"),
1233                "expected the collision WARN to fire: {log:?}"
1234            );
1235            assert!(
1236                !log.contains("SECRET_A") && !log.contains("SECRET_B"),
1237                "tracing output leaked a query-string credential: {log:?}"
1238            );
1239            assert!(
1240                log.contains("index-a.mycorp.dev") && log.contains("index-b.mycorp.dev"),
1241                "host should survive redaction: {log:?}"
1242            );
1243        }
1244    }
1245
1246    #[tokio::test]
1247    async fn test_fetch_latest_versions_parallel_with_timeout() {
1248        use deps_core::{Metadata, Registry, Version};
1249        use std::any::Any;
1250        use std::time::Duration;
1251
1252        struct TimeoutRegistry;
1253
1254        impl Registry for TimeoutRegistry {
1255            fn get_versions<'a>(
1256                &'a self,
1257                _name: &'a deps_core::PackageName,
1258            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1259            {
1260                Box::pin(async move {
1261                    tokio::time::sleep(Duration::from_secs(10)).await;
1262                    Ok(vec![])
1263                })
1264            }
1265
1266            fn get_latest_matching<'a>(
1267                &'a self,
1268                _name: &'a deps_core::PackageName,
1269                _req: &'a deps_core::VersionReq,
1270            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1271            {
1272                Box::pin(async move {
1273                    tokio::time::sleep(Duration::from_secs(10)).await;
1274                    Ok(None)
1275                })
1276            }
1277
1278            fn search_raw<'a>(
1279                &'a self,
1280                _query: &'a str,
1281                _limit: usize,
1282            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1283            {
1284                Box::pin(async move { Ok(vec![]) })
1285            }
1286
1287            fn as_any(&self) -> &dyn Any {
1288                self
1289            }
1290        }
1291
1292        let registry: Arc<dyn Registry> = Arc::new(TimeoutRegistry);
1293        let packages = vec![PackageName::new("slow-package")];
1294
1295        let result = fetch_latest_versions_parallel(
1296            registry,
1297            with_registry_source(packages),
1298            &HashMap::new(),
1299            None,
1300            deps_core::freshness::FreshnessSettings::default(),
1301            1,
1302            10,
1303            None,
1304        )
1305        .await;
1306
1307        assert!(result.versions.is_empty(), "Slow package should timeout");
1308        assert_eq!(result.failed_count, 1, "Should track 1 failed package");
1309        // #267: a timeout is also a fetch failure, not a "not found" — must
1310        // be recorded the same way as a hard registry error.
1311        assert_eq!(
1312            result.fetch_failed,
1313            HashMap::from([(PackageName::new("slow-package"), FetchFailure::Transient)]),
1314            "timed-out package must be recorded in fetch_failed"
1315        );
1316    }
1317
1318    #[tokio::test]
1319    async fn test_fetch_latest_versions_parallel_fast_packages_not_blocked() {
1320        use deps_core::{Metadata, Registry, Version};
1321        use std::any::Any;
1322        use std::time::Duration;
1323
1324        struct MixedRegistry;
1325
1326        impl Registry for MixedRegistry {
1327            fn get_versions<'a>(
1328                &'a self,
1329                name: &'a deps_core::PackageName,
1330            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1331            {
1332                Box::pin(async move {
1333                    if name == "slow-package" {
1334                        tokio::time::sleep(Duration::from_secs(10)).await;
1335                    }
1336                    Ok(vec![])
1337                })
1338            }
1339
1340            fn get_latest_matching<'a>(
1341                &'a self,
1342                name: &'a deps_core::PackageName,
1343                _req: &'a deps_core::VersionReq,
1344            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1345            {
1346                Box::pin(async move {
1347                    if name == "slow-package" {
1348                        tokio::time::sleep(Duration::from_secs(10)).await;
1349                    }
1350                    Ok(None)
1351                })
1352            }
1353
1354            fn search_raw<'a>(
1355                &'a self,
1356                _query: &'a str,
1357                _limit: usize,
1358            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1359            {
1360                Box::pin(async move { Ok(vec![]) })
1361            }
1362
1363            fn as_any(&self) -> &dyn Any {
1364                self
1365            }
1366        }
1367
1368        let registry: Arc<dyn Registry> = Arc::new(MixedRegistry);
1369        let packages = vec![
1370            PackageName::new("slow-package"),
1371            PackageName::new("fast-package"),
1372        ];
1373
1374        let start = std::time::Instant::now();
1375        let result = fetch_latest_versions_parallel(
1376            registry,
1377            with_registry_source(packages),
1378            &HashMap::new(),
1379            None,
1380            deps_core::freshness::FreshnessSettings::default(),
1381            1,
1382            10,
1383            None,
1384        )
1385        .await;
1386        let elapsed = start.elapsed();
1387
1388        assert!(
1389            elapsed < Duration::from_secs(3),
1390            "Should not wait for slow package: {:?}",
1391            elapsed
1392        );
1393
1394        assert!(
1395            result.versions.is_empty(),
1396            "No versions returned (test registry returns empty)"
1397        );
1398        assert_eq!(
1399            result.failed_count, 1,
1400            "Slow package should be marked as failed"
1401        );
1402    }
1403
1404    #[tokio::test]
1405    async fn test_fetch_latest_versions_parallel_concurrency_limit() {
1406        use deps_core::{Metadata, Registry, Version};
1407        use std::any::Any;
1408        use std::sync::atomic::{AtomicUsize, Ordering};
1409        use std::time::Duration;
1410
1411        struct ConcurrencyTrackingRegistry {
1412            current: Arc<AtomicUsize>,
1413            max_seen: Arc<AtomicUsize>,
1414        }
1415
1416        impl Registry for ConcurrencyTrackingRegistry {
1417            fn get_versions<'a>(
1418                &'a self,
1419                _name: &'a deps_core::PackageName,
1420            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1421            {
1422                Box::pin(async move {
1423                    let current = self.current.fetch_add(1, Ordering::SeqCst) + 1;
1424                    self.max_seen.fetch_max(current, Ordering::SeqCst);
1425                    tokio::time::sleep(Duration::from_millis(50)).await;
1426                    self.current.fetch_sub(1, Ordering::SeqCst);
1427
1428                    Ok(vec![])
1429                })
1430            }
1431
1432            fn get_latest_matching<'a>(
1433                &'a self,
1434                _name: &'a deps_core::PackageName,
1435                _req: &'a deps_core::VersionReq,
1436            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1437            {
1438                Box::pin(async move {
1439                    let current = self.current.fetch_add(1, Ordering::SeqCst) + 1;
1440                    self.max_seen.fetch_max(current, Ordering::SeqCst);
1441                    tokio::time::sleep(Duration::from_millis(50)).await;
1442                    self.current.fetch_sub(1, Ordering::SeqCst);
1443
1444                    Ok(None)
1445                })
1446            }
1447
1448            fn search_raw<'a>(
1449                &'a self,
1450                _query: &'a str,
1451                _limit: usize,
1452            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1453            {
1454                Box::pin(async move { Ok(vec![]) })
1455            }
1456
1457            fn as_any(&self) -> &dyn Any {
1458                self
1459            }
1460        }
1461
1462        let current = Arc::new(AtomicUsize::new(0));
1463        let max_seen = Arc::new(AtomicUsize::new(0));
1464
1465        let registry: Arc<dyn Registry> = Arc::new(ConcurrencyTrackingRegistry {
1466            current: Arc::clone(&current),
1467            max_seen: Arc::clone(&max_seen),
1468        });
1469
1470        let packages: Vec<PackageName> = (0..50)
1471            .map(|i| PackageName::new(format!("package-{}", i)))
1472            .collect();
1473
1474        fetch_latest_versions_parallel(
1475            registry,
1476            with_registry_source(packages),
1477            &HashMap::new(),
1478            None,
1479            deps_core::freshness::FreshnessSettings::default(),
1480            5,
1481            20,
1482            None,
1483        )
1484        .await;
1485
1486        // +2 margin for timing noise around the limit of 20.
1487        let max = max_seen.load(Ordering::SeqCst);
1488        assert!(
1489            max <= 22,
1490            "Concurrency limit violated: {} concurrent requests (limit: 20)",
1491            max
1492        );
1493    }
1494
1495    /// Regression test for issue #833: `buffer_unordered(0)` never polls its source
1496    /// stream and returns `Pending` forever, so a `max_concurrent` of `0` reaching this
1497    /// call previously hung the fetch indefinitely instead of completing or erroring.
1498    /// Wrapped in a short outer timeout so a regression fails fast instead of hanging
1499    /// the test suite.
1500    #[tokio::test]
1501    async fn test_fetch_latest_versions_parallel_zero_max_concurrent_still_completes() {
1502        use deps_core::{Metadata, Registry, Version};
1503        use std::any::Any;
1504
1505        struct InstantRegistry;
1506
1507        impl Registry for InstantRegistry {
1508            fn get_versions<'a>(
1509                &'a self,
1510                _name: &'a deps_core::PackageName,
1511            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1512            {
1513                Box::pin(async move { Ok(vec![]) })
1514            }
1515
1516            fn get_latest_matching<'a>(
1517                &'a self,
1518                _name: &'a deps_core::PackageName,
1519                _req: &'a deps_core::VersionReq,
1520            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1521            {
1522                Box::pin(async move { Ok(None) })
1523            }
1524
1525            fn search_raw<'a>(
1526                &'a self,
1527                _query: &'a str,
1528                _limit: usize,
1529            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1530            {
1531                Box::pin(async move { Ok(vec![]) })
1532            }
1533
1534            fn as_any(&self) -> &dyn Any {
1535                self
1536            }
1537        }
1538
1539        let registry: Arc<dyn Registry> = Arc::new(InstantRegistry);
1540        let packages = vec![PackageName::new("some-package")];
1541
1542        let result = tokio::time::timeout(
1543            std::time::Duration::from_secs(5),
1544            fetch_latest_versions_parallel(
1545                registry,
1546                with_registry_source(packages),
1547                &HashMap::new(),
1548                None,
1549                deps_core::freshness::FreshnessSettings::default(),
1550                5,
1551                0,
1552                None,
1553            ),
1554        )
1555        .await
1556        .expect("fetch with max_concurrent=0 must not hang forever");
1557
1558        assert!(
1559            result
1560                .no_comparable_versions
1561                .contains(&PackageName::new("some-package")),
1562            "fetch must still run to completion when max_concurrent is 0"
1563        );
1564    }
1565
1566    #[tokio::test]
1567    async fn test_fetch_partial_success_with_mixed_outcomes() {
1568        use deps_core::{Metadata, Registry, Version};
1569        use std::any::Any;
1570        use std::time::Duration;
1571
1572        #[derive(Debug)]
1573        struct MockVersion {
1574            version: ConcreteVersion,
1575        }
1576
1577        impl Version for MockVersion {
1578            fn version_string(&self) -> &ConcreteVersion {
1579                &self.version
1580            }
1581
1582            fn is_prerelease(&self) -> bool {
1583                false
1584            }
1585
1586            fn as_any(&self) -> &dyn Any {
1587                self
1588            }
1589        }
1590
1591        struct MixedOutcomeRegistry;
1592
1593        impl Registry for MixedOutcomeRegistry {
1594            fn get_versions<'a>(
1595                &'a self,
1596                name: &'a deps_core::PackageName,
1597            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1598            {
1599                Box::pin(async move {
1600                    match name.as_str() {
1601                        "package-fast" => Ok(vec![Box::new(MockVersion {
1602                            version: "1.0.0".into(),
1603                        }) as Box<dyn Version>]),
1604                        "package-slow" => {
1605                            tokio::time::sleep(Duration::from_secs(10)).await;
1606                            Ok(vec![])
1607                        }
1608                        "package-error" => Err(deps_core::error::DepsError::CacheError(
1609                            "Mock registry error".to_string(),
1610                        )),
1611                        _ => Ok(vec![]),
1612                    }
1613                })
1614            }
1615
1616            fn get_latest_matching<'a>(
1617                &'a self,
1618                name: &'a deps_core::PackageName,
1619                _req: &'a deps_core::VersionReq,
1620            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1621            {
1622                Box::pin(async move {
1623                    match name.as_str() {
1624                        "package-fast" => Ok(Some(Box::new(MockVersion {
1625                            version: "1.0.0".into(),
1626                        }) as Box<dyn Version>)),
1627                        "package-slow" => {
1628                            tokio::time::sleep(Duration::from_secs(10)).await;
1629                            Ok(None)
1630                        }
1631                        "package-error" => Err(deps_core::error::DepsError::CacheError(
1632                            "Mock registry error".to_string(),
1633                        )),
1634                        _ => Ok(None),
1635                    }
1636                })
1637            }
1638
1639            fn search_raw<'a>(
1640                &'a self,
1641                _query: &'a str,
1642                _limit: usize,
1643            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1644            {
1645                Box::pin(async move { Ok(vec![]) })
1646            }
1647
1648            fn select_latest_matching(
1649                &self,
1650                versions: &[Box<dyn Version>],
1651                _req: &deps_core::VersionReq,
1652            ) -> Option<usize> {
1653                // The fetch loop derives "latest" from `get_versions` via this method, not
1654                // `get_latest_matching` — must override it (not rely on the `None` default)
1655                // to keep exercising "package-fast" as a successful fetch.
1656                if versions.is_empty() { None } else { Some(0) }
1657            }
1658
1659            fn as_any(&self) -> &dyn Any {
1660                self
1661            }
1662        }
1663
1664        let registry: Arc<dyn Registry> = Arc::new(MixedOutcomeRegistry);
1665        let packages = vec![
1666            PackageName::new("package-fast"),
1667            PackageName::new("package-slow"),
1668            PackageName::new("package-error"),
1669        ];
1670
1671        let result = fetch_latest_versions_parallel(
1672            registry,
1673            with_registry_source(packages),
1674            &HashMap::new(),
1675            None,
1676            deps_core::freshness::FreshnessSettings::default(),
1677            1,
1678            10,
1679            None,
1680        )
1681        .await;
1682
1683        assert_eq!(
1684            result.versions.len(),
1685            1,
1686            "Should have exactly 1 successful package"
1687        );
1688        assert_eq!(
1689            result
1690                .versions
1691                .get("package-fast")
1692                .map(|v| v.latest.as_str()),
1693            Some("1.0.0"),
1694            "Fast package should have correct version"
1695        );
1696        assert!(
1697            !result.versions.contains_key("package-slow"),
1698            "Slow package should not be in results (timeout)"
1699        );
1700        assert!(
1701            !result.versions.contains_key("package-error"),
1702            "Error package should not be in results"
1703        );
1704    }
1705
1706    /// Issue #247: the per-version yanked flag from `get_versions` must survive into
1707    /// `PackageVersions.yanked`, not be discarded — this is what lets
1708    /// `generate_diagnostics_from_cache` (via `requirement_matches_only_yanked`) detect a
1709    /// requirement that is satisfiable only by a yanked version.
1710    #[tokio::test]
1711    async fn test_fetch_latest_versions_parallel_carries_yanked_flag_into_cache() {
1712        use deps_core::{Metadata, Registry, Version};
1713        use std::any::Any;
1714
1715        #[derive(Debug)]
1716        struct MockVersion {
1717            version: ConcreteVersion,
1718            yanked: bool,
1719        }
1720
1721        impl Version for MockVersion {
1722            fn version_string(&self) -> &ConcreteVersion {
1723                &self.version
1724            }
1725            fn removal_status(&self) -> deps_core::RemovalStatus {
1726                deps_core::RemovalStatus::from_yanked(self.yanked)
1727            }
1728            fn as_any(&self) -> &dyn Any {
1729                self
1730            }
1731        }
1732
1733        struct YankedRegistry;
1734
1735        impl Registry for YankedRegistry {
1736            fn get_versions<'a>(
1737                &'a self,
1738                _name: &'a deps_core::PackageName,
1739            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1740            {
1741                Box::pin(async move {
1742                    Ok(vec![
1743                        Box::new(MockVersion {
1744                            version: "1.0.214".into(),
1745                            yanked: false,
1746                        }) as Box<dyn Version>,
1747                        Box::new(MockVersion {
1748                            version: "1.0.213".into(),
1749                            yanked: true,
1750                        }) as Box<dyn Version>,
1751                    ])
1752                })
1753            }
1754
1755            fn get_latest_matching<'a>(
1756                &'a self,
1757                _name: &'a deps_core::PackageName,
1758                _req: &'a deps_core::VersionReq,
1759            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1760            {
1761                Box::pin(async move { Ok(None) })
1762            }
1763
1764            fn search_raw<'a>(
1765                &'a self,
1766                _query: &'a str,
1767                _limit: usize,
1768            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1769            {
1770                Box::pin(async move { Ok(vec![]) })
1771            }
1772
1773            fn select_latest_matching(
1774                &self,
1775                versions: &[Box<dyn Version>],
1776                _req: &deps_core::VersionReq,
1777            ) -> Option<usize> {
1778                versions
1779                    .iter()
1780                    .position(|v| !v.removal_status().blocks_resolution())
1781            }
1782
1783            fn as_any(&self) -> &dyn Any {
1784                self
1785            }
1786        }
1787
1788        let registry: Arc<dyn Registry> = Arc::new(YankedRegistry);
1789        let packages = vec![PackageName::new("serde")];
1790
1791        let result = fetch_latest_versions_parallel(
1792            registry,
1793            with_registry_source(packages),
1794            &HashMap::new(),
1795            None,
1796            deps_core::freshness::FreshnessSettings::default(),
1797            10,
1798            10,
1799            None,
1800        )
1801        .await;
1802
1803        let serde = result
1804            .versions
1805            .get("serde")
1806            .expect("serde should be fetched");
1807        assert_eq!(serde.latest, "1.0.214", "latest must skip the yanked entry");
1808        assert_eq!(
1809            &*serde.available,
1810            &[
1811                ConcreteVersion::new("1.0.214"),
1812                ConcreteVersion::new("1.0.213")
1813            ],
1814            "available must remain unfiltered"
1815        );
1816        assert_eq!(
1817            &*serde.yanked,
1818            &[(
1819                ConcreteVersion::new("1.0.213"),
1820                deps_core::RemovalStatus::Yanked
1821            )],
1822            "yanked must carry only the entries reported as yanked, paired with their status"
1823        );
1824    }
1825
1826    /// Issue #227 C3: `PackageVersions.published_at` must be the publish time of
1827    /// `latest` specifically, not of some other entry in `available` — a risk the old
1828    /// two-parallel-map design (a separate `HashMap<String, PublishTime>` alongside the
1829    /// version map) could not structurally rule out. Bundling `published_at` onto the
1830    /// same struct as `latest`/`available`/`yanked` makes that desync impossible: both
1831    /// are set from the same `Box<dyn Version>` in the same match arm.
1832    #[tokio::test]
1833    async fn test_fetch_latest_versions_parallel_carries_published_at_for_latest_only() {
1834        use deps_core::freshness::PublishTime;
1835        use deps_core::{Metadata, Registry, Version};
1836        use std::any::Any;
1837
1838        #[derive(Debug)]
1839        struct MockVersion {
1840            version: ConcreteVersion,
1841            yanked: bool,
1842            published_at: Option<PublishTime>,
1843        }
1844
1845        impl Version for MockVersion {
1846            fn version_string(&self) -> &ConcreteVersion {
1847                &self.version
1848            }
1849            fn removal_status(&self) -> deps_core::RemovalStatus {
1850                deps_core::RemovalStatus::from_yanked(self.yanked)
1851            }
1852            fn published_at(&self) -> Option<PublishTime> {
1853                self.published_at
1854            }
1855            fn as_any(&self) -> &dyn Any {
1856                self
1857            }
1858        }
1859
1860        struct DatedRegistry;
1861
1862        impl Registry for DatedRegistry {
1863            fn get_versions<'a>(
1864                &'a self,
1865                _name: &'a deps_core::PackageName,
1866            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1867            {
1868                Box::pin(async move {
1869                    Ok(vec![
1870                        Box::new(MockVersion {
1871                            version: "1.0.214".into(),
1872                            yanked: false,
1873                            published_at: Some(PublishTime::from_unix_secs(2_000)),
1874                        }) as Box<dyn Version>,
1875                        Box::new(MockVersion {
1876                            version: "1.0.213".into(),
1877                            yanked: true,
1878                            // Deliberately a different timestamp — proves the fetch loop
1879                            // never accidentally attaches this entry's age to `latest`.
1880                            published_at: Some(PublishTime::from_unix_secs(1_000)),
1881                        }) as Box<dyn Version>,
1882                    ])
1883                })
1884            }
1885
1886            fn get_latest_matching<'a>(
1887                &'a self,
1888                _name: &'a deps_core::PackageName,
1889                _req: &'a deps_core::VersionReq,
1890            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1891            {
1892                Box::pin(async move { Ok(None) })
1893            }
1894
1895            fn search_raw<'a>(
1896                &'a self,
1897                _query: &'a str,
1898                _limit: usize,
1899            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1900            {
1901                Box::pin(async move { Ok(vec![]) })
1902            }
1903
1904            fn select_latest_matching(
1905                &self,
1906                versions: &[Box<dyn Version>],
1907                _req: &deps_core::VersionReq,
1908            ) -> Option<usize> {
1909                versions
1910                    .iter()
1911                    .position(|v| !v.removal_status().blocks_resolution())
1912            }
1913
1914            fn as_any(&self) -> &dyn Any {
1915                self
1916            }
1917        }
1918
1919        let registry: Arc<dyn Registry> = Arc::new(DatedRegistry);
1920        let packages = vec![PackageName::new("serde")];
1921
1922        let result = fetch_latest_versions_parallel(
1923            registry,
1924            with_registry_source(packages),
1925            &HashMap::new(),
1926            None,
1927            deps_core::freshness::FreshnessSettings::default(),
1928            10,
1929            10,
1930            None,
1931        )
1932        .await;
1933
1934        let serde = result
1935            .versions
1936            .get("serde")
1937            .expect("serde should be fetched");
1938        assert_eq!(serde.latest, "1.0.214");
1939        assert_eq!(
1940            serde.published_at,
1941            Some(PublishTime::from_unix_secs(2_000)),
1942            "published_at must be 1.0.214's own timestamp, not the yanked 1.0.213 entry's"
1943        );
1944    }
1945
1946    /// Issue #660/#661 tier-1 backfill: a `Version::license()` override on the
1947    /// already-fetched version-list entry (today, only Composer's `impl_version!`
1948    /// includes one — `deps-composer/src/types.rs`) must flow into
1949    /// `FetchResult::licenses`, keyed by package name — this is what
1950    /// `merge_registry_fetch_result` then merges into `DocumentState::licenses`,
1951    /// letting #661's policy diagnostics see it without a second, ecosystem-specific
1952    /// fetch. An empty `license()` (every other ecosystem's default) must produce no
1953    /// entry at all, not an empty-vec one — `merge_licenses` relies on this to never
1954    /// accidentally overwrite real data with a spurious empty entry.
1955    #[tokio::test]
1956    async fn test_fetch_latest_versions_parallel_carries_license_into_fetch_result() {
1957        use deps_core::{Metadata, Registry, Version};
1958        use std::any::Any;
1959
1960        #[derive(Debug)]
1961        struct MockVersion {
1962            version: ConcreteVersion,
1963            license: Vec<String>,
1964        }
1965
1966        impl Version for MockVersion {
1967            fn version_string(&self) -> &ConcreteVersion {
1968                &self.version
1969            }
1970            fn as_any(&self) -> &dyn Any {
1971                self
1972            }
1973            fn license(&self) -> &[String] {
1974                &self.license
1975            }
1976        }
1977
1978        struct LicensedRegistry;
1979
1980        impl Registry for LicensedRegistry {
1981            fn get_versions<'a>(
1982                &'a self,
1983                name: &'a PackageName,
1984            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1985            {
1986                let license = if name.as_str() == "licensed-pkg" {
1987                    vec!["MIT".to_string()]
1988                } else {
1989                    vec![]
1990                };
1991                Box::pin(async move {
1992                    Ok(vec![Box::new(MockVersion {
1993                        version: "1.0.0".into(),
1994                        license,
1995                    }) as Box<dyn Version>])
1996                })
1997            }
1998
1999            fn get_latest_matching<'a>(
2000                &'a self,
2001                _name: &'a PackageName,
2002                _req: &'a deps_core::VersionReq,
2003            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2004            {
2005                Box::pin(async move { Ok(None) })
2006            }
2007
2008            fn search_raw<'a>(
2009                &'a self,
2010                _query: &'a str,
2011                _limit: usize,
2012            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2013            {
2014                Box::pin(async move { Ok(vec![]) })
2015            }
2016
2017            fn select_latest_matching(
2018                &self,
2019                versions: &[Box<dyn Version>],
2020                _req: &deps_core::VersionReq,
2021            ) -> Option<usize> {
2022                (!versions.is_empty()).then_some(0)
2023            }
2024
2025            fn as_any(&self) -> &dyn Any {
2026                self
2027            }
2028        }
2029
2030        let registry: Arc<dyn Registry> = Arc::new(LicensedRegistry);
2031        let packages = vec![
2032            PackageName::new("licensed-pkg"),
2033            PackageName::new("unlicensed-pkg"),
2034        ];
2035
2036        let result = fetch_latest_versions_parallel(
2037            registry,
2038            with_registry_source(packages),
2039            &HashMap::new(),
2040            None,
2041            deps_core::freshness::FreshnessSettings::default(),
2042            10,
2043            10,
2044            None,
2045        )
2046        .await;
2047
2048        assert_eq!(
2049            result.licenses.get(&PackageName::new("licensed-pkg")),
2050            Some(&vec!["MIT".to_string()])
2051        );
2052        assert!(
2053            !result
2054                .licenses
2055                .contains_key(&PackageName::new("unlicensed-pkg")),
2056            "an empty Version::license() must produce no entry, not an empty-vec one"
2057        );
2058    }
2059
2060    /// #339 regression guard: the bulk diagnostics-cache-population pass must call the
2061    /// freshness-aware `Registry::get_versions_with`, not the freshness-blind `get_versions`,
2062    /// for a registry that implements the override — otherwise `published_at` (and the
2063    /// cooldown-context diagnostic message it drives) is silently always `None` in
2064    /// production even though hover's separate call path gets it right.
2065    #[tokio::test]
2066    async fn test_fetch_latest_versions_parallel_uses_get_versions_with_for_freshness() {
2067        use deps_core::freshness::{FreshnessSettings, PublishTime};
2068        use deps_core::{Metadata, Registry, Version};
2069        use std::any::Any;
2070
2071        #[derive(Debug)]
2072        struct MockVersion {
2073            version: ConcreteVersion,
2074            published_at: Option<PublishTime>,
2075        }
2076
2077        impl Version for MockVersion {
2078            fn version_string(&self) -> &ConcreteVersion {
2079                &self.version
2080            }
2081            fn published_at(&self) -> Option<PublishTime> {
2082                self.published_at
2083            }
2084            fn as_any(&self) -> &dyn Any {
2085                self
2086            }
2087        }
2088
2089        struct FreshnessAwareRegistry;
2090
2091        impl Registry for FreshnessAwareRegistry {
2092            fn get_versions<'a>(
2093                &'a self,
2094                _name: &'a deps_core::PackageName,
2095            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2096            {
2097                // Deliberately returns no `published_at` — if the fetch loop ever calls
2098                // this instead of `get_versions_with`, the assertion below catches it.
2099                Box::pin(async move {
2100                    Ok(vec![Box::new(MockVersion {
2101                        version: "1.0.0".into(),
2102                        published_at: None,
2103                    }) as Box<dyn Version>])
2104                })
2105            }
2106
2107            fn get_versions_with<'a>(
2108                &'a self,
2109                _name: &'a deps_core::PackageName,
2110                freshness: FreshnessSettings,
2111            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2112            {
2113                Box::pin(async move {
2114                    Ok(vec![Box::new(MockVersion {
2115                        version: "1.0.0".into(),
2116                        published_at: freshness
2117                            .enabled
2118                            .then(|| PublishTime::from_unix_secs(5_000)),
2119                    }) as Box<dyn Version>])
2120                })
2121            }
2122
2123            fn get_latest_matching<'a>(
2124                &'a self,
2125                _name: &'a deps_core::PackageName,
2126                _req: &'a deps_core::VersionReq,
2127            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2128            {
2129                Box::pin(async move { Ok(None) })
2130            }
2131
2132            fn search_raw<'a>(
2133                &'a self,
2134                _query: &'a str,
2135                _limit: usize,
2136            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2137            {
2138                Box::pin(async move { Ok(vec![]) })
2139            }
2140
2141            fn select_latest_matching(
2142                &self,
2143                versions: &[Box<dyn Version>],
2144                _req: &deps_core::VersionReq,
2145            ) -> Option<usize> {
2146                if versions.is_empty() { None } else { Some(0) }
2147            }
2148
2149            fn as_any(&self) -> &dyn Any {
2150                self
2151            }
2152        }
2153
2154        let registry: Arc<dyn Registry> = Arc::new(FreshnessAwareRegistry);
2155        let packages = vec![PackageName::new("widget")];
2156
2157        let result = fetch_latest_versions_parallel(
2158            registry,
2159            with_registry_source(packages),
2160            &HashMap::new(),
2161            None,
2162            FreshnessSettings::default(),
2163            10,
2164            10,
2165            None,
2166        )
2167        .await;
2168
2169        let widget = result
2170            .versions
2171            .get("widget")
2172            .expect("widget should be fetched");
2173        assert_eq!(
2174            widget.published_at,
2175            Some(PublishTime::from_unix_secs(5_000)),
2176            "published_at must come from get_versions_with, not the freshness-blind \
2177             get_versions (#339)"
2178        );
2179    }
2180
2181    /// #424 S1: `fetch_latest_versions_parallel` must call `select_latest_matching_with_context`
2182    /// with the `minimum_stability` value it was given, not the plain `select_latest_matching`
2183    /// — otherwise a registry with manifest-level stability state (e.g. Composer's
2184    /// `minimum-stability`) never actually sees it, and #424's S1 fix stays unreachable dead
2185    /// code from the live LSP fetch path's perspective (critic S3/tester's reachability gap).
2186    #[tokio::test]
2187    async fn test_fetch_latest_versions_parallel_threads_minimum_stability_into_select_latest_matching_with_context()
2188     {
2189        use deps_core::{Metadata, Registry, Version};
2190        use std::any::Any;
2191        use std::sync::Mutex;
2192
2193        #[derive(Debug)]
2194        struct MockVersion {
2195            version: ConcreteVersion,
2196        }
2197
2198        impl Version for MockVersion {
2199            fn version_string(&self) -> &ConcreteVersion {
2200                &self.version
2201            }
2202            fn as_any(&self) -> &dyn Any {
2203                self
2204            }
2205        }
2206
2207        struct ContextAwareRegistry {
2208            // Records every `minimum_stability` value observed, in call order — an empty
2209            // `Vec` after the fetch means the `_with_context` method was never invoked.
2210            seen_minimum_stability: Mutex<Vec<Option<String>>>,
2211        }
2212
2213        impl Registry for ContextAwareRegistry {
2214            fn get_versions<'a>(
2215                &'a self,
2216                _name: &'a deps_core::PackageName,
2217            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2218            {
2219                Box::pin(async move {
2220                    Ok(vec![Box::new(MockVersion {
2221                        version: "1.0.0".into(),
2222                    }) as Box<dyn Version>])
2223                })
2224            }
2225
2226            fn get_latest_matching<'a>(
2227                &'a self,
2228                _name: &'a deps_core::PackageName,
2229                _req: &'a deps_core::VersionReq,
2230            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2231            {
2232                Box::pin(async move { Ok(None) })
2233            }
2234
2235            fn search_raw<'a>(
2236                &'a self,
2237                _query: &'a str,
2238                _limit: usize,
2239            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2240            {
2241                Box::pin(async move { Ok(vec![]) })
2242            }
2243
2244            // Deliberately NOT overridden: if the fetch loop ever calls the plain
2245            // `select_latest_matching` instead of the `_with_context` variant, this default
2246            // (`None`) makes the pick fail, which the fallback below records as "not found" —
2247            // distinguishable from the success path this test asserts on.
2248            fn select_latest_matching_with_context(
2249                &self,
2250                versions: &[Box<dyn Version>],
2251                _req: &deps_core::VersionReq,
2252                minimum_stability: Option<&str>,
2253            ) -> Option<usize> {
2254                self.seen_minimum_stability
2255                    .lock()
2256                    .unwrap_or_else(|p| p.into_inner())
2257                    .push(minimum_stability.map(str::to_string));
2258                if versions.is_empty() { None } else { Some(0) }
2259            }
2260
2261            fn as_any(&self) -> &dyn Any {
2262                self
2263            }
2264        }
2265
2266        let registry = Arc::new(ContextAwareRegistry {
2267            seen_minimum_stability: Mutex::new(Vec::new()),
2268        });
2269        let packages = vec![PackageName::new("vendor/pkg")];
2270
2271        let result = fetch_latest_versions_parallel(
2272            Arc::clone(&registry) as Arc<dyn Registry>,
2273            with_registry_source(packages),
2274            &HashMap::new(),
2275            None,
2276            deps_core::freshness::FreshnessSettings::default(),
2277            10,
2278            10,
2279            Some("beta"),
2280        )
2281        .await;
2282
2283        assert_eq!(
2284            *registry
2285                .seen_minimum_stability
2286                .lock()
2287                .unwrap_or_else(|p| p.into_inner()),
2288            vec![Some("beta".to_string())],
2289            "select_latest_matching_with_context must receive the caller's minimum_stability"
2290        );
2291        assert!(
2292            result.versions.contains_key("vendor/pkg"),
2293            "the pick must still succeed via the _with_context path"
2294        );
2295    }
2296
2297    /// #424 S1: the `get_latest_matching` fallback path (used when the pure list-based pick
2298    /// finds nothing) must also thread `minimum_stability` through its own `_with_context`
2299    /// variant.
2300    #[tokio::test]
2301    async fn test_fetch_latest_versions_parallel_threads_minimum_stability_into_get_latest_matching_with_context()
2302     {
2303        use deps_core::{Metadata, Registry, Version};
2304        use std::any::Any;
2305        use std::sync::Mutex;
2306
2307        #[derive(Debug)]
2308        struct MockVersion {
2309            version: ConcreteVersion,
2310        }
2311
2312        impl Version for MockVersion {
2313            fn version_string(&self) -> &ConcreteVersion {
2314                &self.version
2315            }
2316            fn as_any(&self) -> &dyn Any {
2317                self
2318            }
2319        }
2320
2321        struct FallbackContextAwareRegistry {
2322            // Records every `minimum_stability` value observed, in call order — an empty
2323            // `Vec` after the fetch means the `_with_context` method was never invoked.
2324            seen_minimum_stability: Mutex<Vec<Option<String>>>,
2325        }
2326
2327        impl Registry for FallbackContextAwareRegistry {
2328            fn get_versions<'a>(
2329                &'a self,
2330                _name: &'a deps_core::PackageName,
2331            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2332            {
2333                // Empty list forces the fetch loop's `get_latest_matching_with_context`
2334                // fallback (the pure list-based pick over an empty list finds nothing).
2335                Box::pin(async move { Ok(vec![]) })
2336            }
2337
2338            fn get_latest_matching<'a>(
2339                &'a self,
2340                _name: &'a deps_core::PackageName,
2341                _req: &'a deps_core::VersionReq,
2342            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2343            {
2344                Box::pin(async move { Ok(None) })
2345            }
2346
2347            fn get_latest_matching_with_context<'a>(
2348                &'a self,
2349                _name: &'a deps_core::PackageName,
2350                _req: &'a deps_core::VersionReq,
2351                minimum_stability: Option<&'a str>,
2352            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2353            {
2354                self.seen_minimum_stability
2355                    .lock()
2356                    .unwrap_or_else(|p| p.into_inner())
2357                    .push(minimum_stability.map(str::to_string));
2358                Box::pin(async move {
2359                    Ok(Some(Box::new(MockVersion {
2360                        version: "2.0.0-beta1".into(),
2361                    }) as Box<dyn Version>))
2362                })
2363            }
2364
2365            fn search_raw<'a>(
2366                &'a self,
2367                _query: &'a str,
2368                _limit: usize,
2369            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2370            {
2371                Box::pin(async move { Ok(vec![]) })
2372            }
2373
2374            fn as_any(&self) -> &dyn Any {
2375                self
2376            }
2377        }
2378
2379        let registry = Arc::new(FallbackContextAwareRegistry {
2380            seen_minimum_stability: Mutex::new(Vec::new()),
2381        });
2382        let packages = vec![PackageName::new("vendor/pkg")];
2383
2384        let result = fetch_latest_versions_parallel(
2385            Arc::clone(&registry) as Arc<dyn Registry>,
2386            with_registry_source(packages),
2387            &HashMap::new(),
2388            None,
2389            deps_core::freshness::FreshnessSettings::default(),
2390            10,
2391            10,
2392            Some("beta"),
2393        )
2394        .await;
2395
2396        assert_eq!(
2397            *registry
2398                .seen_minimum_stability
2399                .lock()
2400                .unwrap_or_else(|p| p.into_inner()),
2401            vec![Some("beta".to_string())],
2402            "get_latest_matching_with_context must receive the caller's minimum_stability"
2403        );
2404        let widget = result
2405            .versions
2406            .get("vendor/pkg")
2407            .expect("fallback pick should succeed");
2408        assert_eq!(widget.latest, "2.0.0-beta1");
2409    }
2410
2411    /// S3 regression: a registry whose `get_versions` list is incomplete (e.g. Go's
2412    /// `/@v/list`, which never enumerates pseudo-versions and can be entirely empty for an
2413    /// untagged module) must not render the package as "no version found" just because
2414    /// `select_latest_matching`'s pure list-based pick came up empty — the fetch loop must
2415    /// fall back to the registry's own `get_latest_matching`.
2416    #[tokio::test]
2417    async fn test_fetch_falls_back_to_get_latest_matching_when_list_based_pick_finds_nothing() {
2418        use deps_core::{Metadata, Registry, Version};
2419        use std::any::Any;
2420
2421        #[derive(Debug)]
2422        struct MockVersion {
2423            version: ConcreteVersion,
2424        }
2425
2426        impl Version for MockVersion {
2427            fn version_string(&self) -> &ConcreteVersion {
2428                &self.version
2429            }
2430            fn as_any(&self) -> &dyn Any {
2431                self
2432            }
2433        }
2434
2435        /// Mimics an untagged Go module: `get_versions` (the list endpoint) is empty, but
2436        /// `get_latest_matching` (a different, more complete endpoint) still resolves a
2437        /// pseudo-version. `select_latest_matching` deliberately relies on the trait
2438        /// default (`None`), matching a real registry whose list-based pick has nothing to
2439        /// work with.
2440        struct UntaggedModuleRegistry;
2441
2442        impl Registry for UntaggedModuleRegistry {
2443            fn get_versions<'a>(
2444                &'a self,
2445                _name: &'a deps_core::PackageName,
2446            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2447            {
2448                Box::pin(async move { Ok(vec![]) })
2449            }
2450
2451            fn get_latest_matching<'a>(
2452                &'a self,
2453                _name: &'a deps_core::PackageName,
2454                _req: &'a deps_core::VersionReq,
2455            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2456            {
2457                Box::pin(async move {
2458                    Ok(Some(Box::new(MockVersion {
2459                        version: "v0.0.0-20191109021931-daa7c04131f5".into(),
2460                    }) as Box<dyn Version>))
2461                })
2462            }
2463
2464            fn search_raw<'a>(
2465                &'a self,
2466                _query: &'a str,
2467                _limit: usize,
2468            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2469            {
2470                Box::pin(async move { Ok(vec![]) })
2471            }
2472
2473            fn as_any(&self) -> &dyn Any {
2474                self
2475            }
2476        }
2477
2478        let registry: Arc<dyn Registry> = Arc::new(UntaggedModuleRegistry);
2479        let packages = vec![PackageName::new("golang.org/x/exp")];
2480
2481        let result = fetch_latest_versions_parallel(
2482            registry,
2483            with_registry_source(packages),
2484            &HashMap::new(),
2485            None,
2486            deps_core::freshness::FreshnessSettings::default(),
2487            5,
2488            10,
2489            None,
2490        )
2491        .await;
2492
2493        assert_eq!(
2494            result
2495                .versions
2496                .get("golang.org/x/exp")
2497                .map(|v| v.latest.as_str()),
2498            Some("v0.0.0-20191109021931-daa7c04131f5"),
2499            "must fall back to get_latest_matching instead of reporting no version found"
2500        );
2501    }
2502
2503    #[tokio::test]
2504    async fn test_fetch_registry_error_handled() {
2505        use deps_core::{Metadata, Registry, Version};
2506        use std::any::Any;
2507
2508        struct ErrorRegistry;
2509
2510        impl Registry for ErrorRegistry {
2511            fn get_versions<'a>(
2512                &'a self,
2513                name: &'a deps_core::PackageName,
2514            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2515            {
2516                Box::pin(async move {
2517                    Err(deps_core::error::DepsError::CacheError(format!(
2518                        "Failed to fetch package: {}",
2519                        name.as_str()
2520                    )))
2521                })
2522            }
2523
2524            fn get_latest_matching<'a>(
2525                &'a self,
2526                name: &'a deps_core::PackageName,
2527                _req: &'a deps_core::VersionReq,
2528            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2529            {
2530                Box::pin(async move {
2531                    Err(deps_core::error::DepsError::CacheError(format!(
2532                        "Failed to fetch package: {}",
2533                        name.as_str()
2534                    )))
2535                })
2536            }
2537
2538            fn search_raw<'a>(
2539                &'a self,
2540                _query: &'a str,
2541                _limit: usize,
2542            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2543            {
2544                Box::pin(async move { Ok(vec![]) })
2545            }
2546
2547            fn as_any(&self) -> &dyn Any {
2548                self
2549            }
2550        }
2551
2552        let registry: Arc<dyn Registry> = Arc::new(ErrorRegistry);
2553        let packages = vec![
2554            PackageName::new("package-1"),
2555            PackageName::new("package-2"),
2556            PackageName::new("package-3"),
2557        ];
2558
2559        let result = fetch_latest_versions_parallel(
2560            registry,
2561            with_registry_source(packages),
2562            &HashMap::new(),
2563            None,
2564            deps_core::freshness::FreshnessSettings::default(),
2565            5,
2566            10,
2567            None,
2568        )
2569        .await;
2570
2571        assert!(
2572            result.versions.is_empty(),
2573            "All packages with errors should be omitted from results"
2574        );
2575        assert_eq!(
2576            result.failed_count, 3,
2577            "All 3 packages should be marked as failed"
2578        );
2579        // #267: a fetch error must be recorded per-package, not just counted,
2580        // so diagnostic generation can tell "fetch failed" apart from
2581        // "genuinely not found" instead of reporting "Unknown package".
2582        assert_eq!(
2583            result.fetch_failed,
2584            HashMap::from([
2585                (PackageName::new("package-1"), FetchFailure::Transient),
2586                (PackageName::new("package-2"), FetchFailure::Transient),
2587                (PackageName::new("package-3"), FetchFailure::Transient),
2588            ]),
2589            "every errored package must be recorded in fetch_failed"
2590        );
2591    }
2592
2593    /// #1209: the `"fetch failed"` WARN's `package` field used to interpolate the raw,
2594    /// manifest-derived name directly (`package = %name`) — a credential embedded in a
2595    /// name-shaped manifest field (e.g. via property interpolation) reached this log
2596    /// verbatim. Now redacted via [`deps_core::PackageName::for_tracing`]. Asserts against
2597    /// the fully rendered line (not just the event message), mirroring
2598    /// `deps-maven::registry::tests::test_fetch_publish_times_failure_log_redacts_url_query_string`'s
2599    /// precedent: a leak reintroduced only in an enclosing span/field would otherwise pass a
2600    /// message-only assertion.
2601    #[tokio::test]
2602    async fn test_fetch_failed_log_redacts_credential_shaped_package_name() {
2603        use deps_core::{Metadata, Registry, Version};
2604        use std::any::Any;
2605
2606        // M1 (impl-critic on the original #1209 fix): a real ecosystem registry's
2607        // `get_versions` runs inside a `#[tracing::instrument(fields(package = ...))]` span
2608        // (e.g. `deps-maven`'s `get_metadata`) — the exact shape the original audit's most
2609        // defensible finding hinged on (`warn_rejected_value`'s len-only design defeated by
2610        // its own enclosing span). Without an instrumented mock here, this test would still
2611        // pass if a *span*-level redaction fix were reverted, since only `fetch.rs`'s own
2612        // event field would be exercised. `inner_fetch` mirrors the production idiom exactly
2613        // (`fields(package = %name.for_tracing())`) so a regression to `?name`/`%name` here
2614        // would fail this test's assertions.
2615        #[tracing::instrument(skip_all, fields(package = %name.for_tracing()), level = "debug")]
2616        async fn inner_fetch(name: &PackageName) -> deps_core::Result<Vec<Box<dyn Version>>> {
2617            // An event fired *from inside* the span (not just the span's own fields) is what
2618            // makes `tracing_subscriber`'s default formatter render the span context
2619            // (`inner_fetch{package=...}: ...`) into the captured line at all — a span with no
2620            // event inside it produces no output on its own.
2621            tracing::debug!("mock registry fetch invoked");
2622            Err(deps_core::error::DepsError::CacheError(
2623                "transient backend failure".to_string(),
2624            ))
2625        }
2626
2627        struct AlwaysFailsRegistry;
2628
2629        impl Registry for AlwaysFailsRegistry {
2630            fn get_versions<'a>(
2631                &'a self,
2632                name: &'a deps_core::PackageName,
2633            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2634            {
2635                Box::pin(inner_fetch(name))
2636            }
2637
2638            fn get_latest_matching<'a>(
2639                &'a self,
2640                _name: &'a deps_core::PackageName,
2641                _req: &'a deps_core::VersionReq,
2642            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2643            {
2644                Box::pin(async move {
2645                    Err(deps_core::error::DepsError::CacheError(
2646                        "transient backend failure".to_string(),
2647                    ))
2648                })
2649            }
2650
2651            fn search_raw<'a>(
2652                &'a self,
2653                _query: &'a str,
2654                _limit: usize,
2655            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2656            {
2657                Box::pin(async move { Ok(vec![]) })
2658            }
2659
2660            fn as_any(&self) -> &dyn Any {
2661                self
2662            }
2663        }
2664
2665        let sentinel_name =
2666            PackageName::new("com.example:deploy:AUDITSENTINEL0000@git.internal.corp");
2667        let registry: Arc<dyn Registry> = Arc::new(AlwaysFailsRegistry);
2668        let packages = vec![sentinel_name.clone()];
2669
2670        let log =
2671            deps_core::test_util::capture_tracing_output_async_at(tracing::Level::DEBUG, async {
2672                let result = fetch_latest_versions_parallel(
2673                    registry,
2674                    with_registry_source(packages),
2675                    &HashMap::new(),
2676                    None,
2677                    deps_core::freshness::FreshnessSettings::default(),
2678                    5,
2679                    10,
2680                    None,
2681                )
2682                .await;
2683                assert_eq!(result.failed_count, 1);
2684            })
2685            .await;
2686
2687        assert!(
2688            log.contains("fetch failed"),
2689            "expected the fetch-failed WARN to fire: {log:?}"
2690        );
2691        assert!(
2692            log.contains("mock registry fetch invoked"),
2693            "expected the in-span event to fire — without it the span's fields never render, \
2694             silently downgrading this test back to event-field-only coverage: {log:?}"
2695        );
2696        assert!(
2697            !log.contains("AUDITSENTINEL0000"),
2698            "tracing output leaked a credential-shaped package name: {log:?}"
2699        );
2700        assert!(
2701            log.contains("git.internal.corp"),
2702            "host should survive redaction: {log:?}"
2703        );
2704    }
2705
2706    #[tokio::test]
2707    async fn test_fetch_not_found_is_not_recorded_as_fetch_failed() {
2708        // #267 C1: a genuine not-found (`DepsError::PackageNotFound`, the
2709        // variant npm/PyPI/Go/Swift map a 404 to) means the registry was
2710        // successfully asked and answered "no such package" — recording it
2711        // in `fetch_failed` would make `generate_diagnostics_from_cache`
2712        // report "Registry lookup failed" instead of "Unknown package" for
2713        // the common typo'd-dependency case, inverting the bug this field
2714        // exists to fix.
2715        use deps_core::{Metadata, Registry, Version};
2716        use std::any::Any;
2717
2718        struct NotFoundRegistry;
2719
2720        impl Registry for NotFoundRegistry {
2721            fn get_versions<'a>(
2722                &'a self,
2723                name: &'a deps_core::PackageName,
2724            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2725            {
2726                Box::pin(async move {
2727                    Err(deps_core::error::DepsError::PackageNotFound {
2728                        package: name.as_str().into(),
2729                        registry: "mock",
2730                    })
2731                })
2732            }
2733
2734            fn get_latest_matching<'a>(
2735                &'a self,
2736                name: &'a deps_core::PackageName,
2737                _req: &'a deps_core::VersionReq,
2738            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2739            {
2740                Box::pin(async move {
2741                    Err(deps_core::error::DepsError::PackageNotFound {
2742                        package: name.as_str().into(),
2743                        registry: "mock",
2744                    })
2745                })
2746            }
2747
2748            fn search_raw<'a>(
2749                &'a self,
2750                _query: &'a str,
2751                _limit: usize,
2752            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2753            {
2754                Box::pin(async move { Ok(vec![]) })
2755            }
2756
2757            fn as_any(&self) -> &dyn Any {
2758                self
2759            }
2760        }
2761
2762        let registry: Arc<dyn Registry> = Arc::new(NotFoundRegistry);
2763        let packages = vec![PackageName::new("typo-pkg")];
2764
2765        let result = fetch_latest_versions_parallel(
2766            registry,
2767            with_registry_source(packages),
2768            &HashMap::new(),
2769            None,
2770            deps_core::freshness::FreshnessSettings::default(),
2771            5,
2772            10,
2773            None,
2774        )
2775        .await;
2776
2777        assert!(result.versions.is_empty());
2778        assert!(
2779            result.fetch_failed.is_empty(),
2780            "a genuine not-found must not be recorded in fetch_failed, or \
2781             generate_diagnostics_from_cache would report it as a registry \
2782             error instead of Unknown package"
2783        );
2784    }
2785
2786    /// Regression for #550: a registry fetch that genuinely succeeds (no error at
2787    /// either the list-based pick or the `get_latest_matching` fallback) but resolves
2788    /// to zero versions must be recorded in `no_comparable_versions`, distinct from
2789    /// both a normal successful fetch (`versions`) and a real failure
2790    /// (`fetch_failed`). Mirrors `GithubActionsRegistry::get_versions("dtolnay/rust-toolchain")`,
2791    /// whose sole tag `v1` doesn't parse as full semver, so `tags_to_versions` filters
2792    /// it out and returns `Ok(vec![])`.
2793    #[tokio::test]
2794    async fn test_fetch_success_with_zero_versions_is_recorded_as_no_comparable_versions() {
2795        use deps_core::{Metadata, Registry, Version};
2796        use std::any::Any;
2797
2798        struct EmptyButRealRegistry;
2799
2800        impl Registry for EmptyButRealRegistry {
2801            fn get_versions<'a>(
2802                &'a self,
2803                _name: &'a deps_core::PackageName,
2804            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2805            {
2806                Box::pin(async move { Ok(vec![]) })
2807            }
2808
2809            fn get_latest_matching<'a>(
2810                &'a self,
2811                _name: &'a deps_core::PackageName,
2812                _req: &'a deps_core::VersionReq,
2813            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2814            {
2815                Box::pin(async move { Ok(None) })
2816            }
2817
2818            fn search_raw<'a>(
2819                &'a self,
2820                _query: &'a str,
2821                _limit: usize,
2822            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2823            {
2824                Box::pin(async move { Ok(vec![]) })
2825            }
2826
2827            fn as_any(&self) -> &dyn Any {
2828                self
2829            }
2830        }
2831
2832        let registry: Arc<dyn Registry> = Arc::new(EmptyButRealRegistry);
2833        let packages = vec![PackageName::new("dtolnay/rust-toolchain")];
2834
2835        let result = fetch_latest_versions_parallel(
2836            registry,
2837            with_registry_source(packages),
2838            &HashMap::new(),
2839            None,
2840            deps_core::freshness::FreshnessSettings::default(),
2841            5,
2842            10,
2843            None,
2844        )
2845        .await;
2846
2847        assert!(result.versions.is_empty());
2848        assert!(
2849            result.fetch_failed.is_empty(),
2850            "a genuine empty-but-successful fetch must not be recorded as a fetch \
2851             failure, or generate_diagnostics_from_cache would report a registry \
2852             error instead of nothing"
2853        );
2854        assert!(
2855            result
2856                .no_comparable_versions
2857                .contains(&PackageName::new("dtolnay/rust-toolchain")),
2858            "a package whose fetch succeeded with zero comparable versions must be \
2859             recorded in no_comparable_versions, or R5 would misreport it as Unknown \
2860             package; got: {:?}",
2861            result.no_comparable_versions
2862        );
2863    }
2864
2865    #[tokio::test]
2866    async fn test_fetch_http_404_is_not_recorded_as_fetch_failed() {
2867        // Same as `test_fetch_not_found_is_not_recorded_as_fetch_failed`, for
2868        // the ecosystems (Cargo, Maven, Gradle, Bundler, Dart, Composer,
2869        // NuGet) that propagate a raw `DepsError::HttpStatus { status: 404 }`
2870        // instead of mapping it to `PackageNotFound`.
2871        use deps_core::{Metadata, Registry, Version};
2872        use std::any::Any;
2873
2874        struct Http404Registry;
2875
2876        impl Registry for Http404Registry {
2877            fn get_versions<'a>(
2878                &'a self,
2879                name: &'a deps_core::PackageName,
2880            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2881            {
2882                Box::pin(async move {
2883                    Err(deps_core::error::DepsError::HttpStatus {
2884                        url: format!("https://example.com/{}", name.as_str()).into(),
2885                        status: 404,
2886                    })
2887                })
2888            }
2889
2890            fn get_latest_matching<'a>(
2891                &'a self,
2892                name: &'a deps_core::PackageName,
2893                _req: &'a deps_core::VersionReq,
2894            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2895            {
2896                Box::pin(async move {
2897                    Err(deps_core::error::DepsError::HttpStatus {
2898                        url: format!("https://example.com/{}", name.as_str()).into(),
2899                        status: 404,
2900                    })
2901                })
2902            }
2903
2904            fn search_raw<'a>(
2905                &'a self,
2906                _query: &'a str,
2907                _limit: usize,
2908            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2909            {
2910                Box::pin(async move { Ok(vec![]) })
2911            }
2912
2913            fn as_any(&self) -> &dyn Any {
2914                self
2915            }
2916        }
2917
2918        let registry: Arc<dyn Registry> = Arc::new(Http404Registry);
2919        let packages = vec![PackageName::new("typo-pkg")];
2920
2921        let result = fetch_latest_versions_parallel(
2922            registry,
2923            with_registry_source(packages),
2924            &HashMap::new(),
2925            None,
2926            deps_core::freshness::FreshnessSettings::default(),
2927            5,
2928            10,
2929            None,
2930        )
2931        .await;
2932
2933        assert!(result.versions.is_empty());
2934        assert!(
2935            result.fetch_failed.is_empty(),
2936            "a bare HTTP 404 must not be recorded in fetch_failed either"
2937        );
2938    }
2939
2940    #[tokio::test]
2941    async fn test_fetch_fallback_error_recorded_as_fetch_failed_unless_not_found() {
2942        // Go-shaped path: `get_versions` returns an empty list (nothing for
2943        // `select_latest_matching` to pick), so `fetch_latest_versions_parallel`
2944        // falls back to `get_latest_matching`. Exercises the fallback's own
2945        // error/timeout arms (previously zero test coverage — tester gap),
2946        // and confirms the same not-found-vs-failure gating (#267 C1) applies
2947        // there too, per-package via the `not_found` name.
2948        use deps_core::{Metadata, Registry, Version};
2949        use std::any::Any;
2950
2951        struct FallbackErrorRegistry;
2952
2953        impl Registry for FallbackErrorRegistry {
2954            fn get_versions<'a>(
2955                &'a self,
2956                _name: &'a deps_core::PackageName,
2957            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2958            {
2959                Box::pin(async move { Ok(vec![]) })
2960            }
2961
2962            fn get_latest_matching<'a>(
2963                &'a self,
2964                name: &'a deps_core::PackageName,
2965                _req: &'a deps_core::VersionReq,
2966            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2967            {
2968                let name = name.clone();
2969                Box::pin(async move {
2970                    if name.as_str() == "not-found" {
2971                        Err(deps_core::error::DepsError::PackageNotFound {
2972                            package: name.as_str().into(),
2973                            registry: "mock",
2974                        })
2975                    } else {
2976                        Err(deps_core::error::DepsError::CacheError(
2977                            "mock fallback failure".to_string(),
2978                        ))
2979                    }
2980                })
2981            }
2982
2983            fn search_raw<'a>(
2984                &'a self,
2985                _query: &'a str,
2986                _limit: usize,
2987            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2988            {
2989                Box::pin(async move { Ok(vec![]) })
2990            }
2991
2992            fn as_any(&self) -> &dyn Any {
2993                self
2994            }
2995        }
2996
2997        let registry: Arc<dyn Registry> = Arc::new(FallbackErrorRegistry);
2998        let packages = vec![PackageName::new("flaky"), PackageName::new("not-found")];
2999
3000        let result = fetch_latest_versions_parallel(
3001            registry,
3002            with_registry_source(packages),
3003            &HashMap::new(),
3004            None,
3005            deps_core::freshness::FreshnessSettings::default(),
3006            5,
3007            10,
3008            None,
3009        )
3010        .await;
3011
3012        assert!(result.versions.is_empty());
3013        assert_eq!(
3014            result.fetch_failed,
3015            HashMap::from([(PackageName::new("flaky"), FetchFailure::Transient)]),
3016            "the fallback's own non-not-found error must be recorded in fetch_failed, \
3017             but its not-found error must not"
3018        );
3019        assert_eq!(
3020            result.failed_count, 2,
3021            "both fallback failures count toward failed_count regardless of cause (S2)"
3022        );
3023    }
3024
3025    #[tokio::test]
3026    async fn test_fetch_fallback_timeout_recorded_as_fetch_failed() {
3027        // Timeout coverage for the `get_latest_matching` fallback path — a
3028        // timeout is never a "not found", so it must always land in
3029        // `fetch_failed` (and count toward `failed_count`, S2).
3030        use deps_core::{Metadata, Registry, Version};
3031        use std::any::Any;
3032        use std::time::Duration;
3033
3034        struct FallbackTimeoutRegistry;
3035
3036        impl Registry for FallbackTimeoutRegistry {
3037            fn get_versions<'a>(
3038                &'a self,
3039                _name: &'a deps_core::PackageName,
3040            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3041            {
3042                Box::pin(async move { Ok(vec![]) })
3043            }
3044
3045            fn get_latest_matching<'a>(
3046                &'a self,
3047                _name: &'a deps_core::PackageName,
3048                _req: &'a deps_core::VersionReq,
3049            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3050            {
3051                Box::pin(async move {
3052                    tokio::time::sleep(Duration::from_secs(10)).await;
3053                    Ok(None)
3054                })
3055            }
3056
3057            fn search_raw<'a>(
3058                &'a self,
3059                _query: &'a str,
3060                _limit: usize,
3061            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3062            {
3063                Box::pin(async move { Ok(vec![]) })
3064            }
3065
3066            fn as_any(&self) -> &dyn Any {
3067                self
3068            }
3069        }
3070
3071        let registry: Arc<dyn Registry> = Arc::new(FallbackTimeoutRegistry);
3072        let packages = vec![PackageName::new("slow-fallback")];
3073
3074        let result = fetch_latest_versions_parallel(
3075            registry,
3076            with_registry_source(packages),
3077            &HashMap::new(),
3078            None,
3079            deps_core::freshness::FreshnessSettings::default(),
3080            1,
3081            10,
3082            None,
3083        )
3084        .await;
3085
3086        assert!(result.versions.is_empty());
3087        assert_eq!(
3088            result.fetch_failed,
3089            HashMap::from([(PackageName::new("slow-fallback"), FetchFailure::Transient)])
3090        );
3091        assert_eq!(result.failed_count, 1);
3092    }
3093
3094    #[tokio::test]
3095    async fn test_first_error_prefers_actionable_error_over_not_found_regardless_of_race_order() {
3096        // #480: before this fix, `first_error` was simply whichever concurrent fetch
3097        // finished first, so a fast not-found could outrank a slower but more actionable
3098        // error. Here not-found resolves immediately and the actionable error resolves
3099        // after a delay, winning the race — `priority_error` must still make it win.
3100        use deps_core::{Metadata, Registry, Version};
3101        use std::any::Any;
3102        use std::time::Duration;
3103
3104        struct MixedErrorRegistry;
3105
3106        impl Registry for MixedErrorRegistry {
3107            fn get_versions<'a>(
3108                &'a self,
3109                name: &'a deps_core::PackageName,
3110            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3111            {
3112                Box::pin(async move {
3113                    if name.as_str() == "typo-pkg" {
3114                        Err(deps_core::error::DepsError::PackageNotFound {
3115                            package: name.as_str().into(),
3116                            registry: "mock",
3117                        })
3118                    } else {
3119                        tokio::time::sleep(Duration::from_millis(50)).await;
3120                        Err(deps_core::error::DepsError::CacheError(
3121                            "rate limit exceeded".to_string(),
3122                        ))
3123                    }
3124                })
3125            }
3126
3127            fn get_latest_matching<'a>(
3128                &'a self,
3129                _name: &'a deps_core::PackageName,
3130                _req: &'a deps_core::VersionReq,
3131            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3132            {
3133                Box::pin(async move { Ok(None) })
3134            }
3135
3136            fn search_raw<'a>(
3137                &'a self,
3138                _query: &'a str,
3139                _limit: usize,
3140            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3141            {
3142                Box::pin(async move { Ok(vec![]) })
3143            }
3144
3145            fn as_any(&self) -> &dyn Any {
3146                self
3147            }
3148        }
3149
3150        let registry: Arc<dyn Registry> = Arc::new(MixedErrorRegistry);
3151        let packages = vec![
3152            PackageName::new("typo-pkg"),
3153            PackageName::new("rate-limited"),
3154        ];
3155
3156        let result = fetch_latest_versions_parallel(
3157            registry,
3158            with_registry_source(packages),
3159            &HashMap::new(),
3160            None,
3161            deps_core::freshness::FreshnessSettings::default(),
3162            5,
3163            10,
3164            None,
3165        )
3166        .await;
3167
3168        let err = result
3169            .first_error
3170            .expect("an actionable failure occurred and must be reported");
3171        assert!(
3172            err.contains("rate limit exceeded"),
3173            "the actionable error must win the toast over the faster-finishing not-found, \
3174             got: {err}"
3175        );
3176        assert!(
3177            !err.contains("not found"),
3178            "a not-found error must never outrank an actionable error, got: {err}"
3179        );
3180    }
3181
3182    #[tokio::test]
3183    async fn test_first_error_falls_back_to_not_found_when_no_actionable_error_occurred() {
3184        // #480 fallback path: `priority_error` is only populated by errors that also
3185        // count toward `fetch_failed` (non-not-found). A batch whose only failures are
3186        // not-found ones must still surface one via `first_error` instead of silently
3187        // reporting nothing.
3188        use deps_core::{Metadata, Registry, Version};
3189        use std::any::Any;
3190
3191        struct AllNotFoundRegistry;
3192
3193        impl Registry for AllNotFoundRegistry {
3194            fn get_versions<'a>(
3195                &'a self,
3196                name: &'a deps_core::PackageName,
3197            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3198            {
3199                Box::pin(async move {
3200                    Err(deps_core::error::DepsError::PackageNotFound {
3201                        package: name.as_str().into(),
3202                        registry: "mock",
3203                    })
3204                })
3205            }
3206
3207            fn get_latest_matching<'a>(
3208                &'a self,
3209                _name: &'a deps_core::PackageName,
3210                _req: &'a deps_core::VersionReq,
3211            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3212            {
3213                Box::pin(async move { Ok(None) })
3214            }
3215
3216            fn search_raw<'a>(
3217                &'a self,
3218                _query: &'a str,
3219                _limit: usize,
3220            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3221            {
3222                Box::pin(async move { Ok(vec![]) })
3223            }
3224
3225            fn as_any(&self) -> &dyn Any {
3226                self
3227            }
3228        }
3229
3230        let registry: Arc<dyn Registry> = Arc::new(AllNotFoundRegistry);
3231        let packages = vec![
3232            PackageName::new("typo-pkg-1"),
3233            PackageName::new("typo-pkg-2"),
3234        ];
3235
3236        let result = fetch_latest_versions_parallel(
3237            registry,
3238            with_registry_source(packages),
3239            &HashMap::new(),
3240            None,
3241            deps_core::freshness::FreshnessSettings::default(),
3242            5,
3243            10,
3244            None,
3245        )
3246        .await;
3247
3248        assert!(
3249            result.fetch_failed.is_empty(),
3250            "not-found errors must never be recorded in fetch_failed"
3251        );
3252        let err = result
3253            .first_error
3254            .expect("a not-found-only batch must still fall back to reporting one via first_error");
3255        assert!(err.contains("not found"), "got: {err}");
3256    }
3257
3258    #[tokio::test]
3259    async fn test_timeout_only_batch_reports_first_error_alongside_failed_count() {
3260        // #480 S1: the toast used to special-case a populated `first_error`, dropping
3261        // `failed_count` from the message — a timeout batch silently lost its count. Now
3262        // built unconditionally from both fields (#490): asserts `FetchResult` reports
3263        // `failed_count` equal to batch size *and* a populated `first_error` together.
3264        use deps_core::{Metadata, Registry, Version};
3265        use std::any::Any;
3266        use std::time::Duration;
3267
3268        struct AlwaysTimesOutRegistry;
3269
3270        impl Registry for AlwaysTimesOutRegistry {
3271            fn get_versions<'a>(
3272                &'a self,
3273                _name: &'a deps_core::PackageName,
3274            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3275            {
3276                Box::pin(async move {
3277                    tokio::time::sleep(Duration::from_secs(10)).await;
3278                    Ok(vec![])
3279                })
3280            }
3281
3282            fn get_latest_matching<'a>(
3283                &'a self,
3284                _name: &'a deps_core::PackageName,
3285                _req: &'a deps_core::VersionReq,
3286            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3287            {
3288                Box::pin(async move {
3289                    tokio::time::sleep(Duration::from_secs(10)).await;
3290                    Ok(None)
3291                })
3292            }
3293
3294            fn search_raw<'a>(
3295                &'a self,
3296                _query: &'a str,
3297                _limit: usize,
3298            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3299            {
3300                Box::pin(async move { Ok(vec![]) })
3301            }
3302
3303            fn as_any(&self) -> &dyn Any {
3304                self
3305            }
3306        }
3307
3308        let registry: Arc<dyn Registry> = Arc::new(AlwaysTimesOutRegistry);
3309        let packages = vec![
3310            PackageName::new("slow-1"),
3311            PackageName::new("slow-2"),
3312            PackageName::new("slow-3"),
3313        ];
3314
3315        let result = fetch_latest_versions_parallel(
3316            registry,
3317            with_registry_source(packages),
3318            &HashMap::new(),
3319            None,
3320            deps_core::freshness::FreshnessSettings::default(),
3321            1,
3322            10,
3323            None,
3324        )
3325        .await;
3326
3327        assert_eq!(
3328            result.failed_count, 3,
3329            "all 3 packages must count toward failed_count"
3330        );
3331        let err = result
3332            .first_error
3333            .expect("a timeout is actionable and must populate first_error, not just failed_count");
3334        assert!(
3335            err.contains("timed out"),
3336            "first_error must be the actionable timeout message, got: {err}"
3337        );
3338    }
3339
3340    // Composer-specific tests
3341    #[cfg(feature = "composer")]
3342    mod composer_tests {
3343        use super::*;
3344
3345        /// #424 S1: `composer_minimum_stability` must extract the manifest's
3346        /// `minimum-stability` field via the real `deps_composer::parser::parse_composer_json`
3347        /// → `ComposerParseResult` → `deps_core::ParseResult` downcast path, not just a
3348        /// hand-built fixture — this is the actual production call path from the fetch task.
3349        #[tokio::test]
3350        async fn test_composer_minimum_stability_extracts_from_real_parse_result() {
3351            let json = r#"{
3352  "minimum-stability": "beta",
3353  "require": {
3354    "symfony/console": "^6.0"
3355  }
3356}"#;
3357            let uri = deps_core::test_util::test_uri("/test/composer.json");
3358            let parse_result = crate::setup::parse_composer_json(json, &uri).unwrap();
3359
3360            assert_eq!(
3361                composer_minimum_stability(&parse_result as &dyn deps_core::ParseResult),
3362                Some("beta".to_string())
3363            );
3364        }
3365
3366        /// #424 S1: a `composer.json` with no `minimum-stability` field extracts to `None`,
3367        /// not a fabricated `"stable"`.
3368        #[tokio::test]
3369        async fn test_composer_minimum_stability_none_when_absent() {
3370            let json = r#"{"require": {"symfony/console": "^6.0"}}"#;
3371            let uri = deps_core::test_util::test_uri("/test/composer.json");
3372            let parse_result = crate::setup::parse_composer_json(json, &uri).unwrap();
3373
3374            assert_eq!(
3375                composer_minimum_stability(&parse_result as &dyn deps_core::ParseResult),
3376                None
3377            );
3378        }
3379
3380        /// #424 S1: a non-Composer `ParseResult` (the downcast target type mismatches) must
3381        /// extract to `None` rather than panicking — this is what every other ecosystem's
3382        /// document hits on every fetch cycle.
3383        #[test]
3384        fn test_composer_minimum_stability_none_for_non_composer_parse_result() {
3385            struct OtherParseResult;
3386            impl deps_core::ParseResult for OtherParseResult {
3387                fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
3388                    vec![]
3389                }
3390                fn workspace_root(&self) -> Option<&std::path::Path> {
3391                    None
3392                }
3393                fn uri(&self) -> &url::Url {
3394                    unimplemented!("not exercised by this test")
3395                }
3396                fn as_any(&self) -> &dyn std::any::Any {
3397                    self
3398                }
3399            }
3400
3401            assert_eq!(
3402                composer_minimum_stability(&OtherParseResult as &dyn deps_core::ParseResult),
3403                None
3404            );
3405        }
3406    }
3407    mod yanked_check_tests {
3408        use super::*;
3409        use deps_core::{Metadata, Version};
3410        use std::any::Any;
3411        use std::sync::atomic::{AtomicUsize, Ordering};
3412
3413        #[derive(Debug, Clone)]
3414        struct MockYankVersion {
3415            version: ConcreteVersion,
3416            yanked: bool,
3417        }
3418
3419        impl Version for MockYankVersion {
3420            fn version_string(&self) -> &ConcreteVersion {
3421                &self.version
3422            }
3423            fn removal_status(&self) -> deps_core::RemovalStatus {
3424                deps_core::RemovalStatus::from_yanked(self.yanked)
3425            }
3426            fn as_any(&self) -> &dyn Any {
3427                self
3428            }
3429        }
3430
3431        /// Per-package outcome for the primary (and, under #206, only)
3432        /// `get_versions` fetch.
3433        enum FetchOutcome {
3434            Versions(Vec<(&'static str, bool)>),
3435            Error,
3436            Timeout,
3437        }
3438
3439        /// Configurable mock registry for exercising the yanked-check wiring
3440        /// in `fetch_latest_versions_parallel`. Under #206's single-fetch
3441        /// design, `get_versions` is both the source of "latest" (via
3442        /// `select_latest_matching`, mirrored here by picking the first
3443        /// non-yanked entry) and, in the same in-memory list, the source of
3444        /// the yanked check — there is no second registry call to mock.
3445        /// `latest_fallback` only feeds the `get_latest_matching` fallback
3446        /// path, exercised when `select_latest_matching` finds nothing (all
3447        /// yanked, or an empty list).
3448        struct MockRegistry {
3449            reports_yanked: bool,
3450            versions: HashMap<&'static str, FetchOutcome>,
3451            latest_fallback: HashMap<&'static str, (&'static str, bool)>,
3452            fetch_calls: Arc<AtomicUsize>,
3453        }
3454
3455        impl Registry for MockRegistry {
3456            fn get_versions<'a>(
3457                &'a self,
3458                name: &'a PackageName,
3459            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3460            {
3461                self.fetch_calls.fetch_add(1, Ordering::Relaxed);
3462                let outcome = self.versions.get(name.as_str());
3463                Box::pin(async move {
3464                    match outcome {
3465                        Some(FetchOutcome::Versions(vs)) => Ok(vs
3466                            .iter()
3467                            .map(|(v, y)| {
3468                                Box::new(MockYankVersion {
3469                                    version: (*v).into(),
3470                                    yanked: *y,
3471                                }) as Box<dyn Version>
3472                            })
3473                            .collect()),
3474                        Some(FetchOutcome::Error) => Err(deps_core::error::DepsError::CacheError(
3475                            "mock fetch error".to_string(),
3476                        )),
3477                        Some(FetchOutcome::Timeout) => {
3478                            tokio::time::sleep(std::time::Duration::from_secs(10)).await;
3479                            Ok(vec![])
3480                        }
3481                        None => Ok(vec![]),
3482                    }
3483                })
3484            }
3485
3486            fn select_latest_matching(
3487                &self,
3488                versions: &[Box<dyn Version>],
3489                _req: &VersionReq,
3490            ) -> Option<usize> {
3491                versions
3492                    .iter()
3493                    .position(|v| !v.removal_status().blocks_resolution())
3494            }
3495
3496            fn get_latest_matching<'a>(
3497                &'a self,
3498                name: &'a PackageName,
3499                _req: &'a VersionReq,
3500            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3501            {
3502                let outcome = self.latest_fallback.get(name.as_str()).copied();
3503                Box::pin(async move {
3504                    Ok(outcome.map(|(v, y)| {
3505                        Box::new(MockYankVersion {
3506                            version: v.into(),
3507                            yanked: y,
3508                        }) as Box<dyn Version>
3509                    }))
3510                })
3511            }
3512
3513            fn search_raw<'a>(
3514                &'a self,
3515                _query: &'a str,
3516                _limit: usize,
3517            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3518            {
3519                Box::pin(async move { Ok(vec![]) })
3520            }
3521
3522            fn reports_yanked(&self) -> bool {
3523                self.reports_yanked
3524            }
3525
3526            fn as_any(&self) -> &dyn Any {
3527                self
3528            }
3529        }
3530
3531        #[tokio::test]
3532        async fn reports_yanked_false_never_recorded() {
3533            // The fetched list carries a yanked in-use entry, but
3534            // `reports_yanked() == false` means `removal_status()` must never be
3535            // trusted, even though the data is already in hand for free.
3536            let fetch_calls = Arc::new(AtomicUsize::new(0));
3537            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3538                reports_yanked: false,
3539                versions: HashMap::from([(
3540                    "pkg",
3541                    FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", true)]),
3542                )]),
3543                latest_fallback: HashMap::new(),
3544                fetch_calls: Arc::clone(&fetch_calls),
3545            });
3546            let mut in_use = HashMap::new();
3547            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3548
3549            let result = fetch_latest_versions_parallel(
3550                registry,
3551                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3552                &in_use,
3553                None,
3554                deps_core::freshness::FreshnessSettings::default(),
3555                5,
3556                10,
3557                None,
3558            )
3559            .await;
3560
3561            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3562            assert!(result.yanked_versions.is_empty());
3563        }
3564
3565        #[tokio::test]
3566        async fn in_use_equal_to_latest_not_yanked() {
3567            let fetch_calls = Arc::new(AtomicUsize::new(0));
3568            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3569                reports_yanked: true,
3570                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", false)]))]),
3571                latest_fallback: HashMap::new(),
3572                fetch_calls: Arc::clone(&fetch_calls),
3573            });
3574            let mut in_use = HashMap::new();
3575            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3576
3577            let result = fetch_latest_versions_parallel(
3578                registry,
3579                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3580                &in_use,
3581                None,
3582                deps_core::freshness::FreshnessSettings::default(),
3583                5,
3584                10,
3585                None,
3586            )
3587            .await;
3588
3589            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3590            assert!(result.yanked_versions.is_empty());
3591        }
3592
3593        #[tokio::test]
3594        async fn no_known_in_use_version_skips_the_check() {
3595            let fetch_calls = Arc::new(AtomicUsize::new(0));
3596            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3597                reports_yanked: true,
3598                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("2.0.0", false)]))]),
3599                latest_fallback: HashMap::new(),
3600                fetch_calls: Arc::clone(&fetch_calls),
3601            });
3602
3603            let result = fetch_latest_versions_parallel(
3604                registry,
3605                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3606                &HashMap::new(),
3607                None,
3608                deps_core::freshness::FreshnessSettings::default(),
3609                5,
3610                10,
3611                None,
3612            )
3613            .await;
3614
3615            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3616            assert!(result.yanked_versions.is_empty());
3617        }
3618
3619        #[tokio::test]
3620        async fn in_use_differs_and_yanked_is_recorded() {
3621            // No second registry call under #206: the in-use check is a
3622            // search over the same `versions` list already fetched for
3623            // "latest" — `fetch_calls` stays at 1.
3624            let fetch_calls = Arc::new(AtomicUsize::new(0));
3625            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3626                reports_yanked: true,
3627                versions: HashMap::from([(
3628                    "pkg",
3629                    FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", true)]),
3630                )]),
3631                latest_fallback: HashMap::new(),
3632                fetch_calls: Arc::clone(&fetch_calls),
3633            });
3634            let mut in_use = HashMap::new();
3635            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3636
3637            let result = fetch_latest_versions_parallel(
3638                registry,
3639                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3640                &in_use,
3641                None,
3642                deps_core::freshness::FreshnessSettings::default(),
3643                5,
3644                10,
3645                None,
3646            )
3647            .await;
3648
3649            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3650            assert_eq!(
3651                result.yanked_versions.get(&PackageName::new("pkg")),
3652                Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
3653            );
3654        }
3655
3656        #[tokio::test]
3657        async fn in_use_differs_and_not_yanked_is_not_recorded() {
3658            let fetch_calls = Arc::new(AtomicUsize::new(0));
3659            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3660                reports_yanked: true,
3661                versions: HashMap::from([(
3662                    "pkg",
3663                    FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", false)]),
3664                )]),
3665                latest_fallback: HashMap::new(),
3666                fetch_calls: Arc::clone(&fetch_calls),
3667            });
3668            let mut in_use = HashMap::new();
3669            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3670
3671            let result = fetch_latest_versions_parallel(
3672                registry,
3673                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3674                &in_use,
3675                None,
3676                deps_core::freshness::FreshnessSettings::default(),
3677                5,
3678                10,
3679                None,
3680            )
3681            .await;
3682
3683            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3684            assert!(result.yanked_versions.is_empty());
3685        }
3686
3687        #[tokio::test]
3688        async fn every_version_yanked_still_checks_in_use() {
3689            // Critique M2: every version filtered out by the wildcard
3690            // requirement (here, all yanked) is the most severe case, not a
3691            // silent skip. `select_latest_matching` finds nothing, the
3692            // `get_latest_matching` fallback also finds nothing (no entry in
3693            // `latest_fallback`), so `result.versions` stays empty — but the
3694            // yanked check still runs against the originally fetched list.
3695            let fetch_calls = Arc::new(AtomicUsize::new(0));
3696            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3697                reports_yanked: true,
3698                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
3699                latest_fallback: HashMap::new(),
3700                fetch_calls: Arc::clone(&fetch_calls),
3701            });
3702            let mut in_use = HashMap::new();
3703            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3704
3705            let result = fetch_latest_versions_parallel(
3706                registry,
3707                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3708                &in_use,
3709                None,
3710                deps_core::freshness::FreshnessSettings::default(),
3711                5,
3712                10,
3713                None,
3714            )
3715            .await;
3716
3717            assert_eq!(
3718                result.yanked_versions.get(&PackageName::new("pkg")),
3719                Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
3720            );
3721            assert!(result.versions.is_empty());
3722        }
3723
3724        #[tokio::test]
3725        async fn latest_pick_needs_fallback_in_use_yanked_still_found() {
3726            // When the list-based pick fails (all yanked) and the
3727            // `get_latest_matching` fallback succeeds with a *different*,
3728            // non-yanked version, `result.versions` is populated from the
3729            // fallback — but the in-use yanked check still searches the
3730            // originally fetched list, not the fallback's single version.
3731            let fetch_calls = Arc::new(AtomicUsize::new(0));
3732            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3733                reports_yanked: true,
3734                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
3735                latest_fallback: HashMap::from([("pkg", ("2.0.0", false))]),
3736                fetch_calls: Arc::clone(&fetch_calls),
3737            });
3738            let mut in_use = HashMap::new();
3739            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3740
3741            let result = fetch_latest_versions_parallel(
3742                registry,
3743                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3744                &in_use,
3745                None,
3746                deps_core::freshness::FreshnessSettings::default(),
3747                5,
3748                10,
3749                None,
3750            )
3751            .await;
3752
3753            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3754            assert_eq!(
3755                result
3756                    .versions
3757                    .get(&PackageName::new("pkg"))
3758                    .map(|v| v.latest.as_str()),
3759                Some("2.0.0")
3760            );
3761            assert_eq!(
3762                result.yanked_versions.get(&PackageName::new("pkg")),
3763                Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
3764            );
3765        }
3766
3767        #[tokio::test]
3768        async fn in_use_checks_every_occurrence_of_a_duplicate_name() {
3769            // Regression guard for #394: a package can appear more than once
3770            // in a manifest under the same name (e.g. `[dependencies]` +
3771            // `[dev-dependencies]`), each pinned to a different in-use
3772            // version. Only one occurrence ("2.0.0") is yanked; the other
3773            // ("3.0.0", not fetched here, not yanked) must not shadow it.
3774            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3775                reports_yanked: true,
3776                versions: HashMap::from([(
3777                    "pkg",
3778                    FetchOutcome::Versions(vec![
3779                        ("3.0.0", false),
3780                        ("2.0.0", true),
3781                        ("1.0.0", false),
3782                    ]),
3783                )]),
3784                latest_fallback: HashMap::new(),
3785                fetch_calls: Arc::new(AtomicUsize::new(0)),
3786            });
3787            let mut in_use = HashMap::new();
3788            in_use.insert(
3789                PackageName::new("pkg"),
3790                vec!["1.0.0".to_string(), "2.0.0".to_string()],
3791            );
3792
3793            let result = fetch_latest_versions_parallel(
3794                registry,
3795                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3796                &in_use,
3797                None,
3798                deps_core::freshness::FreshnessSettings::default(),
3799                5,
3800                10,
3801                None,
3802            )
3803            .await;
3804
3805            assert_eq!(
3806                result.yanked_versions.get(&PackageName::new("pkg")),
3807                Some(&(ConcreteVersion::new("2.0.0"), RemovalStatus::Yanked)),
3808                "the yanked occurrence must be found even though a name-keyed \
3809                 single-value map could have kept only the non-yanked \"1.0.0\" pin"
3810            );
3811        }
3812
3813        #[tokio::test]
3814        async fn latest_is_yanked_recorded_as_defense_in_depth() {
3815            // §4.7 row 1: a contract-violating registry (its wildcard
3816            // `get_latest_matching` fallback returns a yanked version) still
3817            // gets recorded, at zero extra cost. `select_latest_matching`
3818            // filters yanked entries by construction, so the list-based pick
3819            // finds nothing here and the fallback is what "lies".
3820            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3821                reports_yanked: true,
3822                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
3823                latest_fallback: HashMap::from([("pkg", ("1.0.0", true))]),
3824                fetch_calls: Arc::new(AtomicUsize::new(0)),
3825            });
3826
3827            let result = fetch_latest_versions_parallel(
3828                registry,
3829                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3830                &HashMap::new(),
3831                None,
3832                deps_core::freshness::FreshnessSettings::default(),
3833                5,
3834                10,
3835                None,
3836            )
3837            .await;
3838
3839            assert_eq!(
3840                result.yanked_versions.get(&PackageName::new("pkg")),
3841                Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
3842            );
3843        }
3844
3845        #[tokio::test]
3846        async fn latest_is_yanked_not_recorded_when_reports_yanked_false() {
3847            // impl-critic M1: row 1 must respect the same `reports_yanked()`
3848            // gate as the in-memory in-use check. Harmless today only
3849            // because every opt-out registry also hardcodes `removal_status`
3850            // to `Available` — this guards against a follow-up (§8.2/§8.3)
3851            // making an opt-out registry's `removal_status()` real without
3852            // also flipping `reports_yanked()`, which would otherwise
3853            // silently reintroduce a #233-class bug through this exact row.
3854            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3855                reports_yanked: false,
3856                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
3857                latest_fallback: HashMap::from([("pkg", ("1.0.0", true))]),
3858                fetch_calls: Arc::new(AtomicUsize::new(0)),
3859            });
3860
3861            let result = fetch_latest_versions_parallel(
3862                registry,
3863                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3864                &HashMap::new(),
3865                None,
3866                deps_core::freshness::FreshnessSettings::default(),
3867                5,
3868                10,
3869                None,
3870            )
3871            .await;
3872
3873            assert!(
3874                result.yanked_versions.is_empty(),
3875                "a `reports_yanked() == false` registry's `removal_status()` must never be \
3876                 trusted, even on the zero-cost row-1 path"
3877            );
3878            assert!(
3879                result
3880                    .versions
3881                    .get(&PackageName::new("pkg"))
3882                    .expect("pkg was fetched")
3883                    .yanked
3884                    .is_empty(),
3885                "`PackageVersions::yanked` must stay empty for a `reports_yanked() == false` \
3886                 registry, even though the fetched version is itself flagged"
3887            );
3888        }
3889
3890        #[tokio::test]
3891        async fn primary_fetch_error_counts_as_failed_no_yanked_data() {
3892            // Under #206's single-fetch design there is no separate "probe"
3893            // that can fail independently of the primary fetch — a
3894            // `get_versions` failure loses both the "latest" and the yanked
3895            // data together, and is counted as a real fetch failure (unlike
3896            // the pre-#206 best-effort probe).
3897            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3898                reports_yanked: true,
3899                versions: HashMap::from([("pkg", FetchOutcome::Error)]),
3900                latest_fallback: HashMap::new(),
3901                fetch_calls: Arc::new(AtomicUsize::new(0)),
3902            });
3903            let mut in_use = HashMap::new();
3904            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3905
3906            let result = fetch_latest_versions_parallel(
3907                registry,
3908                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3909                &in_use,
3910                None,
3911                deps_core::freshness::FreshnessSettings::default(),
3912                5,
3913                10,
3914                None,
3915            )
3916            .await;
3917
3918            assert!(result.yanked_versions.is_empty());
3919            assert_eq!(result.failed_count, 1);
3920            assert!(result.versions.is_empty());
3921        }
3922
3923        #[tokio::test]
3924        async fn primary_fetch_timeout_counts_as_failed_no_yanked_data() {
3925            // Same reasoning as the error case above, for the timeout path.
3926            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3927                reports_yanked: true,
3928                versions: HashMap::from([("pkg", FetchOutcome::Timeout)]),
3929                latest_fallback: HashMap::new(),
3930                fetch_calls: Arc::new(AtomicUsize::new(0)),
3931            });
3932            let mut in_use = HashMap::new();
3933            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3934
3935            let result = fetch_latest_versions_parallel(
3936                registry,
3937                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3938                &in_use,
3939                None,
3940                deps_core::freshness::FreshnessSettings::default(),
3941                1,
3942                10,
3943                None,
3944            )
3945            .await;
3946
3947            assert!(result.yanked_versions.is_empty());
3948            assert_eq!(result.failed_count, 1);
3949            assert!(result.versions.is_empty());
3950        }
3951    }
3952
3953    /// #205: the `fetch_latest_versions_parallel` wiring that derives `FetchResult::deprecations`
3954    /// from the `resolved`/"latest" pick, self-contained rather than extending
3955    /// `yanked_check_tests`'s shared `MockYankVersion`/`FetchOutcome` (whose tuple shape has
3956    /// no room for a per-version `Deprecation` payload without touching its many existing
3957    /// call sites).
3958    mod deprecation_derivation_tests {
3959        use super::*;
3960        use deps_core::{Metadata, Version};
3961        use std::any::Any;
3962
3963        struct MockDeprecatedVersion {
3964            version: ConcreteVersion,
3965            deprecation: Option<Deprecation>,
3966        }
3967
3968        impl Version for MockDeprecatedVersion {
3969            fn version_string(&self) -> &ConcreteVersion {
3970                &self.version
3971            }
3972            fn removal_status(&self) -> RemovalStatus {
3973                RemovalStatus::from_advisory(self.deprecation.is_some())
3974            }
3975            fn deprecation(&self) -> Option<&Deprecation> {
3976                self.deprecation.as_ref()
3977            }
3978            fn as_any(&self) -> &dyn Any {
3979                self
3980            }
3981        }
3982
3983        /// Always resolves to its single configured version.
3984        struct SingleVersionRegistry {
3985            deprecation: Option<Deprecation>,
3986        }
3987
3988        impl Registry for SingleVersionRegistry {
3989            fn get_versions<'a>(
3990                &'a self,
3991                _name: &'a PackageName,
3992            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3993            {
3994                let deprecation = self.deprecation.clone();
3995                Box::pin(async move {
3996                    Ok(vec![Box::new(MockDeprecatedVersion {
3997                        version: "1.0.0".into(),
3998                        deprecation,
3999                    }) as Box<dyn Version>])
4000                })
4001            }
4002
4003            fn select_latest_matching(
4004                &self,
4005                versions: &[Box<dyn Version>],
4006                _req: &VersionReq,
4007            ) -> Option<usize> {
4008                (!versions.is_empty()).then_some(0)
4009            }
4010
4011            fn get_latest_matching<'a>(
4012                &'a self,
4013                _name: &'a PackageName,
4014                _req: &'a VersionReq,
4015            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4016            {
4017                Box::pin(async move { Ok(None) })
4018            }
4019
4020            fn search_raw<'a>(
4021                &'a self,
4022                _query: &'a str,
4023                _limit: usize,
4024            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4025            {
4026                Box::pin(async move { Ok(vec![]) })
4027            }
4028
4029            fn as_any(&self) -> &dyn Any {
4030                self
4031            }
4032        }
4033
4034        #[tokio::test]
4035        async fn fetch_result_carries_deprecation_from_resolved_pick() {
4036            let registry: Arc<dyn Registry> = Arc::new(SingleVersionRegistry {
4037                deprecation: Some(Deprecation {
4038                    reason: Some("archived".to_string()),
4039                    replacement: Some("other/pkg".to_string()),
4040                }),
4041            });
4042
4043            let result = fetch_latest_versions_parallel(
4044                registry,
4045                vec![(PackageName::new("pkg"), DependencySource::Registry)],
4046                &HashMap::new(),
4047                None,
4048                deps_core::freshness::FreshnessSettings::default(),
4049                5,
4050                10,
4051                None,
4052            )
4053            .await;
4054
4055            assert_eq!(
4056                result.deprecations.get(&PackageName::new("pkg")),
4057                Some(&Deprecation {
4058                    reason: Some("archived".to_string()),
4059                    replacement: Some("other/pkg".to_string()),
4060                })
4061            );
4062        }
4063
4064        #[tokio::test]
4065        async fn fetch_result_has_no_deprecation_when_resolved_pick_is_clean() {
4066            let registry: Arc<dyn Registry> = Arc::new(SingleVersionRegistry { deprecation: None });
4067
4068            let result = fetch_latest_versions_parallel(
4069                registry,
4070                vec![(PackageName::new("pkg"), DependencySource::Registry)],
4071                &HashMap::new(),
4072                None,
4073                deps_core::freshness::FreshnessSettings::default(),
4074                5,
4075                10,
4076                None,
4077            )
4078            .await;
4079
4080            assert!(result.deprecations.is_empty());
4081        }
4082    }
4083}