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::Version;
20use deps_core::VersionReq;
21use std::collections::{HashMap, HashSet};
22use std::num::NonZeroUsize;
23use std::sync::Arc;
24use std::time::Duration;
25
26/// A dependency name paired with the resolved source to route its registry fetch through
27/// (spec FR-001), as built by [`dedup_dependencies_by_source`].
28pub type DepSources = Vec<(PackageName, deps_core::parser::DependencySource)>;
29
30/// Pairs each distinct dependency name in `parse_result` with the source its occurrence(s)
31/// resolve to.
32///
33/// For the background registry fetch to route through
34/// `Registry::get_versions_from`/`get_latest_matching_from` (spec FR-001).
35///
36/// Two gates, applied in order:
37///
38/// 1. **Resolvability** (closes a review-flagged leak): a dependency whose source is not
39///    resolvable at all (`!formatter.can_resolve_source(source)` — Git, Path, an unresolved
40///    `CustomRegistry` alias, ...) is dropped from the result entirely, never reaching the
41///    fetch. Without this gate, `CargoRegistry`'s (and every other source-aware registry's)
42///    `_ =>` default-to-crates.io arm would silently look up a private/unresolvable name
43///    against the ecosystem's *public* registry — exactly the leak this feature's own
44///    hover/code-actions/diagnostics gating was built to close, just reached through the
45///    highest-traffic path (the background fetch feeding inlay hints and cached
46///    diagnostics) instead.
47/// 2. **Collision** (spec FR-011): when two occurrences of the same name both resolve
48///    (gate 1 passed for both) to two *different* sources — e.g. a genuine resolution bug
49///    producing two distinct index URLs for what should be one registry — both are dropped
50///    from the map and the name is added to the returned collision set instead of being
51///    fetched. The fetch result is shared across every occurrence of a name
52///    (`FetchResult::versions` is name-keyed), so silently picking a source here would
53///    silently apply it to occurrences whose author may have intended a different
54///    registry. A `tracing::warn!` names both resolved sources, using message text
55///    distinguishable from `deps-cargo`'s own FR-003 unresolved-alias warning.
56///
57/// Returns `(sources, collided)`: `sources` is ready to fetch as-is; `collided` must be
58/// merged into `DocumentState::signals.outcomes`' fetch-failure channel by the caller so
59/// `generate_diagnostics_from_cache` reports "lookup could not be determined" rather than
60/// a false "Unknown package" for a dependency that was never actually queried.
61///
62/// # Examples
63///
64/// ```
65/// use deps_core::lsp_helpers::{
66///     DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
67///     RequirementResolution, SourcePolicy,
68/// };
69/// use deps_core::test_util::stub_parse_result_with_dependencies;
70/// use deps_core::{ConcreteVersion, PackageName};
71/// use deps_engine::classify::fetch::dedup_dependencies_by_source;
72///
73/// struct SimpleFormatter;
74/// impl PackageNaming for SimpleFormatter {}
75/// impl PackageRendering for SimpleFormatter {
76///     fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
77///         version.to_string()
78///     }
79///     fn package_url(&self, name: &PackageName) -> String {
80///         name.as_str().to_string()
81///     }
82/// }
83/// impl RequirementResolution for SimpleFormatter {}
84/// impl DiagnosticMessages for SimpleFormatter {}
85/// impl DiagnosticPolicy for SimpleFormatter {}
86/// impl SourcePolicy for SimpleFormatter {}
87/// impl OsvNaming for SimpleFormatter {}
88///
89/// let parsed = stub_parse_result_with_dependencies(2);
90/// let (sources, collided) = dedup_dependencies_by_source(parsed.as_ref(), &SimpleFormatter);
91///
92/// assert_eq!(sources.len(), 2, "both distinct names resolve, no collision");
93/// assert!(collided.is_empty());
94/// ```
95pub fn dedup_dependencies_by_source(
96    parse_result: &dyn deps_core::ParseResult,
97    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
98) -> (
99    HashMap<PackageName, deps_core::parser::DependencySource>,
100    HashSet<PackageName>,
101) {
102    use std::collections::hash_map::Entry;
103
104    let mut by_name: HashMap<PackageName, deps_core::parser::DependencySource> = HashMap::new();
105    let mut collided: HashSet<PackageName> = HashSet::new();
106
107    for dep in parse_result
108        .dependencies()
109        .into_iter()
110        .filter(|dep| formatter.can_resolve_source(&dep.source()))
111    {
112        let name = dep.name().clone();
113        let source = dep.source();
114        match by_name.entry(name.clone()) {
115            Entry::Vacant(entry) => {
116                entry.insert(source);
117            }
118            Entry::Occupied(entry) => {
119                if *entry.get() != source && collided.insert(name.clone()) {
120                    tracing::warn!(
121                        package = %name.for_tracing(),
122                        source_a = ?entry.get(),
123                        source_b = ?source,
124                        "dependency declared against two different resolved registries; \
125                         skipping version resolution for all occurrences"
126                    );
127                }
128            }
129        }
130    }
131
132    for name in &collided {
133        by_name.remove(name);
134    }
135    (by_name, collided)
136}
137
138/// Inputs [`fetch_latest_versions_parallel`] needs, derived from a parsed manifest.
139///
140/// Which sources to fetch (deduped, collision-free — see [`dedup_dependencies_by_source`]), each
141/// dependency's currently in-use version(s), and the manifest's own
142/// [`deps_core::SelectionContext`] (e.g. Composer's `minimum-stability`, #1433).
143///
144/// Built by [`prepare_fetch`], which consolidates three independently hand-rolled copies of
145/// this exact dedup -> in-use -> selection-context sequence (`deps-lsp`'s
146/// `document/lifecycle.rs` and `document/fetch.rs`, `deps-cli`'s `analyze.rs`) so the LSP and
147/// CLI fetch-preparation paths can no longer drift apart (#1433).
148#[non_exhaustive]
149pub struct FetchPreparation {
150    /// Ready to hand to [`fetch_latest_versions_parallel`] as-is, or filtered further by a
151    /// caller that only wants to fetch a subset (e.g. `deps-lsp`'s
152    /// `fetch_registry_versions_for_change`, which fetches only added/version-changed
153    /// dependencies).
154    pub dep_sources: DepSources,
155    /// `dep_name -> [in_use_version, ...]`, from [`crate::classify::resolved::collect_in_use_versions`].
156    pub in_use: HashMap<PackageName, Vec<ConcreteVersion>>,
157    /// The manifest's own [`deps_core::SelectionContext`], from
158    /// [`deps_core::ParseResult::selection_context`].
159    pub selection_context: deps_core::SelectionContext,
160    /// Names dropped by [`dedup_dependencies_by_source`]'s collision gate — must be merged
161    /// into `DocumentState::signals.outcomes`' fetch-failure channel by the caller (spec FR-011).
162    pub collided_names: HashSet<PackageName>,
163}
164
165/// Builds a [`FetchPreparation`] from a parsed manifest and its resolved lock-file state.
166///
167/// # Examples
168///
169/// ```
170/// use deps_core::lsp_helpers::{
171///     DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
172///     RequirementResolution, SourcePolicy,
173/// };
174/// use deps_core::test_util::stub_parse_result_with_dependencies;
175/// use deps_core::{ConcreteVersion, EcosystemId, PackageName};
176/// use deps_engine::classify::fetch::prepare_fetch;
177/// use std::collections::HashMap;
178///
179/// struct SimpleFormatter;
180/// impl PackageNaming for SimpleFormatter {}
181/// impl PackageRendering for SimpleFormatter {
182///     fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
183///         version.to_string()
184///     }
185///     fn package_url(&self, name: &PackageName) -> String {
186///         name.as_str().to_string()
187///     }
188/// }
189/// impl RequirementResolution for SimpleFormatter {}
190/// impl DiagnosticMessages for SimpleFormatter {}
191/// impl DiagnosticPolicy for SimpleFormatter {}
192/// impl SourcePolicy for SimpleFormatter {}
193/// impl OsvNaming for SimpleFormatter {}
194///
195/// let parsed = stub_parse_result_with_dependencies(2);
196/// let prep = prepare_fetch(
197///     parsed.as_ref(),
198///     &SimpleFormatter,
199///     EcosystemId::Cargo,
200///     &HashMap::new(),
201///     &HashMap::new(),
202/// );
203///
204/// assert_eq!(prep.dep_sources.len(), 2);
205/// assert!(prep.selection_context.minimum_stability().is_none());
206/// ```
207pub fn prepare_fetch(
208    parse_result: &dyn deps_core::ParseResult,
209    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
210    ecosystem: deps_core::EcosystemId,
211    resolved_versions: &HashMap<PackageName, ConcreteVersion>,
212    resolved_version_candidates: &HashMap<PackageName, Vec<ConcreteVersion>>,
213) -> FetchPreparation {
214    let (sources_map, collided_names) = dedup_dependencies_by_source(parse_result, formatter);
215    let dep_sources: DepSources = sources_map.into_iter().collect();
216    let in_use = crate::classify::resolved::collect_in_use_versions(
217        parse_result,
218        resolved_versions,
219        resolved_version_candidates,
220        formatter,
221        ecosystem,
222    );
223    FetchPreparation {
224        dep_sources,
225        in_use,
226        selection_context: parse_result.selection_context(),
227        collided_names,
228    }
229}
230
231/// Result of parallel version fetching.
232#[non_exhaustive]
233pub struct FetchResult {
234    /// Successfully fetched versions (package -> latest + full version list)
235    pub versions: HashMap<PackageName, PackageVersions>,
236    /// Yanked-version findings, keyed by **raw** package name (unlike
237    /// `DocumentState::signals.outcomes`, which is normalized-keyed — see
238    /// §3.1 of the design), to (the version string found yanked, its
239    /// `RemovalStatus`). The status rides alongside so #205's package-level
240    /// deprecation diagnostic can gate its yanked-check suppression on
241    /// `AdvisoryDeprecated` specifically, never a genuine `Yanked` finding.
242    /// Callers must re-key through `EcosystemFormatter::normalize_package_name`
243    /// before merging into document state.
244    pub yanked_versions: HashMap<PackageName, (ConcreteVersion, RemovalStatus)>,
245    /// Package-level deprecation findings (issue #205), keyed by **raw** package name
246    /// (same raw/normalized split as `yanked_versions` above). Derived from the
247    /// `resolved`/"latest" pick in the fetch loop below, not by scanning the full
248    /// `versions` list — see that loop's comments for why.
249    pub deprecations: HashMap<PackageName, Deprecation>,
250    /// Packages whose registry fetch errored or timed out, keyed by **raw**
251    /// package name (same raw/normalized split as `yanked_versions` above).
252    /// Lets diagnostic generation (#267) distinguish "the registry said this
253    /// package doesn't exist" from "the registry couldn't be asked" instead
254    /// of conflating both into a misleading "Unknown package" diagnostic.
255    pub fetch_failed: HashMap<PackageName, FetchFailure>,
256    /// Packages whose registry fetch succeeded but produced zero comparable versions
257    /// (#550), keyed by **raw** package name (same raw/normalized split as
258    /// `yanked_versions` above). Distinct from `fetch_failed`: the registry was
259    /// successfully asked and the package demonstrably exists — it just has nothing a
260    /// version-comparison rule can use — so `generate_diagnostics_from_cache` must
261    /// report neither "Registry lookup failed" nor "Unknown package" for it.
262    pub no_comparable_versions: HashSet<PackageName>,
263    /// Toast-ready summary of this round's failures, or `None` when every package either
264    /// resolved or fetched cleanly with no comparable versions. Replaces the previously
265    /// independent `failed_count`/`first_error` field pair (#480, #490): both bugs were
266    /// hand-kept invariants (`failed_count > 0` implies `first_error.is_some()`) rather
267    /// than type-enforced ones. `failure_summary`'s count still counts both a genuine
268    /// fetch failure (recorded in `fetch_failed` above) and a not-found lookup (the
269    /// registry answered "no such package", never recorded in `fetch_failed`, see #267
270    /// C1) — only the `fetch_failed` subset produces an inline "Registry lookup failed"
271    /// diagnostic, so this count can still exceed `fetch_failed.len()` (#276 S2).
272    pub failure_summary: Option<FailureSummary>,
273    /// SPDX license identifier(s) for the resolved/"latest" pick, for every package
274    /// whose `Version::license` on the already-fetched version-list entry is
275    /// non-empty (issue #660/#661 tier-1 backfill) — today, only the native-list
276    /// ecosystems (PyPI, Composer) ever populate this; every other ecosystem's
277    /// `Version::license` default is empty, so this map stays empty for them.
278    /// Deliberately *not* threaded into [`PackageVersions`] itself (that type is
279    /// constructed identically across ~40 call sites throughout the workspace,
280    /// including files outside this crate's ownership for this change) —
281    /// `merge_registry_fetch_result` merges this map directly into
282    /// `deps-lsp`'s `DocumentState::signals.licenses` instead, the same map the tier-3
283    /// background pre-fetch (`run_license_prefetch`) already populates for
284    /// Dart/Swift/Gradle/Deno. A merge (not replace), since the two sources are
285    /// always disjoint per document (one ecosystem per document) but run as
286    /// independent, non-ordered background tasks.
287    pub licenses: HashMap<PackageName, Vec<String>>,
288}
289
290/// Toast-ready summary of a fetch batch's failures: how many packages did not resolve and
291/// the first actionable failure message, in fetch-completion order.
292///
293/// Replaces [`FetchResult`]'s previously independent `failed_count`/`first_error` field
294/// pair, whose only invariant (a nonzero count implies a message) was kept by convention
295/// rather than the type system — two prior bugs (#480, #490) were both hand-patches on top
296/// of that convention rather than fixes to the underlying representation. A `count` of zero
297/// and a missing message are now unrepresentable: this type only exists as `Some` when at
298/// least one package failed, and its message is always present.
299///
300/// # Examples
301///
302/// ```
303/// use deps_engine::classify::fetch::FailureSummary;
304/// use std::num::NonZeroUsize;
305///
306/// let summary = FailureSummary::new(NonZeroUsize::new(2).unwrap(), "HTTP 503".to_string());
307/// assert_eq!(summary.count(), 2);
308/// assert_eq!(summary.message(), "HTTP 503");
309/// ```
310#[derive(Debug, Clone, PartialEq, Eq)]
311pub struct FailureSummary {
312    count: NonZeroUsize,
313    message: String,
314}
315
316impl FailureSummary {
317    /// Builds a `FailureSummary` from its already-computed count and message.
318    #[must_use]
319    pub fn new(count: NonZeroUsize, message: String) -> Self {
320        Self { count, message }
321    }
322
323    /// Number of packages whose registry fetch did not succeed this round (see
324    /// [`FetchResult::failure_summary`] for what counts).
325    #[must_use]
326    pub fn count(&self) -> usize {
327        self.count.get()
328    }
329
330    /// The first actionable failure message, in fetch-completion order.
331    #[must_use]
332    pub fn message(&self) -> &str {
333        &self.message
334    }
335}
336
337impl FetchResult {
338    /// Constructs a `FetchResult` from its already-computed fields.
339    ///
340    /// `#[non_exhaustive]` blocks cross-crate struct-literal construction even with every
341    /// field named, so a caller outside `deps-engine` (e.g. a `deps-lsp` unit test building a
342    /// synthetic fetch outcome) needs this constructor instead.
343    ///
344    /// # Examples
345    ///
346    /// ```
347    /// use deps_engine::classify::fetch::FetchResult;
348    /// use std::collections::{HashMap, HashSet};
349    ///
350    /// let result = FetchResult::new(
351    ///     HashMap::new(),
352    ///     HashMap::new(),
353    ///     HashMap::new(),
354    ///     HashMap::new(),
355    ///     HashSet::new(),
356    ///     None,
357    ///     HashMap::new(),
358    /// );
359    /// assert_eq!(result.failed_count(), 0);
360    /// ```
361    ///
362    /// Parameters, in declaration order (see each field's own doc above for the full
363    /// rationale — this is a quick cross-check against accidental transposition, several
364    /// share the same `HashMap<PackageName, _>`/`HashSet<PackageName>` shape):
365    /// `versions` (successful fetches), `yanked_versions` (yank findings),
366    /// `deprecations` (package-level deprecation findings), `fetch_failed` (errored/timed-out
367    /// packages), `no_comparable_versions` (fetched clean but nothing to compare),
368    /// `failure_summary`, `licenses`.
369    #[must_use]
370    #[allow(clippy::too_many_arguments)]
371    pub fn new(
372        versions: HashMap<PackageName, PackageVersions>,
373        yanked_versions: HashMap<PackageName, (ConcreteVersion, RemovalStatus)>,
374        deprecations: HashMap<PackageName, Deprecation>,
375        fetch_failed: HashMap<PackageName, FetchFailure>,
376        no_comparable_versions: HashSet<PackageName>,
377        failure_summary: Option<FailureSummary>,
378        licenses: HashMap<PackageName, Vec<String>>,
379    ) -> Self {
380        Self {
381            versions,
382            yanked_versions,
383            deprecations,
384            fetch_failed,
385            no_comparable_versions,
386            failure_summary,
387            licenses,
388        }
389    }
390
391    /// Total packages whose fetch did not succeed this round (mirrors the old
392    /// `failed_count` field). Requires `self` not yet partially moved out of — a caller
393    /// that has already moved another field (e.g. `versions`) out of a owned `FetchResult`
394    /// should read `failure_summary` directly instead, since a partial move blocks any
395    /// further whole-`self` method call.
396    #[must_use]
397    pub fn failed_count(&self) -> usize {
398        self.failure_summary
399            .as_ref()
400            .map_or(0, FailureSummary::count)
401    }
402
403    /// The first actionable failure message for this round (mirrors the old `first_error`
404    /// field). Same partial-move caveat as [`Self::failed_count`].
405    #[must_use]
406    pub fn failure_message(&self) -> Option<&str> {
407        self.failure_summary.as_ref().map(FailureSummary::message)
408    }
409}
410
411/// Fetches latest versions for multiple packages in parallel with progress reporting.
412///
413/// Returns a [`FetchResult`] containing successfully fetched versions and failure count.
414/// Packages that fail to fetch are omitted from the versions map.
415///
416/// This function executes all registry requests concurrently with per-dependency
417/// timeout isolation, preventing slow packages from blocking others.
418///
419/// Alongside the primary fetch, checks whether the in-use version of a
420/// dependency has been yanked (#233), for registries that [report yank
421/// data](Registry::reports_yanked). Unlike the original design, this is not
422/// a second registry round trip: `registry.get_versions` below already
423/// fetches the full, unfiltered version list once per package (see
424/// [`PackageVersions`]), so the in-use-version check is a zero-cost
425/// in-memory search over a list already in hand, run for every dependency
426/// with a known in-use version rather than only when it differs from
427/// `latest`.
428///
429/// # Arguments
430///
431/// * `registry` - Package registry to fetch from
432/// * `package_names` - List of package names to fetch
433/// * `in_use` - Raw dependency name -> the version(s) this project actually
434///   has (lockfile-resolved or a concrete pin) for every occurrence of that
435///   name in the manifest, checked against the fetched version list for
436///   yank status
437/// * `progress` - Optional progress tracker (will be updated after each fetch)
438/// * `timeout_secs` - Timeout for each individual package fetch (default: 10s)
439/// * `max_concurrent` - Maximum concurrent fetches (default: 20); clamped to `>= 1`
440///   internally, since `buffer_unordered(0)` would hang forever (issue #833)
441///
442/// # Timeout Behavior
443///
444/// Each package fetch is wrapped in an individual timeout. If a package
445/// takes longer than `timeout_secs` to fetch, it fails fast with a warning
446/// and does NOT block other packages.
447///
448/// # Performance
449///
450/// With 50 dependencies and 100ms per request:
451/// - Sequential: 50 × 100ms = 5000ms
452/// - Parallel (no timeout): max(100ms) ≈ 150ms
453/// - Parallel (10s timeout, 1 slow package at 30s): max(10s) ≈ 10s
454///
455/// # Examples
456///
457/// ```
458/// use deps_core::parser::DependencySource;
459/// use deps_core::{
460///     ConcreteVersion, Metadata, PackageName, Registry, SelectionContext, Version, VersionReq,
461/// };
462/// use deps_engine::classify::fetch::fetch_latest_versions_parallel;
463/// use std::any::Any;
464/// use std::collections::HashMap;
465/// use std::sync::Arc;
466///
467/// struct SingleVersionRegistry;
468///
469/// #[derive(Clone)]
470/// struct SimpleVersion {
471///     version: ConcreteVersion,
472/// }
473/// impl Version for SimpleVersion {
474///     fn version_string(&self) -> &ConcreteVersion {
475///         &self.version
476///     }
477///     fn as_any(&self) -> &dyn Any {
478///         self
479///     }
480/// }
481///
482/// impl Registry for SingleVersionRegistry {
483///     fn get_versions<'a>(
484///         &'a self,
485///         _name: &'a PackageName,
486///     ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>> {
487///         Box::pin(async move {
488///             Ok(vec![Box::new(SimpleVersion { version: "1.0.0".into() }) as Box<dyn Version>])
489///         })
490///     }
491///
492///     // The default `select_latest_matching` always returns `None` (every real registry
493///     // overrides it with ecosystem-specific comparison), so `fetch_and_classify_package`
494///     // falls back to this method for its pick.
495///     fn get_latest_matching<'a>(
496///         &'a self,
497///         _name: &'a PackageName,
498///         _req: &'a VersionReq,
499///         _selection_context: &'a SelectionContext,
500///     ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>> {
501///         Box::pin(async move {
502///             Ok(Some(Box::new(SimpleVersion { version: "1.0.0".into() }) as Box<dyn Version>))
503///         })
504///     }
505///
506///     fn search_raw<'a>(
507///         &'a self,
508///         _query: &'a str,
509///         _limit: usize,
510///     ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>> {
511///         Box::pin(async move { Ok(vec![]) })
512///     }
513///
514///     fn as_any(&self) -> &dyn Any {
515///         self
516///     }
517/// }
518///
519/// #[tokio::main]
520/// async fn main() {
521///     let sources = vec![(PackageName::new("time"), DependencySource::Registry)];
522///
523///     let result = fetch_latest_versions_parallel(
524///         Arc::new(SingleVersionRegistry),
525///         sources,
526///         &HashMap::new(),
527///         None,
528///         deps_core::freshness::FreshnessSettings::default(),
529///         5,
530///         10,
531///         &SelectionContext::none(),
532///         None,
533///     )
534///     .await;
535///
536///     assert_eq!(
537///         result.versions.get(&PackageName::new("time")).map(|v| v.latest.to_string()),
538///         Some("1.0.0".to_string())
539///     );
540/// }
541/// ```
542#[allow(
543    clippy::too_many_arguments,
544    reason = "internal (non-pub) call-site-controlled fetch tuning + ecosystem-context \
545              parameters; grouping into a config struct would only move, not reduce, the \
546              per-call-site churn across this module's ~15 production and test call sites"
547)]
548pub async fn fetch_latest_versions_parallel(
549    registry: Arc<dyn Registry>,
550    package_sources: DepSources,
551    in_use: &HashMap<PackageName, Vec<ConcreteVersion>>,
552    progress_sender: Option<ProgressSender>,
553    freshness: deps_core::freshness::FreshnessSettings,
554    timeout_secs: u64,
555    max_concurrent: usize,
556    selection_context: &deps_core::SelectionContext,
557    gossip: Option<&HashMap<PackageName, deps_core::GossipFindings>>,
558) -> FetchResult {
559    use futures::stream::{self, StreamExt};
560    use std::time::Duration;
561
562    let fetched = Arc::new(std::sync::atomic::AtomicUsize::new(0));
563    let timeout = Duration::from_secs(timeout_secs);
564    let wildcard_req = deps_core::VersionReq::new("*");
565    let check_yanked = registry.reports_yanked();
566
567    let results: Vec<_> = stream::iter(package_sources)
568        .map(|(name, source)| {
569            let registry = Arc::clone(&registry);
570            let fetched = Arc::clone(&fetched);
571            let progress_sender = progress_sender.clone();
572            let wildcard_req = &wildcard_req;
573            let in_use_versions = in_use.get(&name).cloned().unwrap_or_default();
574            async move {
575                fetch_and_classify_package(
576                    registry.as_ref(),
577                    name,
578                    source,
579                    in_use_versions,
580                    wildcard_req,
581                    freshness,
582                    timeout,
583                    selection_context,
584                    check_yanked,
585                    gossip,
586                    &fetched,
587                    progress_sender.as_ref(),
588                )
589                .await
590            }
591        })
592        // `.max(1)`: defence-in-depth against a direct-field-assignment caller bypassing
593        // `with_max_concurrent_fetches`'s clamp with `0` — `buffer_unordered(0)` never
594        // polls its source stream, hanging every fetch through this document forever (#833).
595        .buffer_unordered(max_concurrent.max(1))
596        .collect()
597        .await;
598
599    let mut versions = HashMap::with_capacity(results.len());
600    let mut yanked_versions = HashMap::new();
601    let mut fetch_failed = HashMap::new();
602    let mut deprecations = HashMap::new();
603    let mut no_comparable_versions = HashSet::new();
604    let mut licenses = HashMap::new();
605    let mut failed_count: usize = 0;
606    // `fallback_message`'s tie-break among multiple not-found-only packages is completion
607    // order, not the old mutex-write order — a difference only observable in an artificial
608    // race, since every not-found message is interchangeable in the toast anyway (#480).
609    let mut priority_message: Option<String> = None;
610    let mut fallback_message: Option<String> = None;
611    for PackageOutcome {
612        name,
613        status,
614        yanked,
615    } in results
616    {
617        if let Some(y) = yanked {
618            yanked_versions.insert(name.clone(), y);
619        }
620        match status {
621            PackageStatus::Resolved {
622                versions: v,
623                deprecation,
624                license,
625            } => {
626                if let Some(d) = deprecation {
627                    deprecations.insert(name.clone(), d);
628                }
629                if let Some(lic) = license {
630                    licenses.insert(name.clone(), lic);
631                }
632                versions.insert(name, v);
633            }
634            PackageStatus::NoComparableVersions => {
635                no_comparable_versions.insert(name);
636            }
637            PackageStatus::NotFound { message } => {
638                failed_count += 1;
639                if fallback_message.is_none() {
640                    fallback_message = Some(message);
641                }
642            }
643            PackageStatus::Failed { failure, message } => {
644                failed_count += 1;
645                fetch_failed.insert(name, failure);
646                if priority_message.is_none() {
647                    priority_message = Some(message.clone());
648                }
649                if fallback_message.is_none() {
650                    fallback_message = Some(message);
651                }
652            }
653        }
654    }
655
656    let failure_summary = NonZeroUsize::new(failed_count).map(|count| {
657        #[allow(clippy::expect_used)]
658        let message = priority_message.or(fallback_message).expect(
659            "every branch that increments failed_count also sets fallback_message \
660             in the same match arm",
661        );
662        FailureSummary::new(count, message)
663    });
664
665    FetchResult {
666        versions,
667        yanked_versions,
668        deprecations,
669        fetch_failed,
670        no_comparable_versions,
671        failure_summary,
672        licenses,
673    }
674}
675
676/// One package's terminal registry-fetch status (issue #1470): replaces a 6-tuple of
677/// independent `Option`s whose illegal combinations (e.g. a resolved version alongside a
678/// fetch failure, or `NoComparableVersions` alongside a resolved version) were previously
679/// prevented only by careful match-arm discipline in [`fetch_latest_versions_parallel`]'s
680/// aggregation fold, not by the type system. Exactly one variant applies per package.
681///
682/// The license carried by [`Self::Resolved`] comes from `select_latest_matching`'s pick
683/// (critic S1: previously documented as "the resolved version's license", which was
684/// wrong — this function never reads `resolved_versions` at all, it picks the latest
685/// version matching the requirement/stability floor, same as `PackageVersions.latest`).
686enum PackageStatus {
687    /// The list-based pick (or its `get_latest_matching` fallback) resolved to a usable
688    /// version.
689    Resolved {
690        versions: PackageVersions,
691        /// Package-level deprecation finding (#205), derived from the resolved pick.
692        deprecation: Option<Deprecation>,
693        /// SPDX license identifier(s) (issue #660/#661 tier-1 backfill), `None` when the
694        /// ecosystem's `Version::license` was empty.
695        license: Option<Vec<String>>,
696    },
697    /// Both the list-based pick and the `get_latest_matching` fallback succeeded but found
698    /// nothing comparable (#550), e.g. tags that don't parse as full semver.
699    NoComparableVersions,
700    /// The registry answered "no such package" (#267 C1) — never counted in
701    /// [`FetchResult::fetch_failed`], but still counted toward the batch's failure total.
702    NotFound { message: String },
703    /// The fetch (or its fallback) errored for a reason other than not-found, or timed out.
704    Failed {
705        failure: FetchFailure,
706        message: String,
707    },
708}
709
710/// One package's fetch result: its terminal [`PackageStatus`] plus an independent
711/// in-use-version yank finding, folded into [`fetch_latest_versions_parallel`]'s aggregate
712/// `FetchResult` once every package in the stream has finished.
713///
714/// `yanked` is independent of `status` (not one of its variants) because it is sourced
715/// from the full version list fetched by the *initial* `get_versions_from` round trip
716/// (see the `check_yanked` block in [`fetch_and_classify_package`]), which can succeed even
717/// when the subsequent `get_latest_matching_from` fallback pick fails — so a package can be
718/// both [`PackageStatus::Failed`] and carry a yanked in-use-version finding at once.
719struct PackageOutcome {
720    name: PackageName,
721    status: PackageStatus,
722    yanked: Option<(ConcreteVersion, RemovalStatus)>,
723}
724
725/// A successfully picked "latest" version's extracted fields — [`Pick::Resolved`]'s payload,
726/// named so the yanked/deprecation/license extraction in [`fetch_and_classify_package`] reads
727/// as field access (`r.removal_status`, `&r.license`) rather than a positional tuple whose
728/// members are distinguished only by comment.
729struct ResolvedPick {
730    version: ConcreteVersion,
731    removal_status: RemovalStatus,
732    published_at: Option<deps_core::freshness::PublishTime>,
733    deprecation: Option<Deprecation>,
734    license: Vec<String>,
735}
736
737/// The list-based pick's outcome, or the `get_latest_matching` fallback's outcome when the
738/// list-based pick found nothing — an intermediate result [`fetch_and_classify_package`]
739/// uses to run the yanked/deprecation/license extraction once, uniformly, before it settles
740/// on a final [`PackageStatus`].
741enum Pick {
742    Resolved(ResolvedPick),
743    Unresolved(PackageStatus),
744}
745
746impl Pick {
747    /// Builds [`Self::Resolved`] from a picked `Version`, shared by the list-based pick and
748    /// the `get_latest_matching` fallback pick so the two success arms can't drift.
749    fn resolved(v: &dyn Version) -> Self {
750        Self::Resolved(ResolvedPick {
751            version: v.version_string().clone(),
752            removal_status: v.removal_status(),
753            published_at: v.published_at(),
754            deprecation: v.deprecation().cloned(),
755            license: v.license().to_vec(),
756        })
757    }
758}
759
760/// Fetches, classifies, and version-selects a single package within
761/// [`fetch_latest_versions_parallel`]'s concurrent stream: one round trip for the full
762/// version list, an in-memory "latest" pick with a `get_latest_matching_from` fallback
763/// when the list-based pick fails on a non-empty list, yanked/deprecation extraction, and
764/// an update to the shared `fetched` progress counter.
765#[allow(
766    clippy::too_many_arguments,
767    reason = "mirrors the per-package async closure this was extracted from — every \
768              parameter is either call-site fetch tuning already threaded through \
769              fetch_latest_versions_parallel or a counter/sender shared across the \
770              whole stream; grouping into a struct would only move, not reduce, churn"
771)]
772async fn fetch_and_classify_package(
773    registry: &dyn Registry,
774    name: PackageName,
775    source: deps_core::parser::DependencySource,
776    in_use_versions: Vec<ConcreteVersion>,
777    wildcard_req: &VersionReq,
778    freshness: deps_core::freshness::FreshnessSettings,
779    timeout: Duration,
780    selection_context: &deps_core::SelectionContext,
781    check_yanked: bool,
782    gossip: Option<&HashMap<PackageName, deps_core::GossipFindings>>,
783    fetched: &std::sync::atomic::AtomicUsize,
784    progress_sender: Option<&ProgressSender>,
785) -> PackageOutcome {
786    // Single round trip: the full version list is fetched once and "latest" is a pure
787    // in-memory pick over it, no second registry call. `get_versions_from` (source-aware,
788    // spec FR-001) over `get_versions`: populates `published_at` where supported (#339) and
789    // routes a resolved `AlternateRegistry` source to its own index — zero extra cost
790    // either way for registries with no override.
791    let result = tokio::time::timeout(
792        timeout,
793        registry.get_versions_from(&name, &source, freshness),
794    )
795    .await;
796
797    let mut yanked: Option<(ConcreteVersion, RemovalStatus)> = None;
798
799    let status = match result {
800        Ok(Ok(versions)) => {
801            let available: Arc<[ConcreteVersion]> = versions
802                .iter()
803                .map(|v| v.version_string().clone())
804                .collect();
805            // Retained alongside `available` so diagnostics can flag a requirement
806            // satisfiable only by a yanked version (`PackageVersions::yanked`). Gated on
807            // `check_yanked`: a registry unable to answer `removal_status()` (§#298) must
808            // not populate this with an untrustworthy always-`Available` signal. Carries
809            // each entry's `RemovalStatus` (#437) so #247's diagnostic path can gate
810            // deprecation suppression on `AdvisoryDeprecated` specifically, not `Yanked`.
811            let yanked_list: Arc<[(ConcreteVersion, RemovalStatus)]> = if check_yanked {
812                versions
813                    .iter()
814                    .filter_map(|v| {
815                        let status = v.removal_status();
816                        status
817                            .is_flagged()
818                            .then(|| (v.version_string().clone(), status))
819                    })
820                    .collect()
821            } else {
822                Arc::from([])
823            };
824
825            // Row 2/3 (§4.7, revised under #206) computed eagerly, against the full
826            // unfiltered `versions` list, before the GOSSIP-cooldown filter below consumes
827            // it — see that filter's own comment for why. Multiple occurrences of the same
828            // name (#394) can carry different in-use versions — every one is checked so a
829            // yanked pin on any occurrence is never missed. Filters on `is_flagged()` inside
830            // `find` itself (not a separate `.filter()`) so a response with multiple entries
831            // sharing `iv`'s version string still finds a flagged one if any exists (mirrors
832            // the pre-#205 `.any` scan).
833            let in_use_yanked: Option<(ConcreteVersion, RemovalStatus)> = check_yanked
834                .then(|| {
835                    in_use_versions.iter().find_map(|iv| {
836                        versions
837                            .iter()
838                            .find(|v| v.version_string() == iv && v.removal_status().is_flagged())
839                            .map(|v| (iv.clone(), v.removal_status()))
840                    })
841                })
842                .flatten();
843
844            // GOSSIP-cooldown floor-protected filter (spec 074 FR-003) — history in
845            // specs/074-deps-cli-gossip-parity/spec.md, not restated here.
846            //
847            // Computed first so the common case (not flagged) reuses this index below with
848            // zero extra `select_latest_matching` calls; captured as an owned `ConcreteVersion`
849            // since `Version` has no `clone_box` and `versions` is moved further down.
850            let unfiltered_pick_idx =
851                registry.select_latest_matching(&versions, wildcard_req, selection_context);
852            let unfiltered_pick_version: Option<ConcreteVersion> = unfiltered_pick_idx
853                .and_then(|idx| versions.get(idx))
854                .map(|v| v.version_string().clone());
855
856            // `now` read once per fetch call.
857            let now = deps_core::freshness::PublishTime::now();
858            // Shared gate (spec 075 FR-005/T000, DRY) — already required `Some` + active, so
859            // behavior here is unchanged; only the ad-hoc closure is replaced.
860            let unfiltered_pick_flagged = unfiltered_pick_version.as_ref().is_some_and(|version| {
861                deps_core::lsp_helpers::gossip_cooldown_for(gossip, &name, version.as_str(), now)
862                    == deps_core::lsp_helpers::GossipCooldownLookup::Active
863            });
864
865            // Spec 075 FR-001/FR-002 (T001): computed unconditionally whenever freshness is
866            // enabled, before `versions` is consumed below — read-time disposition
867            // (`deps_core::lsp_helpers::cooldown_disposition`) decides later whether `latest`
868            // is actually blocked and this candidate is needed. `compute_cooldown_fallback`
869            // itself short-circuits on `unfiltered_pick_idx` when that pick isn't cooldown-
870            // blocked (issue #1551 finding 4).
871            let cooldown_fallback = compute_cooldown_fallback(
872                registry,
873                &versions,
874                &name,
875                &in_use_versions,
876                wildcard_req,
877                *selection_context,
878                freshness,
879                gossip,
880                now,
881                unfiltered_pick_idx,
882            );
883
884            // The resolved pick (or `None`, meaning the `get_latest_matching_from` fallback
885            // below runs) and the FR-005 attribution field.
886            let (list_pick, gossip_excluded_version) = gossip_floor_protected_pick(
887                registry,
888                versions,
889                &in_use_versions,
890                wildcard_req,
891                *selection_context,
892                unfiltered_pick_idx,
893                unfiltered_pick_version,
894                unfiltered_pick_flagged,
895                gossip,
896                &name,
897                now,
898            );
899
900            // `selection_context` is threaded through so a registry with manifest-level
901            // stability state (Composer's `minimum-stability`, #424 S1) can apply it — already
902            // accounted for by `list_pick`'s own `select_latest_matching` call(s) above; no
903            // further selection call happens here.
904            let pick = if let Some(v) = list_pick.as_deref() {
905                tracing::debug!(
906                    package = %name.for_tracing(),
907                    version = %v.version_string(),
908                    "fetched"
909                );
910                Pick::resolved(v)
911            } else {
912                // The list-based pick found nothing — usually a genuine "no version", but
913                // a registry with an incomplete list endpoint (Go's `/@v/list`, which never
914                // enumerates pseudo-versions) may need the more complete `get_latest_matching`
915                // (Go's `/@latest`). Costs a second network call, only in this rare case.
916                get_latest_matching_fallback(
917                    registry,
918                    &name,
919                    &source,
920                    wildcard_req,
921                    *selection_context,
922                    timeout,
923                )
924                .await
925            };
926
927            let resolved: Option<&ResolvedPick> = match &pick {
928                Pick::Resolved(r) => Some(r),
929                Pick::Unresolved(_) => None,
930            };
931
932            if check_yanked {
933                // Row 1 (§4.7): the picked "latest" itself yanked — free, already in hand.
934                // Unreachable in production under today's hardcoded wildcard, but stays
935                // correct as a defense-in-depth check.
936                if let Some(r) = resolved
937                    && r.removal_status.is_flagged()
938                {
939                    yanked = Some((r.version.clone(), r.removal_status));
940                }
941
942                // A yanked in-use version wins over an already-recorded yanked `latest`
943                // since it's the version the user actually has — see `in_use_yanked`'s own
944                // comment above for why this is computed ahead of the GOSSIP filter.
945                if let Some(iv_yanked) = in_use_yanked {
946                    yanked = Some(iv_yanked);
947                }
948            }
949
950            // #205: the deprecation finding is derived from `resolved` (already picked as
951            // "latest"), covering the fallback branch too, whose `Version` isn't a member
952            // of `versions` at all — see `FetchResult::deprecations`'s doc for why this
953            // must not scan `versions` instead.
954            let deprecation = resolved.and_then(|r| r.deprecation.clone());
955
956            // #660/#661 tier-1 backfill. Filtered here so an empty license list —
957            // indistinguishable from "no data" once merged into
958            // `DocumentState::signals.licenses` — never gets inserted.
959            let license = resolved
960                .map(|r| &r.license)
961                .filter(|lic| !lic.is_empty())
962                .cloned();
963
964            match pick {
965                Pick::Resolved(ResolvedPick {
966                    version,
967                    published_at,
968                    ..
969                }) => {
970                    let mut versions =
971                        PackageVersions::new(version, available).with_yanked(yanked_list);
972                    if let Some(published_at) = published_at {
973                        versions = versions.with_published_at(published_at);
974                    }
975                    if let Some(excluded) = gossip_excluded_version {
976                        versions = versions.with_gossip_excluded_version(excluded);
977                    }
978                    if let Some(fallback) = cooldown_fallback {
979                        versions = versions.with_cooldown_fallback(fallback);
980                    }
981                    PackageStatus::Resolved {
982                        versions,
983                        deprecation,
984                        license,
985                    }
986                }
987                Pick::Unresolved(status) => status,
988            }
989        }
990        Ok(Err(e)) => {
991            // Issue #483: while offline, every fetch fails by design — this
992            // would otherwise log a per-dependency WARNING for every open/edit,
993            // contradicting the toast suppression two call sites away in this
994            // same file for being "unusable".
995            if e.is_offline() {
996                tracing::debug!(package = %name.for_tracing(), "fetch skipped: offline");
997            } else {
998                tracing::warn!(package = %name.for_tracing(), error = %e, "fetch failed");
999            }
1000            // A genuine not-found is not a fetch failure — only an unanswerable request is
1001            // (#267 C1). Marking it here would report "Registry lookup failed" for a
1002            // typo'd name instead of "Unknown package", inverting the bug this fixes.
1003            if e.is_not_found() {
1004                PackageStatus::NotFound {
1005                    message: e.to_string(),
1006                }
1007            } else {
1008                PackageStatus::Failed {
1009                    failure: e.fetch_failure(),
1010                    message: e.to_string(),
1011                }
1012            }
1013        }
1014        Err(_) => {
1015            tracing::warn!(package = %name.for_tracing(), "fetch timed out ({}s)", timeout.as_secs());
1016            PackageStatus::Failed {
1017                failure: FetchFailure::Transient,
1018                message: format!(
1019                    "{}: registry request timed out after {}s",
1020                    name.for_tracing(),
1021                    timeout.as_secs()
1022                ),
1023            }
1024        }
1025    };
1026
1027    let count = fetched.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
1028    if let Some(sender) = progress_sender {
1029        sender.send(count);
1030    }
1031
1032    PackageOutcome {
1033        name,
1034        status,
1035        yanked,
1036    }
1037}
1038
1039/// GOSSIP-cooldown floor-protected re-filter of the list-based pick (spec 074 FR-003),
1040/// extracted from [`fetch_and_classify_package`] (issue #1560): when the unfiltered pick
1041/// isn't cooldown-flagged, it is returned unchanged; otherwise candidates below the in-use
1042/// floor are filtered out and re-ranked, falling back to the unfiltered pick when no
1043/// acceptable filtered candidate remains (FR-003d/e). History in
1044/// specs/074-deps-cli-gossip-parity/spec.md, not restated here.
1045///
1046/// Returns the final list-based pick (`None` means the `get_latest_matching_from` fallback
1047/// must run) and the FR-005 attribution field (the version excluded by the floor filter, when
1048/// one was).
1049#[allow(
1050    clippy::too_many_arguments,
1051    reason = "every parameter is either data already owned by the caller (versions, the \
1052              in-use floor inputs) or a piece of the unfiltered pick the caller computed \
1053              once and must not recompute here — grouping into a struct would only move, \
1054              not reduce, the parameter count"
1055)]
1056fn gossip_floor_protected_pick(
1057    registry: &dyn Registry,
1058    versions: Vec<Box<dyn Version>>,
1059    in_use_versions: &[ConcreteVersion],
1060    wildcard_req: &VersionReq,
1061    selection_context: deps_core::SelectionContext,
1062    unfiltered_pick_idx: Option<usize>,
1063    unfiltered_pick_version: Option<ConcreteVersion>,
1064    unfiltered_pick_flagged: bool,
1065    gossip: Option<&HashMap<PackageName, deps_core::GossipFindings>>,
1066    name: &PackageName,
1067    now: deps_core::freshness::PublishTime,
1068) -> (Option<Box<dyn Version>>, Option<ConcreteVersion>) {
1069    if !unfiltered_pick_flagged {
1070        return (
1071            unfiltered_pick_idx.and_then(|idx| versions.into_iter().nth(idx)),
1072            None,
1073        );
1074    }
1075
1076    let is_gossip_cooldown = |version: &ConcreteVersion| {
1077        deps_core::lsp_helpers::gossip_cooldown_for(gossip, name, version.as_str(), now)
1078            == deps_core::lsp_helpers::GossipCooldownLookup::Active
1079    };
1080
1081    // Protect floor (FR-003b: no-op when no in-use version resolved) — shared with
1082    // `compute_cooldown_fallback`'s own D2 floor (issue #1551 finding 2). Spec 076 FR-018:
1083    // this GOSSIP site floors at `newest_located` even under `Unlocatable` (byte-identical
1084    // to the pre-`InUseFloor` behavior) — `compute_cooldown_fallback` alone tightens further.
1085    let floor = match in_use_floor(in_use_versions, &versions) {
1086        InUseFloor::Located(floor)
1087        | InUseFloor::Unlocatable {
1088            newest_located: Some(floor),
1089        } => floor,
1090        InUseFloor::Absent
1091        | InUseFloor::Unlocatable {
1092            newest_located: None,
1093        } => {
1094            return (
1095                unfiltered_pick_idx.and_then(|idx| versions.into_iter().nth(idx)),
1096                None,
1097            );
1098        }
1099    };
1100
1101    // Keep each candidate's original index alongside it (parallel
1102    // `filtered_indices`/`filtered_versions`, since `select_latest_matching` needs a plain
1103    // slice) so the final pick's position can be checked against `floor` (FR-003e) and the
1104    // unfiltered pick recovered by index, not version string (avoids matching the wrong
1105    // duplicate-string entry).
1106    type IndexedVersion = (usize, Box<dyn Version>);
1107    let mut filtered_indices: Vec<usize> = Vec::new();
1108    let mut filtered_versions: Vec<Box<dyn Version>> = Vec::new();
1109    let mut dropped: Vec<IndexedVersion> = Vec::new();
1110    for (idx, v) in versions.into_iter().enumerate() {
1111        if idx >= floor || !is_gossip_cooldown(v.version_string()) {
1112            filtered_indices.push(idx);
1113            filtered_versions.push(v);
1114        } else {
1115            dropped.push((idx, v));
1116        }
1117    }
1118
1119    let filtered_pick_idx =
1120        registry.select_latest_matching(&filtered_versions, wildcard_req, &selection_context);
1121    // Reject a filtered pick older than the floor (FR-003e) — a downgrade.
1122    let pick_at_or_above_floor = filtered_pick_idx
1123        .and_then(|idx| filtered_indices.get(idx))
1124        .is_some_and(|original_idx| *original_idx <= floor);
1125
1126    if pick_at_or_above_floor {
1127        let filtered_pick_version = filtered_pick_idx
1128            .and_then(|idx| filtered_versions.get(idx))
1129            .map(|v| v.version_string().clone());
1130        let excluded = (filtered_pick_version != unfiltered_pick_version)
1131            .then(|| unfiltered_pick_version.clone())
1132            .flatten();
1133        (
1134            filtered_pick_idx.and_then(|idx| filtered_versions.into_iter().nth(idx)),
1135            excluded,
1136        )
1137    } else {
1138        // No acceptable pick (FR-003d/e) — recover the unfiltered pick by its original
1139        // index (dropped, or defensively filtered_versions).
1140        let recovered = unfiltered_pick_idx.and_then(|target| {
1141            dropped
1142                .iter()
1143                .position(|(idx, _)| *idx == target)
1144                .map(|i| dropped.swap_remove(i).1)
1145                .or_else(|| {
1146                    filtered_indices
1147                        .iter()
1148                        .position(|idx| *idx == target)
1149                        .map(|i| filtered_versions.swap_remove(i))
1150                })
1151        });
1152        (recovered, None)
1153    }
1154}
1155
1156/// `get_latest_matching_from` network fallback, extracted from
1157/// [`fetch_and_classify_package`] (issue #1560): run only when the list-based pick found
1158/// nothing, for a registry with an incomplete list endpoint (Go's `/@v/list`, which never
1159/// enumerates pseudo-versions) that may still answer through the more complete
1160/// `get_latest_matching` (Go's `/@latest`). Costs a second network call, only in this rare
1161/// case.
1162async fn get_latest_matching_fallback(
1163    registry: &dyn Registry,
1164    name: &PackageName,
1165    source: &deps_core::parser::DependencySource,
1166    wildcard_req: &VersionReq,
1167    selection_context: deps_core::SelectionContext,
1168    timeout: Duration,
1169) -> Pick {
1170    let fallback = tokio::time::timeout(
1171        timeout,
1172        registry.get_latest_matching_from(name, source, wildcard_req, &selection_context),
1173    )
1174    .await;
1175    match fallback {
1176        Ok(Ok(Some(v))) => {
1177            tracing::debug!(
1178                package = %name.for_tracing(),
1179                version = %v.version_string(),
1180                "fetched via get_latest_matching fallback"
1181            );
1182            Pick::resolved(v.as_ref())
1183        }
1184        Ok(Ok(None)) => {
1185            tracing::debug!(package = %name.for_tracing(), "no version found");
1186            // Both the list-based pick and this fallback succeeded and found nothing — the
1187            // package exists but has zero comparable versions (#550). Distinct from every
1188            // branch below that produces `Failed`.
1189            Pick::Unresolved(PackageStatus::NoComparableVersions)
1190        }
1191        Ok(Err(e)) => {
1192            tracing::warn!(
1193                package = %name.for_tracing(),
1194                error = %e,
1195                "fetch fallback failed"
1196            );
1197            // A genuine not-found (the registry was successfully asked and said "no such
1198            // package") is not a fetch failure — only an unanswerable request is (#267 C1).
1199            if e.is_not_found() {
1200                Pick::Unresolved(PackageStatus::NotFound {
1201                    message: e.to_string(),
1202                })
1203            } else {
1204                Pick::Unresolved(PackageStatus::Failed {
1205                    failure: e.fetch_failure(),
1206                    message: e.to_string(),
1207                })
1208            }
1209        }
1210        Err(_) => {
1211            tracing::warn!(package = %name.for_tracing(), "fetch fallback timed out ({}s)", timeout.as_secs());
1212            Pick::Unresolved(PackageStatus::Failed {
1213                failure: FetchFailure::Transient,
1214                message: format!(
1215                    "{}: registry request timed out after {}s",
1216                    name.for_tracing(),
1217                    timeout.as_secs()
1218                ),
1219            })
1220        }
1221    }
1222}
1223
1224/// A cheap, cloneable [`Version`] carrying just the fields [`compute_cooldown_fallback`]'s
1225/// re-ranking pass needs — lets it build an owned candidate list for
1226/// [`Registry::select_latest_matching`] from borrowed data, since `Version` trait objects have
1227/// no `clone_box` (fetch.rs's own `IndexedVersion` comment explains why: `versions` is moved
1228/// further down this same function and no ecosystem needs to duplicate a real `Version` impl).
1229#[derive(Debug, Clone)]
1230struct CooldownCandidate {
1231    version: ConcreteVersion,
1232    published_at: deps_core::freshness::PublishTime,
1233    removal_status: RemovalStatus,
1234    prerelease: bool,
1235}
1236
1237impl Version for CooldownCandidate {
1238    fn version_string(&self) -> &ConcreteVersion {
1239        &self.version
1240    }
1241    fn published_at(&self) -> Option<deps_core::freshness::PublishTime> {
1242        Some(self.published_at)
1243    }
1244    fn removal_status(&self) -> RemovalStatus {
1245        self.removal_status
1246    }
1247    fn is_prerelease(&self) -> bool {
1248        self.prerelease
1249    }
1250    fn as_any(&self) -> &dyn std::any::Any {
1251        self
1252    }
1253}
1254
1255/// D2 in-use floor classification (spec 074 FR-003b / spec 075 OQ1 / spec 076 FR-016/FR-017):
1256/// position of the newest `in_use_versions` entry within `versions` (newest-first, so the
1257/// smallest index is newest), distinguishing "no in-use version at all" from "an in-use version
1258/// exists but could not be placed in `versions`" — a partial match (one entry locatable, one
1259/// not) previously silently floored at the locatable entry and ignored the unplaceable one
1260/// (round-1 critic M2).
1261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1262enum InUseFloor {
1263    /// `in_use_versions` is empty.
1264    Absent,
1265    /// Every resolvable `in_use_versions` entry maps to a position in `versions`; the `usize`
1266    /// is the newest (smallest index).
1267    Located(usize),
1268    /// At least one `in_use_versions` entry does not map to any position in `versions` (a Go
1269    /// pseudo-version, a private-registry pin, or a stale lockfile entry). `newest_located`
1270    /// carries the newest position among the entries that DID resolve, or `None` if none did.
1271    Unlocatable {
1272        /// Newest position among the entries that did resolve, if any.
1273        newest_located: Option<usize>,
1274    },
1275}
1276
1277/// Shared by [`fetch_and_classify_package`]'s GOSSIP floor-protected filter and
1278/// [`compute_cooldown_fallback`]'s own floor (issue #1551 finding 2). Both agree on
1279/// `Absent`/`Located`, but deliberately diverge on `Unlocatable { newest_located: Some(_) }`
1280/// (spec 076 FR-018): the GOSSIP filter still floors at `newest_located`, while
1281/// `compute_cooldown_fallback` fails closed to no fallback at all — stricter than silently
1282/// flooring at only the locatable entries and ignoring the unplaceable one.
1283fn in_use_floor(in_use_versions: &[ConcreteVersion], versions: &[Box<dyn Version>]) -> InUseFloor {
1284    if in_use_versions.is_empty() {
1285        return InUseFloor::Absent;
1286    }
1287
1288    let mut newest_located: Option<usize> = None;
1289    let mut all_located = true;
1290    for iv in in_use_versions {
1291        match versions.iter().position(|v| v.version_string() == iv) {
1292            Some(idx) => newest_located = Some(newest_located.map_or(idx, |cur| cur.min(idx))),
1293            None => all_located = false,
1294        }
1295    }
1296
1297    match (all_located, newest_located) {
1298        // Non-empty `in_use_versions` with every entry located always yields a position; the
1299        // `None` arm is unreachable in practice but falls back to `Unlocatable` (fail closed)
1300        // rather than panicking.
1301        (true, Some(idx)) => InUseFloor::Located(idx),
1302        (true, None) | (false, _) => InUseFloor::Unlocatable { newest_located },
1303    }
1304}
1305
1306/// Spec 075 FR-001/FR-002: computes the cooldown-fallback candidate for one dependency.
1307///
1308/// (Fix-cycle item 5/S5): the cooled subset is ranked through `registry`'s own
1309/// `select_latest_matching` — the exact same selection algorithm that picks `latest` itself —
1310/// rather than by raw newest-first list position. A naive "first cooldown-cleared entry by
1311/// date" pick can land on a prerelease/canary release a frequent publisher ships between
1312/// stable releases; FR-001's ecosystem-safety guard would then reject it with no retry,
1313/// reopening the starvation this feature exists to close. Delegating the ranking itself to
1314/// `select_latest_matching` means the pick is already ecosystem-preferred by construction; the
1315/// explicit `ecosystem_safe` check below is defense in depth against `select_latest_matching`'s
1316/// own wildcard-existence fallback (which may prefer a yanked version to answer "does this
1317/// package exist" rather than ever returning `None` — never appropriate for a fallback write
1318/// target).
1319///
1320/// `unfiltered_pick_idx` is the same list-based pick [`fetch_and_classify_package`] computed
1321/// over the full, unfiltered `versions` (before any GOSSIP floor-protected filtering) — issue
1322/// #1551 finding 4: when that pick is known (`Some`) AND isn't itself cooldown-blocked, the
1323/// full-history GOSSIP/local-heuristic loop below is skipped entirely, since
1324/// `cooldown_disposition` only ever surfaces a stored fallback candidate from inside its
1325/// `Blocked` arm — a candidate computed here for an already-cleared pick would never be read.
1326/// `None` (the list-based pick found nothing, e.g. Go's incomplete `/@v/list` endpoint that
1327/// never enumerates pseudo-versions, while the network `get_latest_matching_from` fallback
1328/// still resolves a real `latest`) never short-circuits — the full search below always ran in
1329/// that case before this finding, and still does.
1330///
1331/// Timing caveat (impl-critic M1): the short-circuit's `cooldown_precedence` check runs at
1332/// fetch time (`now`, captured once per package in [`fetch_and_classify_package`], strictly
1333/// *after* `deps-cli`'s `ManifestAnalysis::now` — the earlier instant the later read-time
1334/// `cooldown_disposition` call actually evaluates against). Since age only grows between the
1335/// two captures, this can only make the fetch-time check see a version as *more* cleared than
1336/// the read-time check would — never the reverse. So a version whose cooldown boundary falls
1337/// between the two instants can short-circuit to `None` here even though `cooldown_disposition`
1338/// still finds it `Blocked` moments later, silently losing a fallback candidate the pre-#1551
1339/// code would have computed (surfacing as an unnecessary `WithinFreshnessCooldown` skip rather
1340/// than an applied fallback). This window is bounded by one package's own fetch latency, not the
1341/// whole batch's, and never causes the reverse mistake (writing a candidate that is actually
1342/// still cooldown-blocked) — but it means this short-circuit is a narrow, real divergence from
1343/// the prior always-compute behavior, not a proven-identical optimization.
1344#[allow(
1345    clippy::too_many_arguments,
1346    reason = "mirrors fetch_and_classify_package's own identical rationale — every parameter \
1347              is either registry/selection-rule plumbing already threaded through that \
1348              function or a planning knob this helper alone needs"
1349)]
1350fn compute_cooldown_fallback(
1351    registry: &dyn Registry,
1352    versions: &[Box<dyn Version>],
1353    name: &PackageName,
1354    in_use_versions: &[ConcreteVersion],
1355    wildcard_req: &VersionReq,
1356    selection_context: deps_core::SelectionContext,
1357    freshness: deps_core::freshness::FreshnessSettings,
1358    gossip: Option<&HashMap<PackageName, deps_core::GossipFindings>>,
1359    now: deps_core::freshness::PublishTime,
1360    unfiltered_pick_idx: Option<usize>,
1361) -> Option<deps_core::lsp_helpers::CooldownFallback> {
1362    let deps_core::freshness::FreshnessSettings::Enabled { cooldown } = freshness else {
1363        return None;
1364    };
1365
1366    let unfiltered_pick = unfiltered_pick_idx.and_then(|idx| versions.get(idx));
1367    let latest_is_prerelease = unfiltered_pick.is_some_and(|v| v.is_prerelease());
1368    // Tester finding: only short-circuit when the pick is known (`Some`) — an unknown pick
1369    // (`None`) must fall through to the full search below exactly like the pre-#1551 code,
1370    // never treated as "cleared".
1371    if let Some(pick) = unfiltered_pick {
1372        let cleared = matches!(
1373            deps_core::lsp_helpers::cooldown_precedence(
1374                gossip,
1375                name,
1376                pick.version_string().as_str(),
1377                pick.published_at(),
1378                cooldown,
1379                now,
1380            ),
1381            deps_core::lsp_helpers::CooldownPrecedence::Cleared
1382        );
1383        if cleared {
1384            return None;
1385        }
1386    }
1387
1388    // D2 floor (spec 076 FR-018): `Located` sets the floor unchanged from spec 075; `Absent`
1389    // (no in-use version resolved at all) computes the fallback with NO positional floor —
1390    // spec 076's core no-lockfile feature; `Unlocatable` (at least one in-use version could not
1391    // be placed) yields no fallback at all — stricter than the prior `.min()?` behavior, which
1392    // silently floored at only the locatable entries and ignored the unplaceable one.
1393    let floor: Option<usize> = match in_use_floor(in_use_versions, versions) {
1394        InUseFloor::Located(idx) => Some(idx),
1395        InUseFloor::Absent => None,
1396        InUseFloor::Unlocatable { .. } => return None,
1397    };
1398
1399    // Every candidate that clears cooldown via the same shared precedence rule (an
1400    // authoritative GOSSIP answer wins, the local heuristic applies otherwise) — fail-closed
1401    // when `published_at` is unknown (OQ2) — paired with its original index into `versions`.
1402    let (cleared_indices, cleared_versions): (Vec<usize>, Vec<Box<dyn Version>>) = versions
1403        .iter()
1404        .enumerate()
1405        .filter_map(|(idx, v)| {
1406            let published_at = v.published_at()?;
1407            let cleared = matches!(
1408                deps_core::lsp_helpers::cooldown_precedence(
1409                    gossip,
1410                    name,
1411                    v.version_string().as_str(),
1412                    Some(published_at),
1413                    cooldown,
1414                    now,
1415                ),
1416                deps_core::lsp_helpers::CooldownPrecedence::Cleared
1417            );
1418            cleared.then(|| {
1419                let candidate: Box<dyn Version> = Box::new(CooldownCandidate {
1420                    version: v.version_string().clone(),
1421                    published_at,
1422                    removal_status: v.removal_status(),
1423                    prerelease: v.is_prerelease(),
1424                });
1425                (idx, candidate)
1426            })
1427        })
1428        .unzip();
1429
1430    // FR-001: the ecosystem's own selection-rule pick over the cooled subset — checked only
1431    // against this single pick, no retry further down the list on failure.
1432    let pick =
1433        registry.select_latest_matching(&cleared_versions, wildcard_req, &selection_context)?;
1434    let idx = *cleared_indices.get(pick)?;
1435    let candidate = versions.get(idx)?;
1436    let published_at = candidate.published_at()?;
1437    let ecosystem_safe = !candidate.removal_status().blocks_resolution()
1438        && (!candidate.is_prerelease() || latest_is_prerelease);
1439    let above_floor = floor.is_none_or(|floor| idx < floor);
1440
1441    (ecosystem_safe && above_floor).then(|| {
1442        deps_core::lsp_helpers::CooldownFallback::new(
1443            candidate.version_string().clone(),
1444            published_at,
1445        )
1446    })
1447}
1448
1449/// Re-keys a completed fetch's yanked/fetch-failure findings from raw to normalized package
1450/// names and applies them to `outcomes`.
1451///
1452/// Also records every collided name (two occurrences resolving to different sources,
1453/// [`dedup_dependencies_by_source`]) as not-attempted.
1454///
1455/// `set_fetch_failure_if_absent` (not `set_fetch_failure`) for `collided_names`: a collided
1456/// name normalizing to the same key as a genuine failure just recorded above must not
1457/// clobber it (impl-critic M2).
1458///
1459/// # Examples
1460///
1461/// ```
1462/// use deps_core::lsp_helpers::{
1463///     DependencyOutcomes, DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming,
1464///     PackageRendering, RequirementResolution, SourcePolicy,
1465/// };
1466/// use deps_core::{ConcreteVersion, PackageName, RemovalStatus};
1467/// use deps_engine::classify::fetch::apply_fetch_outcomes;
1468/// use std::collections::{HashMap, HashSet};
1469///
1470/// struct SimpleFormatter;
1471/// impl PackageNaming for SimpleFormatter {}
1472/// impl PackageRendering for SimpleFormatter {
1473///     fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
1474///         version.to_string()
1475///     }
1476///     fn package_url(&self, name: &PackageName) -> String {
1477///         name.as_str().to_string()
1478///     }
1479/// }
1480/// impl RequirementResolution for SimpleFormatter {}
1481/// impl DiagnosticMessages for SimpleFormatter {}
1482/// impl DiagnosticPolicy for SimpleFormatter {}
1483/// impl SourcePolicy for SimpleFormatter {}
1484/// impl OsvNaming for SimpleFormatter {}
1485///
1486/// let mut outcomes = DependencyOutcomes::new();
1487/// let mut yanked = HashMap::new();
1488/// yanked.insert(
1489///     PackageName::new("time"),
1490///     (ConcreteVersion::from("0.1.43"), RemovalStatus::Yanked),
1491/// );
1492///
1493/// apply_fetch_outcomes(
1494///     &mut outcomes,
1495///     yanked,
1496///     HashMap::new(),
1497///     HashSet::new(),
1498///     &SimpleFormatter,
1499/// );
1500///
1501/// assert_eq!(
1502///     outcomes.yanked("time").map(|(v, _)| v.to_string()),
1503///     Some("0.1.43".to_string())
1504/// );
1505/// ```
1506pub fn apply_fetch_outcomes(
1507    outcomes: &mut deps_core::lsp_helpers::DependencyOutcomes,
1508    yanked_versions: HashMap<PackageName, (ConcreteVersion, RemovalStatus)>,
1509    fetch_failed: HashMap<PackageName, FetchFailure>,
1510    collided_names: HashSet<PackageName>,
1511    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
1512) {
1513    for (name, version) in yanked_versions {
1514        outcomes.set_yanked(formatter.normalize_package_name(&name), version);
1515    }
1516    for (name, failure) in fetch_failed {
1517        outcomes.set_fetch_failure(formatter.normalize_package_name(&name), failure);
1518    }
1519    for name in collided_names {
1520        outcomes.set_fetch_failure_if_absent(
1521            formatter.normalize_package_name(&name),
1522            FetchFailure::NotAttempted,
1523        );
1524    }
1525}
1526
1527#[cfg(test)]
1528mod tests {
1529    use super::*;
1530    use deps_core::SelectionContext;
1531    use deps_core::parser::DependencySource;
1532
1533    /// Pairs every name with the plain `Registry` source — the shape every pre-existing
1534    /// `fetch_latest_versions_parallel` test used before that function became source-aware
1535    /// (spec FR-001). Production call sites build real `(name, source)` pairs from a
1536    /// parsed manifest via `dedup_dependencies_by_source` instead.
1537    fn with_registry_source(names: Vec<PackageName>) -> Vec<(PackageName, DependencySource)> {
1538        names
1539            .into_iter()
1540            .map(|name| (name, DependencySource::Registry))
1541            .collect()
1542    }
1543
1544    #[cfg(feature = "cargo")]
1545    mod apply_fetch_outcomes_tests {
1546        use super::*;
1547        use crate::setup::CargoFormatter;
1548        use deps_core::lsp_helpers::DependencyOutcomes;
1549
1550        #[test]
1551        fn apply_fetch_outcomes_sets_yanked_re_keyed_to_normalized_name() {
1552            let mut outcomes = DependencyOutcomes::new();
1553            let mut yanked_versions = HashMap::new();
1554            yanked_versions.insert(
1555                PackageName::new("time"),
1556                ("0.1.43".into(), RemovalStatus::Yanked),
1557            );
1558
1559            apply_fetch_outcomes(
1560                &mut outcomes,
1561                yanked_versions,
1562                HashMap::new(),
1563                HashSet::new(),
1564                &CargoFormatter,
1565            );
1566
1567            assert_eq!(
1568                outcomes.yanked("time"),
1569                Some(&("0.1.43".into(), RemovalStatus::Yanked))
1570            );
1571        }
1572
1573        /// Loop order (yanked → fetch_failed → collided) and `set_fetch_failure_if_absent`
1574        /// (impl-critic M2): a collided name that normalizes to the same key as a genuine
1575        /// failure recorded just before it must not clobber that failure.
1576        #[test]
1577        fn apply_fetch_outcomes_collided_name_does_not_clobber_existing_fetch_failure() {
1578            let mut outcomes = DependencyOutcomes::new();
1579            let mut fetch_failed = HashMap::new();
1580            fetch_failed.insert(PackageName::new("serde"), FetchFailure::Transient);
1581            let mut collided_names = HashSet::new();
1582            collided_names.insert(PackageName::new("serde"));
1583
1584            apply_fetch_outcomes(
1585                &mut outcomes,
1586                HashMap::new(),
1587                fetch_failed,
1588                collided_names,
1589                &CargoFormatter,
1590            );
1591
1592            assert_eq!(
1593                outcomes.fetch_failure("serde"),
1594                Some(&FetchFailure::Transient),
1595                "a genuine fetch failure must survive a collided name normalizing to the same key"
1596            );
1597        }
1598
1599        /// The other half of the precedence rule: a collided name with no pre-existing
1600        /// failure under its normalized key must still be recorded as not-attempted.
1601        #[test]
1602        fn apply_fetch_outcomes_collided_name_alone_is_recorded_as_not_attempted() {
1603            let mut outcomes = DependencyOutcomes::new();
1604            let mut collided_names = HashSet::new();
1605            collided_names.insert(PackageName::new("serde"));
1606
1607            apply_fetch_outcomes(
1608                &mut outcomes,
1609                HashMap::new(),
1610                HashMap::new(),
1611                collided_names,
1612                &CargoFormatter,
1613            );
1614
1615            assert_eq!(
1616                outcomes.fetch_failure("serde"),
1617                Some(&FetchFailure::NotAttempted)
1618            );
1619        }
1620    }
1621
1622    mod dedup_by_source_collision_tests {
1623        use super::*;
1624        use deps_core::Dependency;
1625        use deps_core::position::{Position, Range};
1626        use deps_core::test_util::StubFormatter;
1627        use std::any::Any;
1628
1629        /// Treats both `Registry` and `AlternateRegistry` as resolvable, mirroring the real
1630        /// `CargoFormatter`'s own `resolves_alternate_registry` override — needed so two
1631        /// distinct source values can both pass gate 1 (resolvability) and reach gate 2
1632        /// (collision) in the same test.
1633        const ALTERNATE_AWARE_FORMATTER: StubFormatter =
1634            StubFormatter::new().with_alternate_registry_resolution();
1635
1636        struct MockDep {
1637            name: PackageName,
1638            source: DependencySource,
1639            addr_tag: u32,
1640        }
1641
1642        impl Dependency for MockDep {
1643            fn name(&self) -> &PackageName {
1644                &self.name
1645            }
1646            fn name_range(&self) -> Range {
1647                Range::new(
1648                    Position::new(0, self.addr_tag),
1649                    Position::new(0, self.addr_tag + 1),
1650                )
1651            }
1652            fn version_requirement(&self) -> Option<&VersionReq> {
1653                None
1654            }
1655            fn version_range(&self) -> Option<Range> {
1656                None
1657            }
1658            fn source(&self) -> DependencySource {
1659                self.source.clone()
1660            }
1661            fn as_any(&self) -> &dyn Any {
1662                self
1663            }
1664        }
1665
1666        struct MockParseResult {
1667            deps: Vec<MockDep>,
1668        }
1669
1670        impl deps_core::ParseResult for MockParseResult {
1671            fn dependencies(&self) -> Vec<&dyn Dependency> {
1672                self.deps.iter().map(|d| d as &dyn Dependency).collect()
1673            }
1674            fn workspace_root(&self) -> Option<&std::path::Path> {
1675                None
1676            }
1677            fn uri(&self) -> &url::Url {
1678                static URI: std::sync::OnceLock<url::Url> = std::sync::OnceLock::new();
1679                URI.get_or_init(|| deps_core::test_util::test_uri("/test/Cargo.toml"))
1680            }
1681            fn as_any(&self) -> &dyn Any {
1682                self
1683            }
1684        }
1685
1686        #[test]
1687        fn test_two_different_resolvable_sources_collide_and_are_dropped() {
1688            let parse_result = MockParseResult {
1689                deps: vec![
1690                    MockDep {
1691                        name: PackageName::new("shared-name"),
1692                        source: DependencySource::Registry,
1693                        addr_tag: 0,
1694                    },
1695                    MockDep {
1696                        name: PackageName::new("shared-name"),
1697                        source: DependencySource::AlternateRegistry {
1698                            index: "https://index.mycorp.dev".into(),
1699                            mirrors_crates_io: false,
1700                        },
1701                        addr_tag: 1,
1702                    },
1703                ],
1704            };
1705
1706            let (sources, collided) =
1707                dedup_dependencies_by_source(&parse_result, &ALTERNATE_AWARE_FORMATTER);
1708
1709            assert!(
1710                !sources.contains_key(&PackageName::new("shared-name")),
1711                "a colliding name must not be fetched under either source"
1712            );
1713            assert!(
1714                collided.contains(&PackageName::new("shared-name")),
1715                "the collision must be recorded so the caller can mark it fetch_failed"
1716            );
1717        }
1718
1719        #[test]
1720        fn test_identical_sources_do_not_collide() {
1721            let parse_result = MockParseResult {
1722                deps: vec![
1723                    MockDep {
1724                        name: PackageName::new("shared-name"),
1725                        source: DependencySource::Registry,
1726                        addr_tag: 0,
1727                    },
1728                    MockDep {
1729                        name: PackageName::new("shared-name"),
1730                        source: DependencySource::Registry,
1731                        addr_tag: 1,
1732                    },
1733                ],
1734            };
1735
1736            let (sources, collided) =
1737                dedup_dependencies_by_source(&parse_result, &ALTERNATE_AWARE_FORMATTER);
1738
1739            assert!(collided.is_empty());
1740            assert_eq!(
1741                sources.get(&PackageName::new("shared-name")),
1742                Some(&DependencySource::Registry)
1743            );
1744        }
1745
1746        /// Gate 1 (Critical review finding #1): a non-resolvable source is dropped
1747        /// entirely, never reaching the fetch — this is what prevents a Git/Path
1748        /// dependency's name from being looked up against the ecosystem's default
1749        /// registry via the background fetch's routing default arm.
1750        #[test]
1751        fn test_non_resolvable_source_is_dropped_not_fetched() {
1752            let parse_result = MockParseResult {
1753                deps: vec![MockDep {
1754                    name: PackageName::new("local-fork"),
1755                    source: DependencySource::Path {
1756                        path: "../local-fork".into(),
1757                    },
1758                    addr_tag: 0,
1759                }],
1760            };
1761
1762            let (sources, collided) =
1763                dedup_dependencies_by_source(&parse_result, &ALTERNATE_AWARE_FORMATTER);
1764
1765            assert!(sources.is_empty());
1766            assert!(collided.is_empty());
1767        }
1768
1769        /// #935/#936 sink-level regression test, mirroring the repo's precedent for this bug
1770        /// class (`deps-cargo/src/parser.rs`'s `test_parse_registry_index_env_collision_...`
1771        /// tests, and cache.rs #756): pinning the type-level fix (`DependencySource`'s
1772        /// hand-written `Debug`) alone leaves the actual `tracing::warn!(?source, ...)` call
1773        /// site in this function untested. Two `AlternateRegistry` sources, each carrying a
1774        /// distinct query-string credential, collide — this is the exact `tracing::warn!`
1775        /// this module emits with `source_a`/`source_b` via `?` (Debug) formatting.
1776        #[test]
1777        fn test_collision_warning_redacts_credentials_in_alternate_registry_debug_output() {
1778            let parse_result = MockParseResult {
1779                deps: vec![
1780                    MockDep {
1781                        name: PackageName::new("shared-name"),
1782                        source: DependencySource::AlternateRegistry {
1783                            index: "https://index-a.mycorp.dev/api?api_key=SECRET_A".into(),
1784                            mirrors_crates_io: false,
1785                        },
1786                        addr_tag: 0,
1787                    },
1788                    MockDep {
1789                        name: PackageName::new("shared-name"),
1790                        source: DependencySource::AlternateRegistry {
1791                            index: "https://index-b.mycorp.dev/api?api_key=SECRET_B".into(),
1792                            mirrors_crates_io: false,
1793                        },
1794                        addr_tag: 1,
1795                    },
1796                ],
1797            };
1798
1799            let log = deps_core::test_util::capture_tracing_output(|| {
1800                let (sources, collided) =
1801                    dedup_dependencies_by_source(&parse_result, &ALTERNATE_AWARE_FORMATTER);
1802                assert!(!sources.contains_key(&PackageName::new("shared-name")));
1803                assert!(collided.contains(&PackageName::new("shared-name")));
1804            });
1805
1806            assert!(
1807                log.contains("two different resolved registries"),
1808                "expected the collision WARN to fire: {log:?}"
1809            );
1810            assert!(
1811                !log.contains("SECRET_A") && !log.contains("SECRET_B"),
1812                "tracing output leaked a query-string credential: {log:?}"
1813            );
1814            assert!(
1815                log.contains("index-a.mycorp.dev") && log.contains("index-b.mycorp.dev"),
1816                "host should survive redaction: {log:?}"
1817            );
1818        }
1819    }
1820
1821    #[tokio::test]
1822    async fn test_fetch_latest_versions_parallel_with_timeout() {
1823        use deps_core::{Metadata, Registry, Version};
1824        use std::any::Any;
1825        use std::time::Duration;
1826
1827        struct TimeoutRegistry;
1828
1829        impl Registry for TimeoutRegistry {
1830            fn get_versions<'a>(
1831                &'a self,
1832                _name: &'a deps_core::PackageName,
1833            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1834            {
1835                Box::pin(async move {
1836                    tokio::time::sleep(Duration::from_secs(10)).await;
1837                    Ok(vec![])
1838                })
1839            }
1840
1841            fn get_latest_matching<'a>(
1842                &'a self,
1843                _name: &'a deps_core::PackageName,
1844                _req: &'a deps_core::VersionReq,
1845                _selection_context: &'a deps_core::SelectionContext,
1846            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1847            {
1848                Box::pin(async move {
1849                    tokio::time::sleep(Duration::from_secs(10)).await;
1850                    Ok(None)
1851                })
1852            }
1853
1854            fn search_raw<'a>(
1855                &'a self,
1856                _query: &'a str,
1857                _limit: usize,
1858            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1859            {
1860                Box::pin(async move { Ok(vec![]) })
1861            }
1862
1863            fn as_any(&self) -> &dyn Any {
1864                self
1865            }
1866        }
1867
1868        let registry: Arc<dyn Registry> = Arc::new(TimeoutRegistry);
1869        let packages = vec![PackageName::new("slow-package")];
1870
1871        let result = fetch_latest_versions_parallel(
1872            registry,
1873            with_registry_source(packages),
1874            &HashMap::new(),
1875            None,
1876            deps_core::freshness::FreshnessSettings::default(),
1877            1,
1878            10,
1879            &SelectionContext::none(),
1880            None,
1881        )
1882        .await;
1883
1884        assert!(result.versions.is_empty(), "Slow package should timeout");
1885        assert_eq!(result.failed_count(), 1, "Should track 1 failed package");
1886        // #267: a timeout is also a fetch failure, not a "not found" — must
1887        // be recorded the same way as a hard registry error.
1888        assert_eq!(
1889            result.fetch_failed,
1890            HashMap::from([(PackageName::new("slow-package"), FetchFailure::Transient)]),
1891            "timed-out package must be recorded in fetch_failed"
1892        );
1893    }
1894
1895    #[tokio::test]
1896    async fn test_fetch_latest_versions_parallel_fast_packages_not_blocked() {
1897        use deps_core::{Metadata, Registry, Version};
1898        use std::any::Any;
1899        use std::time::Duration;
1900
1901        struct MixedRegistry;
1902
1903        impl Registry for MixedRegistry {
1904            fn get_versions<'a>(
1905                &'a self,
1906                name: &'a deps_core::PackageName,
1907            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1908            {
1909                Box::pin(async move {
1910                    if name == "slow-package" {
1911                        tokio::time::sleep(Duration::from_secs(10)).await;
1912                    }
1913                    Ok(vec![])
1914                })
1915            }
1916
1917            fn get_latest_matching<'a>(
1918                &'a self,
1919                name: &'a deps_core::PackageName,
1920                _req: &'a deps_core::VersionReq,
1921                _selection_context: &'a deps_core::SelectionContext,
1922            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1923            {
1924                Box::pin(async move {
1925                    if name == "slow-package" {
1926                        tokio::time::sleep(Duration::from_secs(10)).await;
1927                    }
1928                    Ok(None)
1929                })
1930            }
1931
1932            fn search_raw<'a>(
1933                &'a self,
1934                _query: &'a str,
1935                _limit: usize,
1936            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1937            {
1938                Box::pin(async move { Ok(vec![]) })
1939            }
1940
1941            fn as_any(&self) -> &dyn Any {
1942                self
1943            }
1944        }
1945
1946        let registry: Arc<dyn Registry> = Arc::new(MixedRegistry);
1947        let packages = vec![
1948            PackageName::new("slow-package"),
1949            PackageName::new("fast-package"),
1950        ];
1951
1952        let start = std::time::Instant::now();
1953        let result = fetch_latest_versions_parallel(
1954            registry,
1955            with_registry_source(packages),
1956            &HashMap::new(),
1957            None,
1958            deps_core::freshness::FreshnessSettings::default(),
1959            1,
1960            10,
1961            &SelectionContext::none(),
1962            None,
1963        )
1964        .await;
1965        let elapsed = start.elapsed();
1966
1967        assert!(
1968            elapsed < Duration::from_secs(3),
1969            "Should not wait for slow package: {:?}",
1970            elapsed
1971        );
1972
1973        assert!(
1974            result.versions.is_empty(),
1975            "No versions returned (test registry returns empty)"
1976        );
1977        assert_eq!(
1978            result.failed_count(),
1979            1,
1980            "Slow package should be marked as failed"
1981        );
1982    }
1983
1984    #[tokio::test]
1985    async fn test_fetch_latest_versions_parallel_concurrency_limit() {
1986        use deps_core::{Metadata, Registry, Version};
1987        use std::any::Any;
1988        use std::sync::atomic::{AtomicUsize, Ordering};
1989        use std::time::Duration;
1990
1991        struct ConcurrencyTrackingRegistry {
1992            current: Arc<AtomicUsize>,
1993            max_seen: Arc<AtomicUsize>,
1994        }
1995
1996        impl Registry for ConcurrencyTrackingRegistry {
1997            fn get_versions<'a>(
1998                &'a self,
1999                _name: &'a deps_core::PackageName,
2000            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2001            {
2002                Box::pin(async move {
2003                    let current = self.current.fetch_add(1, Ordering::SeqCst) + 1;
2004                    self.max_seen.fetch_max(current, Ordering::SeqCst);
2005                    tokio::time::sleep(Duration::from_millis(50)).await;
2006                    self.current.fetch_sub(1, Ordering::SeqCst);
2007
2008                    Ok(vec![])
2009                })
2010            }
2011
2012            fn get_latest_matching<'a>(
2013                &'a self,
2014                _name: &'a deps_core::PackageName,
2015                _req: &'a deps_core::VersionReq,
2016                _selection_context: &'a deps_core::SelectionContext,
2017            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2018            {
2019                Box::pin(async move {
2020                    let current = self.current.fetch_add(1, Ordering::SeqCst) + 1;
2021                    self.max_seen.fetch_max(current, Ordering::SeqCst);
2022                    tokio::time::sleep(Duration::from_millis(50)).await;
2023                    self.current.fetch_sub(1, Ordering::SeqCst);
2024
2025                    Ok(None)
2026                })
2027            }
2028
2029            fn search_raw<'a>(
2030                &'a self,
2031                _query: &'a str,
2032                _limit: usize,
2033            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2034            {
2035                Box::pin(async move { Ok(vec![]) })
2036            }
2037
2038            fn as_any(&self) -> &dyn Any {
2039                self
2040            }
2041        }
2042
2043        let current = Arc::new(AtomicUsize::new(0));
2044        let max_seen = Arc::new(AtomicUsize::new(0));
2045
2046        let registry: Arc<dyn Registry> = Arc::new(ConcurrencyTrackingRegistry {
2047            current: Arc::clone(&current),
2048            max_seen: Arc::clone(&max_seen),
2049        });
2050
2051        let packages: Vec<PackageName> = (0..50)
2052            .map(|i| PackageName::new(format!("package-{}", i)))
2053            .collect();
2054
2055        fetch_latest_versions_parallel(
2056            registry,
2057            with_registry_source(packages),
2058            &HashMap::new(),
2059            None,
2060            deps_core::freshness::FreshnessSettings::default(),
2061            5,
2062            20,
2063            &SelectionContext::none(),
2064            None,
2065        )
2066        .await;
2067
2068        // +2 margin for timing noise around the limit of 20.
2069        let max = max_seen.load(Ordering::SeqCst);
2070        assert!(
2071            max <= 22,
2072            "Concurrency limit violated: {} concurrent requests (limit: 20)",
2073            max
2074        );
2075    }
2076
2077    /// Regression test for issue #833: `buffer_unordered(0)` never polls its source
2078    /// stream and returns `Pending` forever, so a `max_concurrent` of `0` reaching this
2079    /// call previously hung the fetch indefinitely instead of completing or erroring.
2080    /// Wrapped in a short outer timeout so a regression fails fast instead of hanging
2081    /// the test suite.
2082    #[tokio::test]
2083    async fn test_fetch_latest_versions_parallel_zero_max_concurrent_still_completes() {
2084        use deps_core::Registry;
2085        let registry: Arc<dyn Registry> = Arc::new(deps_core::test_util::MockRegistry::new());
2086        let packages = vec![PackageName::new("some-package")];
2087
2088        let result = tokio::time::timeout(
2089            std::time::Duration::from_secs(5),
2090            fetch_latest_versions_parallel(
2091                registry,
2092                with_registry_source(packages),
2093                &HashMap::new(),
2094                None,
2095                deps_core::freshness::FreshnessSettings::default(),
2096                5,
2097                0,
2098                &SelectionContext::none(),
2099                None,
2100            ),
2101        )
2102        .await
2103        .expect("fetch with max_concurrent=0 must not hang forever");
2104
2105        assert!(
2106            result
2107                .no_comparable_versions
2108                .contains(&PackageName::new("some-package")),
2109            "fetch must still run to completion when max_concurrent is 0"
2110        );
2111    }
2112
2113    #[tokio::test]
2114    async fn test_fetch_partial_success_with_mixed_outcomes() {
2115        use deps_core::test_util::MockVersion;
2116        use deps_core::{Metadata, Registry, Version};
2117        use std::any::Any;
2118        use std::time::Duration;
2119
2120        struct MixedOutcomeRegistry;
2121
2122        impl Registry for MixedOutcomeRegistry {
2123            fn get_versions<'a>(
2124                &'a self,
2125                name: &'a deps_core::PackageName,
2126            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2127            {
2128                Box::pin(async move {
2129                    match name.as_str() {
2130                        "package-fast" => {
2131                            Ok(vec![
2132                                Box::new(MockVersion::new("1.0.0").with_prerelease(false))
2133                                    as Box<dyn Version>,
2134                            ])
2135                        }
2136                        "package-slow" => {
2137                            tokio::time::sleep(Duration::from_secs(10)).await;
2138                            Ok(vec![])
2139                        }
2140                        "package-error" => Err(deps_core::error::DepsError::CacheError(
2141                            "Mock registry error".to_string(),
2142                        )),
2143                        _ => Ok(vec![]),
2144                    }
2145                })
2146            }
2147
2148            fn get_latest_matching<'a>(
2149                &'a self,
2150                name: &'a deps_core::PackageName,
2151                _req: &'a deps_core::VersionReq,
2152                _selection_context: &'a deps_core::SelectionContext,
2153            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2154            {
2155                Box::pin(async move {
2156                    match name.as_str() {
2157                        "package-fast" => Ok(Some(Box::new(
2158                            MockVersion::new("1.0.0").with_prerelease(false),
2159                        ) as Box<dyn Version>)),
2160                        "package-slow" => {
2161                            tokio::time::sleep(Duration::from_secs(10)).await;
2162                            Ok(None)
2163                        }
2164                        "package-error" => Err(deps_core::error::DepsError::CacheError(
2165                            "Mock registry error".to_string(),
2166                        )),
2167                        _ => Ok(None),
2168                    }
2169                })
2170            }
2171
2172            fn search_raw<'a>(
2173                &'a self,
2174                _query: &'a str,
2175                _limit: usize,
2176            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2177            {
2178                Box::pin(async move { Ok(vec![]) })
2179            }
2180
2181            fn select_latest_matching(
2182                &self,
2183                versions: &[Box<dyn Version>],
2184                _req: &deps_core::VersionReq,
2185                _selection_context: &deps_core::SelectionContext,
2186            ) -> Option<usize> {
2187                // The fetch loop derives "latest" from `get_versions` via this method, not
2188                // `get_latest_matching` — must override it (not rely on the `None` default)
2189                // to keep exercising "package-fast" as a successful fetch.
2190                if versions.is_empty() { None } else { Some(0) }
2191            }
2192
2193            fn as_any(&self) -> &dyn Any {
2194                self
2195            }
2196        }
2197
2198        let registry: Arc<dyn Registry> = Arc::new(MixedOutcomeRegistry);
2199        let packages = vec![
2200            PackageName::new("package-fast"),
2201            PackageName::new("package-slow"),
2202            PackageName::new("package-error"),
2203        ];
2204
2205        let result = fetch_latest_versions_parallel(
2206            registry,
2207            with_registry_source(packages),
2208            &HashMap::new(),
2209            None,
2210            deps_core::freshness::FreshnessSettings::default(),
2211            1,
2212            10,
2213            &SelectionContext::none(),
2214            None,
2215        )
2216        .await;
2217
2218        assert_eq!(
2219            result.versions.len(),
2220            1,
2221            "Should have exactly 1 successful package"
2222        );
2223        assert_eq!(
2224            result
2225                .versions
2226                .get("package-fast")
2227                .map(|v| v.latest.as_str()),
2228            Some("1.0.0"),
2229            "Fast package should have correct version"
2230        );
2231        assert!(
2232            !result.versions.contains_key("package-slow"),
2233            "Slow package should not be in results (timeout)"
2234        );
2235        assert!(
2236            !result.versions.contains_key("package-error"),
2237            "Error package should not be in results"
2238        );
2239    }
2240
2241    /// Issue #247: the per-version yanked flag from `get_versions` must survive into
2242    /// `PackageVersions.yanked`, not be discarded — this is what lets
2243    /// `generate_diagnostics_from_cache` (via `requirement_matches_only_yanked`) detect a
2244    /// requirement that is satisfiable only by a yanked version.
2245    #[tokio::test]
2246    async fn test_fetch_latest_versions_parallel_carries_yanked_flag_into_cache() {
2247        use deps_core::Registry;
2248        use deps_core::test_util::MockVersion;
2249
2250        let registry: Arc<dyn Registry> = Arc::new(
2251            deps_core::test_util::MockRegistry::new().with_versions(vec![
2252                MockVersion::new("1.0.214"),
2253                MockVersion::new("1.0.213").yanked(true),
2254            ]),
2255        );
2256        let packages = vec![PackageName::new("serde")];
2257
2258        let result = fetch_latest_versions_parallel(
2259            registry,
2260            with_registry_source(packages),
2261            &HashMap::new(),
2262            None,
2263            deps_core::freshness::FreshnessSettings::default(),
2264            10,
2265            10,
2266            &SelectionContext::none(),
2267            None,
2268        )
2269        .await;
2270
2271        let serde = result
2272            .versions
2273            .get("serde")
2274            .expect("serde should be fetched");
2275        assert_eq!(serde.latest, "1.0.214", "latest must skip the yanked entry");
2276        assert_eq!(
2277            &*serde.available,
2278            &[
2279                ConcreteVersion::new("1.0.214"),
2280                ConcreteVersion::new("1.0.213")
2281            ],
2282            "available must remain unfiltered"
2283        );
2284        assert_eq!(
2285            &*serde.yanked,
2286            &[(
2287                ConcreteVersion::new("1.0.213"),
2288                deps_core::RemovalStatus::Yanked
2289            )],
2290            "yanked must carry only the entries reported as yanked, paired with their status"
2291        );
2292    }
2293
2294    /// Issue #227 C3: `PackageVersions.published_at` must be the publish time of
2295    /// `latest` specifically, not of some other entry in `available` — a risk the old
2296    /// two-parallel-map design (a separate `HashMap<String, PublishTime>` alongside the
2297    /// version map) could not structurally rule out. Bundling `published_at` onto the
2298    /// same struct as `latest`/`available`/`yanked` makes that desync impossible: both
2299    /// are set from the same `Box<dyn Version>` in the same match arm.
2300    #[tokio::test]
2301    async fn test_fetch_latest_versions_parallel_carries_published_at_for_latest_only() {
2302        use deps_core::Registry;
2303        use deps_core::freshness::PublishTime;
2304        use deps_core::test_util::MockVersion;
2305
2306        let registry: Arc<dyn Registry> = Arc::new(
2307            deps_core::test_util::MockRegistry::new().with_versions(vec![
2308                MockVersion::new("1.0.214").with_published_at(PublishTime::from_unix_secs(2_000)),
2309                // Deliberately a different timestamp — proves the fetch loop never
2310                // accidentally attaches this entry's age to `latest`.
2311                MockVersion::new("1.0.213")
2312                    .yanked(true)
2313                    .with_published_at(PublishTime::from_unix_secs(1_000)),
2314            ]),
2315        );
2316        let packages = vec![PackageName::new("serde")];
2317
2318        let result = fetch_latest_versions_parallel(
2319            registry,
2320            with_registry_source(packages),
2321            &HashMap::new(),
2322            None,
2323            deps_core::freshness::FreshnessSettings::default(),
2324            10,
2325            10,
2326            &SelectionContext::none(),
2327            None,
2328        )
2329        .await;
2330
2331        let serde = result
2332            .versions
2333            .get("serde")
2334            .expect("serde should be fetched");
2335        assert_eq!(serde.latest, "1.0.214");
2336        assert_eq!(
2337            serde.published_at,
2338            Some(PublishTime::from_unix_secs(2_000)),
2339            "published_at must be 1.0.214's own timestamp, not the yanked 1.0.213 entry's"
2340        );
2341    }
2342
2343    /// Issue #660/#661 tier-1 backfill: a `Version::license()` override on the
2344    /// already-fetched version-list entry (today, only Composer's `impl_version!`
2345    /// includes one — `deps-composer/src/types.rs`) must flow into
2346    /// `FetchResult::licenses`, keyed by package name — this is what
2347    /// `merge_registry_fetch_result` then merges into `DocumentState::signals.licenses`,
2348    /// letting #661's policy diagnostics see it without a second, ecosystem-specific
2349    /// fetch. An empty `license()` (every other ecosystem's default) must produce no
2350    /// entry at all, not an empty-vec one — `merge_licenses` relies on this to never
2351    /// accidentally overwrite real data with a spurious empty entry.
2352    #[tokio::test]
2353    async fn test_fetch_latest_versions_parallel_carries_license_into_fetch_result() {
2354        use deps_core::test_util::MockVersion;
2355        use deps_core::{Metadata, Registry, Version};
2356        use std::any::Any;
2357
2358        struct LicensedRegistry;
2359
2360        impl Registry for LicensedRegistry {
2361            fn get_versions<'a>(
2362                &'a self,
2363                name: &'a PackageName,
2364            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2365            {
2366                let license = if name.as_str() == "licensed-pkg" {
2367                    vec!["MIT".to_string()]
2368                } else {
2369                    vec![]
2370                };
2371                Box::pin(async move {
2372                    Ok(vec![
2373                        Box::new(MockVersion::new("1.0.0").with_license(license))
2374                            as Box<dyn Version>,
2375                    ])
2376                })
2377            }
2378
2379            fn get_latest_matching<'a>(
2380                &'a self,
2381                _name: &'a PackageName,
2382                _req: &'a deps_core::VersionReq,
2383                _selection_context: &'a deps_core::SelectionContext,
2384            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2385            {
2386                Box::pin(async move { Ok(None) })
2387            }
2388
2389            fn search_raw<'a>(
2390                &'a self,
2391                _query: &'a str,
2392                _limit: usize,
2393            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2394            {
2395                Box::pin(async move { Ok(vec![]) })
2396            }
2397
2398            fn select_latest_matching(
2399                &self,
2400                versions: &[Box<dyn Version>],
2401                _req: &deps_core::VersionReq,
2402                _selection_context: &deps_core::SelectionContext,
2403            ) -> Option<usize> {
2404                (!versions.is_empty()).then_some(0)
2405            }
2406
2407            fn as_any(&self) -> &dyn Any {
2408                self
2409            }
2410        }
2411
2412        let registry: Arc<dyn Registry> = Arc::new(LicensedRegistry);
2413        let packages = vec![
2414            PackageName::new("licensed-pkg"),
2415            PackageName::new("unlicensed-pkg"),
2416        ];
2417
2418        let result = fetch_latest_versions_parallel(
2419            registry,
2420            with_registry_source(packages),
2421            &HashMap::new(),
2422            None,
2423            deps_core::freshness::FreshnessSettings::default(),
2424            10,
2425            10,
2426            &SelectionContext::none(),
2427            None,
2428        )
2429        .await;
2430
2431        assert_eq!(
2432            result.licenses.get(&PackageName::new("licensed-pkg")),
2433            Some(&vec!["MIT".to_string()])
2434        );
2435        assert!(
2436            !result
2437                .licenses
2438                .contains_key(&PackageName::new("unlicensed-pkg")),
2439            "an empty Version::license() must produce no entry, not an empty-vec one"
2440        );
2441    }
2442
2443    /// #339 regression guard: the bulk diagnostics-cache-population pass must call the
2444    /// freshness-aware `Registry::get_versions_with`, not the freshness-blind `get_versions`,
2445    /// for a registry that implements the override — otherwise `published_at` (and the
2446    /// cooldown-context diagnostic message it drives) is silently always `None` in
2447    /// production even though hover's separate call path gets it right.
2448    #[tokio::test]
2449    async fn test_fetch_latest_versions_parallel_uses_get_versions_with_for_freshness() {
2450        use deps_core::freshness::{FreshnessSettings, PublishTime};
2451        use deps_core::test_util::MockVersion;
2452        use deps_core::{Metadata, Registry, Version};
2453        use std::any::Any;
2454
2455        struct FreshnessAwareRegistry;
2456
2457        impl Registry for FreshnessAwareRegistry {
2458            fn get_versions<'a>(
2459                &'a self,
2460                _name: &'a deps_core::PackageName,
2461            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2462            {
2463                // Deliberately returns no `published_at` — if the fetch loop ever calls
2464                // this instead of `get_versions_with`, the assertion below catches it.
2465                Box::pin(async move {
2466                    Ok(vec![Box::new(MockVersion::new("1.0.0")) as Box<dyn Version>])
2467                })
2468            }
2469
2470            fn get_versions_with<'a>(
2471                &'a self,
2472                _name: &'a deps_core::PackageName,
2473                freshness: FreshnessSettings,
2474            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2475            {
2476                let version = MockVersion::new("1.0.0");
2477                let version = match freshness
2478                    .is_enabled()
2479                    .then(|| PublishTime::from_unix_secs(5_000))
2480                {
2481                    Some(published_at) => version.with_published_at(published_at),
2482                    None => version,
2483                };
2484                Box::pin(async move { Ok(vec![Box::new(version) as Box<dyn Version>]) })
2485            }
2486
2487            fn get_latest_matching<'a>(
2488                &'a self,
2489                _name: &'a deps_core::PackageName,
2490                _req: &'a deps_core::VersionReq,
2491                _selection_context: &'a deps_core::SelectionContext,
2492            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2493            {
2494                Box::pin(async move { Ok(None) })
2495            }
2496
2497            fn search_raw<'a>(
2498                &'a self,
2499                _query: &'a str,
2500                _limit: usize,
2501            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2502            {
2503                Box::pin(async move { Ok(vec![]) })
2504            }
2505
2506            fn select_latest_matching(
2507                &self,
2508                versions: &[Box<dyn Version>],
2509                _req: &deps_core::VersionReq,
2510                _selection_context: &deps_core::SelectionContext,
2511            ) -> Option<usize> {
2512                if versions.is_empty() { None } else { Some(0) }
2513            }
2514
2515            fn as_any(&self) -> &dyn Any {
2516                self
2517            }
2518        }
2519
2520        let registry: Arc<dyn Registry> = Arc::new(FreshnessAwareRegistry);
2521        let packages = vec![PackageName::new("widget")];
2522
2523        let result = fetch_latest_versions_parallel(
2524            registry,
2525            with_registry_source(packages),
2526            &HashMap::new(),
2527            None,
2528            FreshnessSettings::default(),
2529            10,
2530            10,
2531            &SelectionContext::none(),
2532            None,
2533        )
2534        .await;
2535
2536        let widget = result
2537            .versions
2538            .get("widget")
2539            .expect("widget should be fetched");
2540        assert_eq!(
2541            widget.published_at,
2542            Some(PublishTime::from_unix_secs(5_000)),
2543            "published_at must come from get_versions_with, not the freshness-blind \
2544             get_versions (#339)"
2545        );
2546    }
2547
2548    /// #424 S1: `fetch_latest_versions_parallel` must call `select_latest_matching` with the
2549    /// `minimum_stability` value it was given — otherwise a registry with manifest-level
2550    /// stability state (e.g. Composer's `minimum-stability`) never actually sees it, and
2551    /// #424's S1 fix stays unreachable dead code from the live LSP fetch path's perspective
2552    /// (critic S3/tester's reachability gap).
2553    #[tokio::test]
2554    async fn test_fetch_latest_versions_parallel_threads_minimum_stability_into_select_latest_matching()
2555     {
2556        use deps_core::test_util::MockVersion;
2557        use deps_core::{Metadata, Registry, StabilityFloor, Version};
2558        use std::any::Any;
2559        use std::sync::Mutex;
2560
2561        struct ContextAwareRegistry {
2562            // Records every `minimum_stability` value observed, in call order — an empty
2563            // `Vec` after the fetch means `select_latest_matching` was never invoked.
2564            seen_minimum_stability: Mutex<Vec<Option<StabilityFloor>>>,
2565        }
2566
2567        impl Registry for ContextAwareRegistry {
2568            fn get_versions<'a>(
2569                &'a self,
2570                _name: &'a deps_core::PackageName,
2571            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2572            {
2573                Box::pin(async move {
2574                    Ok(vec![Box::new(MockVersion::new("1.0.0")) as Box<dyn Version>])
2575                })
2576            }
2577
2578            fn get_latest_matching<'a>(
2579                &'a self,
2580                _name: &'a deps_core::PackageName,
2581                _req: &'a deps_core::VersionReq,
2582                _selection_context: &'a deps_core::SelectionContext,
2583            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2584            {
2585                Box::pin(async move { Ok(None) })
2586            }
2587
2588            fn search_raw<'a>(
2589                &'a self,
2590                _query: &'a str,
2591                _limit: usize,
2592            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2593            {
2594                Box::pin(async move { Ok(vec![]) })
2595            }
2596
2597            fn select_latest_matching(
2598                &self,
2599                versions: &[Box<dyn Version>],
2600                _req: &deps_core::VersionReq,
2601                selection_context: &SelectionContext,
2602            ) -> Option<usize> {
2603                self.seen_minimum_stability
2604                    .lock()
2605                    .unwrap_or_else(|p| p.into_inner())
2606                    .push(selection_context.minimum_stability());
2607                if versions.is_empty() { None } else { Some(0) }
2608            }
2609
2610            fn as_any(&self) -> &dyn Any {
2611                self
2612            }
2613        }
2614
2615        let registry = Arc::new(ContextAwareRegistry {
2616            seen_minimum_stability: Mutex::new(Vec::new()),
2617        });
2618        let packages = vec![PackageName::new("vendor/pkg")];
2619
2620        let result = fetch_latest_versions_parallel(
2621            Arc::clone(&registry) as Arc<dyn Registry>,
2622            with_registry_source(packages),
2623            &HashMap::new(),
2624            None,
2625            deps_core::freshness::FreshnessSettings::default(),
2626            10,
2627            10,
2628            &SelectionContext::with_minimum_stability(StabilityFloor::Beta),
2629            None,
2630        )
2631        .await;
2632
2633        assert_eq!(
2634            *registry
2635                .seen_minimum_stability
2636                .lock()
2637                .unwrap_or_else(|p| p.into_inner()),
2638            vec![Some(StabilityFloor::Beta)],
2639            "select_latest_matching must receive the caller's minimum_stability"
2640        );
2641        assert!(
2642            result.versions.contains_key("vendor/pkg"),
2643            "the pick must still succeed"
2644        );
2645    }
2646
2647    /// #424 S1: the `get_latest_matching` fallback path (used when the pure list-based pick
2648    /// finds nothing) must also thread `minimum_stability` through.
2649    #[tokio::test]
2650    async fn test_fetch_latest_versions_parallel_threads_minimum_stability_into_get_latest_matching_fallback()
2651     {
2652        use deps_core::test_util::MockVersion;
2653        use deps_core::{Metadata, Registry, StabilityFloor, Version};
2654        use std::any::Any;
2655        use std::sync::Mutex;
2656
2657        struct FallbackContextAwareRegistry {
2658            // Records every `minimum_stability` value observed, in call order — an empty
2659            // `Vec` after the fetch means the fallback method was never invoked.
2660            seen_minimum_stability: Mutex<Vec<Option<StabilityFloor>>>,
2661        }
2662
2663        impl Registry for FallbackContextAwareRegistry {
2664            fn get_versions<'a>(
2665                &'a self,
2666                _name: &'a deps_core::PackageName,
2667            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2668            {
2669                // Empty list forces the fetch loop's `get_latest_matching` fallback (the pure
2670                // list-based pick over an empty list finds nothing).
2671                Box::pin(async move { Ok(vec![]) })
2672            }
2673
2674            fn get_latest_matching<'a>(
2675                &'a self,
2676                _name: &'a deps_core::PackageName,
2677                _req: &'a deps_core::VersionReq,
2678                selection_context: &'a SelectionContext,
2679            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2680            {
2681                self.seen_minimum_stability
2682                    .lock()
2683                    .unwrap_or_else(|p| p.into_inner())
2684                    .push(selection_context.minimum_stability());
2685                Box::pin(async move {
2686                    Ok(Some(
2687                        Box::new(MockVersion::new("2.0.0-beta1")) as Box<dyn Version>
2688                    ))
2689                })
2690            }
2691
2692            fn search_raw<'a>(
2693                &'a self,
2694                _query: &'a str,
2695                _limit: usize,
2696            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2697            {
2698                Box::pin(async move { Ok(vec![]) })
2699            }
2700
2701            fn as_any(&self) -> &dyn Any {
2702                self
2703            }
2704        }
2705
2706        let registry = Arc::new(FallbackContextAwareRegistry {
2707            seen_minimum_stability: Mutex::new(Vec::new()),
2708        });
2709        let packages = vec![PackageName::new("vendor/pkg")];
2710
2711        let result = fetch_latest_versions_parallel(
2712            Arc::clone(&registry) as Arc<dyn Registry>,
2713            with_registry_source(packages),
2714            &HashMap::new(),
2715            None,
2716            deps_core::freshness::FreshnessSettings::default(),
2717            10,
2718            10,
2719            &SelectionContext::with_minimum_stability(StabilityFloor::Beta),
2720            None,
2721        )
2722        .await;
2723
2724        assert_eq!(
2725            *registry
2726                .seen_minimum_stability
2727                .lock()
2728                .unwrap_or_else(|p| p.into_inner()),
2729            vec![Some(StabilityFloor::Beta)],
2730            "get_latest_matching must receive the caller's minimum_stability"
2731        );
2732        let widget = result
2733            .versions
2734            .get("vendor/pkg")
2735            .expect("fallback pick should succeed");
2736        assert_eq!(widget.latest, "2.0.0-beta1");
2737    }
2738
2739    /// S3 regression: a registry whose `get_versions` list is incomplete (e.g. Go's
2740    /// `/@v/list`, which never enumerates pseudo-versions and can be entirely empty for an
2741    /// untagged module) must not render the package as "no version found" just because
2742    /// `select_latest_matching`'s pure list-based pick came up empty — the fetch loop must
2743    /// fall back to the registry's own `get_latest_matching`.
2744    #[tokio::test]
2745    async fn test_fetch_falls_back_to_get_latest_matching_when_list_based_pick_finds_nothing() {
2746        use deps_core::test_util::MockVersion;
2747        use deps_core::{Metadata, Registry, Version};
2748        use std::any::Any;
2749
2750        /// Mimics an untagged Go module: `get_versions` (the list endpoint) is empty, but
2751        /// `get_latest_matching` (a different, more complete endpoint) still resolves a
2752        /// pseudo-version. `select_latest_matching` deliberately relies on the trait
2753        /// default (`None`), matching a real registry whose list-based pick has nothing to
2754        /// work with.
2755        struct UntaggedModuleRegistry;
2756
2757        impl Registry for UntaggedModuleRegistry {
2758            fn get_versions<'a>(
2759                &'a self,
2760                _name: &'a deps_core::PackageName,
2761            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2762            {
2763                Box::pin(async move { Ok(vec![]) })
2764            }
2765
2766            fn get_latest_matching<'a>(
2767                &'a self,
2768                _name: &'a deps_core::PackageName,
2769                _req: &'a deps_core::VersionReq,
2770                _selection_context: &'a deps_core::SelectionContext,
2771            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2772            {
2773                Box::pin(async move {
2774                    Ok(Some(
2775                        Box::new(MockVersion::new("v0.0.0-20191109021931-daa7c04131f5"))
2776                            as Box<dyn Version>,
2777                    ))
2778                })
2779            }
2780
2781            fn search_raw<'a>(
2782                &'a self,
2783                _query: &'a str,
2784                _limit: usize,
2785            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2786            {
2787                Box::pin(async move { Ok(vec![]) })
2788            }
2789
2790            fn as_any(&self) -> &dyn Any {
2791                self
2792            }
2793        }
2794
2795        let registry: Arc<dyn Registry> = Arc::new(UntaggedModuleRegistry);
2796        let packages = vec![PackageName::new("golang.org/x/exp")];
2797
2798        let result = fetch_latest_versions_parallel(
2799            registry,
2800            with_registry_source(packages),
2801            &HashMap::new(),
2802            None,
2803            deps_core::freshness::FreshnessSettings::default(),
2804            5,
2805            10,
2806            &SelectionContext::none(),
2807            None,
2808        )
2809        .await;
2810
2811        assert_eq!(
2812            result
2813                .versions
2814                .get("golang.org/x/exp")
2815                .map(|v| v.latest.as_str()),
2816            Some("v0.0.0-20191109021931-daa7c04131f5"),
2817            "must fall back to get_latest_matching instead of reporting no version found"
2818        );
2819    }
2820
2821    #[tokio::test]
2822    async fn test_fetch_registry_error_handled() {
2823        use deps_core::{Metadata, Registry, Version};
2824        use std::any::Any;
2825
2826        struct ErrorRegistry;
2827
2828        impl Registry for ErrorRegistry {
2829            fn get_versions<'a>(
2830                &'a self,
2831                name: &'a deps_core::PackageName,
2832            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2833            {
2834                Box::pin(async move {
2835                    Err(deps_core::error::DepsError::CacheError(format!(
2836                        "Failed to fetch package: {}",
2837                        name.as_str()
2838                    )))
2839                })
2840            }
2841
2842            fn get_latest_matching<'a>(
2843                &'a self,
2844                name: &'a deps_core::PackageName,
2845                _req: &'a deps_core::VersionReq,
2846                _selection_context: &'a deps_core::SelectionContext,
2847            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2848            {
2849                Box::pin(async move {
2850                    Err(deps_core::error::DepsError::CacheError(format!(
2851                        "Failed to fetch package: {}",
2852                        name.as_str()
2853                    )))
2854                })
2855            }
2856
2857            fn search_raw<'a>(
2858                &'a self,
2859                _query: &'a str,
2860                _limit: usize,
2861            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2862            {
2863                Box::pin(async move { Ok(vec![]) })
2864            }
2865
2866            fn as_any(&self) -> &dyn Any {
2867                self
2868            }
2869        }
2870
2871        let registry: Arc<dyn Registry> = Arc::new(ErrorRegistry);
2872        let packages = vec![
2873            PackageName::new("package-1"),
2874            PackageName::new("package-2"),
2875            PackageName::new("package-3"),
2876        ];
2877
2878        let result = fetch_latest_versions_parallel(
2879            registry,
2880            with_registry_source(packages),
2881            &HashMap::new(),
2882            None,
2883            deps_core::freshness::FreshnessSettings::default(),
2884            5,
2885            10,
2886            &SelectionContext::none(),
2887            None,
2888        )
2889        .await;
2890
2891        assert!(
2892            result.versions.is_empty(),
2893            "All packages with errors should be omitted from results"
2894        );
2895        assert_eq!(
2896            result.failed_count(),
2897            3,
2898            "All 3 packages should be marked as failed"
2899        );
2900        // #267: a fetch error must be recorded per-package, not just counted,
2901        // so diagnostic generation can tell "fetch failed" apart from
2902        // "genuinely not found" instead of reporting "Unknown package".
2903        assert_eq!(
2904            result.fetch_failed,
2905            HashMap::from([
2906                (PackageName::new("package-1"), FetchFailure::Transient),
2907                (PackageName::new("package-2"), FetchFailure::Transient),
2908                (PackageName::new("package-3"), FetchFailure::Transient),
2909            ]),
2910            "every errored package must be recorded in fetch_failed"
2911        );
2912    }
2913
2914    /// #1209: the `"fetch failed"` WARN's `package` field used to interpolate the raw,
2915    /// manifest-derived name directly (`package = %name`) — a credential embedded in a
2916    /// name-shaped manifest field (e.g. via property interpolation) reached this log
2917    /// verbatim. Now redacted via [`deps_core::PackageName::for_tracing`]. Asserts against
2918    /// the fully rendered line (not just the event message), mirroring
2919    /// `deps-maven::registry::tests::test_fetch_publish_times_failure_log_redacts_url_query_string`'s
2920    /// precedent: a leak reintroduced only in an enclosing span/field would otherwise pass a
2921    /// message-only assertion.
2922    #[tokio::test]
2923    async fn test_fetch_failed_log_redacts_credential_shaped_package_name() {
2924        use deps_core::{Metadata, Registry, Version};
2925        use std::any::Any;
2926
2927        // M1 (impl-critic on the original #1209 fix): a real ecosystem registry's
2928        // `get_versions` runs inside a `#[tracing::instrument(fields(package = ...))]` span
2929        // (e.g. `deps-maven`'s `get_metadata`) — the exact shape the original audit's most
2930        // defensible finding hinged on (`warn_rejected_value`'s len-only design defeated by
2931        // its own enclosing span). Without an instrumented mock here, this test would still
2932        // pass if a *span*-level redaction fix were reverted, since only `fetch.rs`'s own
2933        // event field would be exercised. `inner_fetch` mirrors the production idiom exactly
2934        // (`fields(package = %name.for_tracing())`) so a regression to `?name`/`%name` here
2935        // would fail this test's assertions.
2936        #[tracing::instrument(skip_all, fields(package = %name.for_tracing()), level = "debug")]
2937        async fn inner_fetch(name: &PackageName) -> deps_core::Result<Vec<Box<dyn Version>>> {
2938            // An event fired *from inside* the span (not just the span's own fields) is what
2939            // makes `tracing_subscriber`'s default formatter render the span context
2940            // (`inner_fetch{package=...}: ...`) into the captured line at all — a span with no
2941            // event inside it produces no output on its own.
2942            tracing::debug!("mock registry fetch invoked");
2943            Err(deps_core::error::DepsError::CacheError(
2944                "transient backend failure".to_string(),
2945            ))
2946        }
2947
2948        struct AlwaysFailsRegistry;
2949
2950        impl Registry for AlwaysFailsRegistry {
2951            fn get_versions<'a>(
2952                &'a self,
2953                name: &'a deps_core::PackageName,
2954            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2955            {
2956                Box::pin(inner_fetch(name))
2957            }
2958
2959            fn get_latest_matching<'a>(
2960                &'a self,
2961                _name: &'a deps_core::PackageName,
2962                _req: &'a deps_core::VersionReq,
2963                _selection_context: &'a deps_core::SelectionContext,
2964            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2965            {
2966                Box::pin(async move {
2967                    Err(deps_core::error::DepsError::CacheError(
2968                        "transient backend failure".to_string(),
2969                    ))
2970                })
2971            }
2972
2973            fn search_raw<'a>(
2974                &'a self,
2975                _query: &'a str,
2976                _limit: usize,
2977            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2978            {
2979                Box::pin(async move { Ok(vec![]) })
2980            }
2981
2982            fn as_any(&self) -> &dyn Any {
2983                self
2984            }
2985        }
2986
2987        let sentinel_name =
2988            PackageName::new("com.example:deploy:AUDITSENTINEL0000@git.internal.corp");
2989        let registry: Arc<dyn Registry> = Arc::new(AlwaysFailsRegistry);
2990        let packages = vec![sentinel_name.clone()];
2991
2992        let log =
2993            deps_core::test_util::capture_tracing_output_async_at(tracing::Level::DEBUG, async {
2994                let result = fetch_latest_versions_parallel(
2995                    registry,
2996                    with_registry_source(packages),
2997                    &HashMap::new(),
2998                    None,
2999                    deps_core::freshness::FreshnessSettings::default(),
3000                    5,
3001                    10,
3002                    &SelectionContext::none(),
3003                    None,
3004                )
3005                .await;
3006                assert_eq!(result.failed_count(), 1);
3007            })
3008            .await;
3009
3010        assert!(
3011            log.contains("fetch failed"),
3012            "expected the fetch-failed WARN to fire: {log:?}"
3013        );
3014        assert!(
3015            log.contains("mock registry fetch invoked"),
3016            "expected the in-span event to fire — without it the span's fields never render, \
3017             silently downgrading this test back to event-field-only coverage: {log:?}"
3018        );
3019        assert!(
3020            !log.contains("AUDITSENTINEL0000"),
3021            "tracing output leaked a credential-shaped package name: {log:?}"
3022        );
3023        assert!(
3024            log.contains("git.internal.corp"),
3025            "host should survive redaction: {log:?}"
3026        );
3027    }
3028
3029    #[tokio::test]
3030    async fn test_fetch_not_found_is_not_recorded_as_fetch_failed() {
3031        // #267 C1: a genuine not-found (`DepsError::PackageNotFound`, the
3032        // variant npm/PyPI/Go/Swift map a 404 to) means the registry was
3033        // successfully asked and answered "no such package" — recording it
3034        // in `fetch_failed` would make `generate_diagnostics_from_cache`
3035        // report "Registry lookup failed" instead of "Unknown package" for
3036        // the common typo'd-dependency case, inverting the bug this field
3037        // exists to fix.
3038        use deps_core::{Metadata, Registry, Version};
3039        use std::any::Any;
3040
3041        struct NotFoundRegistry;
3042
3043        impl Registry for NotFoundRegistry {
3044            fn get_versions<'a>(
3045                &'a self,
3046                name: &'a deps_core::PackageName,
3047            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3048            {
3049                Box::pin(async move {
3050                    Err(deps_core::error::DepsError::PackageNotFound {
3051                        package: name.as_str().into(),
3052                        registry: "mock",
3053                    })
3054                })
3055            }
3056
3057            fn get_latest_matching<'a>(
3058                &'a self,
3059                name: &'a deps_core::PackageName,
3060                _req: &'a deps_core::VersionReq,
3061                _selection_context: &'a deps_core::SelectionContext,
3062            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3063            {
3064                Box::pin(async move {
3065                    Err(deps_core::error::DepsError::PackageNotFound {
3066                        package: name.as_str().into(),
3067                        registry: "mock",
3068                    })
3069                })
3070            }
3071
3072            fn search_raw<'a>(
3073                &'a self,
3074                _query: &'a str,
3075                _limit: usize,
3076            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3077            {
3078                Box::pin(async move { Ok(vec![]) })
3079            }
3080
3081            fn as_any(&self) -> &dyn Any {
3082                self
3083            }
3084        }
3085
3086        let registry: Arc<dyn Registry> = Arc::new(NotFoundRegistry);
3087        let packages = vec![PackageName::new("typo-pkg")];
3088
3089        let result = fetch_latest_versions_parallel(
3090            registry,
3091            with_registry_source(packages),
3092            &HashMap::new(),
3093            None,
3094            deps_core::freshness::FreshnessSettings::default(),
3095            5,
3096            10,
3097            &SelectionContext::none(),
3098            None,
3099        )
3100        .await;
3101
3102        assert!(result.versions.is_empty());
3103        assert!(
3104            result.fetch_failed.is_empty(),
3105            "a genuine not-found must not be recorded in fetch_failed, or \
3106             generate_diagnostics_from_cache would report it as a registry \
3107             error instead of Unknown package"
3108        );
3109    }
3110
3111    /// Regression for #550: a registry fetch that genuinely succeeds (no error at
3112    /// either the list-based pick or the `get_latest_matching` fallback) but resolves
3113    /// to zero versions must be recorded in `no_comparable_versions`, distinct from
3114    /// both a normal successful fetch (`versions`) and a real failure
3115    /// (`fetch_failed`). Mirrors `GithubActionsRegistry::get_versions("dtolnay/rust-toolchain")`,
3116    /// whose sole tag `v1` doesn't parse as full semver, so `tags_to_versions` filters
3117    /// it out and returns `Ok(vec![])`.
3118    #[tokio::test]
3119    async fn test_fetch_success_with_zero_versions_is_recorded_as_no_comparable_versions() {
3120        use deps_core::Registry;
3121
3122        let registry: Arc<dyn Registry> = Arc::new(deps_core::test_util::MockRegistry::new());
3123        let packages = vec![PackageName::new("dtolnay/rust-toolchain")];
3124
3125        let result = fetch_latest_versions_parallel(
3126            registry,
3127            with_registry_source(packages),
3128            &HashMap::new(),
3129            None,
3130            deps_core::freshness::FreshnessSettings::default(),
3131            5,
3132            10,
3133            &SelectionContext::none(),
3134            None,
3135        )
3136        .await;
3137
3138        assert!(result.versions.is_empty());
3139        assert!(
3140            result.fetch_failed.is_empty(),
3141            "a genuine empty-but-successful fetch must not be recorded as a fetch \
3142             failure, or generate_diagnostics_from_cache would report a registry \
3143             error instead of nothing"
3144        );
3145        assert!(
3146            result
3147                .no_comparable_versions
3148                .contains(&PackageName::new("dtolnay/rust-toolchain")),
3149            "a package whose fetch succeeded with zero comparable versions must be \
3150             recorded in no_comparable_versions, or R5 would misreport it as Unknown \
3151             package; got: {:?}",
3152            result.no_comparable_versions
3153        );
3154    }
3155
3156    #[tokio::test]
3157    async fn test_fetch_http_404_is_not_recorded_as_fetch_failed() {
3158        // Same as `test_fetch_not_found_is_not_recorded_as_fetch_failed`, for
3159        // the ecosystems (Cargo, Maven, Gradle, Bundler, Dart, Composer,
3160        // NuGet) that propagate a raw `DepsError::HttpStatus { status: 404 }`
3161        // instead of mapping it to `PackageNotFound`.
3162        use deps_core::{Metadata, Registry, Version};
3163        use std::any::Any;
3164
3165        struct Http404Registry;
3166
3167        impl Registry for Http404Registry {
3168            fn get_versions<'a>(
3169                &'a self,
3170                name: &'a deps_core::PackageName,
3171            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3172            {
3173                Box::pin(async move {
3174                    Err(deps_core::error::DepsError::HttpStatus {
3175                        url: format!("https://example.com/{}", name.as_str()).into(),
3176                        status: 404,
3177                    })
3178                })
3179            }
3180
3181            fn get_latest_matching<'a>(
3182                &'a self,
3183                name: &'a deps_core::PackageName,
3184                _req: &'a deps_core::VersionReq,
3185                _selection_context: &'a deps_core::SelectionContext,
3186            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3187            {
3188                Box::pin(async move {
3189                    Err(deps_core::error::DepsError::HttpStatus {
3190                        url: format!("https://example.com/{}", name.as_str()).into(),
3191                        status: 404,
3192                    })
3193                })
3194            }
3195
3196            fn search_raw<'a>(
3197                &'a self,
3198                _query: &'a str,
3199                _limit: usize,
3200            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3201            {
3202                Box::pin(async move { Ok(vec![]) })
3203            }
3204
3205            fn as_any(&self) -> &dyn Any {
3206                self
3207            }
3208        }
3209
3210        let registry: Arc<dyn Registry> = Arc::new(Http404Registry);
3211        let packages = vec![PackageName::new("typo-pkg")];
3212
3213        let result = fetch_latest_versions_parallel(
3214            registry,
3215            with_registry_source(packages),
3216            &HashMap::new(),
3217            None,
3218            deps_core::freshness::FreshnessSettings::default(),
3219            5,
3220            10,
3221            &SelectionContext::none(),
3222            None,
3223        )
3224        .await;
3225
3226        assert!(result.versions.is_empty());
3227        assert!(
3228            result.fetch_failed.is_empty(),
3229            "a bare HTTP 404 must not be recorded in fetch_failed either"
3230        );
3231    }
3232
3233    #[tokio::test]
3234    async fn test_fetch_fallback_error_recorded_as_fetch_failed_unless_not_found() {
3235        // Go-shaped path: `get_versions` returns an empty list (nothing for
3236        // `select_latest_matching` to pick), so `fetch_latest_versions_parallel`
3237        // falls back to `get_latest_matching`. Exercises the fallback's own
3238        // error/timeout arms (previously zero test coverage — tester gap),
3239        // and confirms the same not-found-vs-failure gating (#267 C1) applies
3240        // there too, per-package via the `not_found` name.
3241        use deps_core::{Metadata, Registry, Version};
3242        use std::any::Any;
3243
3244        struct FallbackErrorRegistry;
3245
3246        impl Registry for FallbackErrorRegistry {
3247            fn get_versions<'a>(
3248                &'a self,
3249                _name: &'a deps_core::PackageName,
3250            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3251            {
3252                Box::pin(async move { Ok(vec![]) })
3253            }
3254
3255            fn get_latest_matching<'a>(
3256                &'a self,
3257                name: &'a deps_core::PackageName,
3258                _req: &'a deps_core::VersionReq,
3259                _selection_context: &'a deps_core::SelectionContext,
3260            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3261            {
3262                let name = name.clone();
3263                Box::pin(async move {
3264                    if name.as_str() == "not-found" {
3265                        Err(deps_core::error::DepsError::PackageNotFound {
3266                            package: name.as_str().into(),
3267                            registry: "mock",
3268                        })
3269                    } else {
3270                        Err(deps_core::error::DepsError::CacheError(
3271                            "mock fallback failure".to_string(),
3272                        ))
3273                    }
3274                })
3275            }
3276
3277            fn search_raw<'a>(
3278                &'a self,
3279                _query: &'a str,
3280                _limit: usize,
3281            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3282            {
3283                Box::pin(async move { Ok(vec![]) })
3284            }
3285
3286            fn as_any(&self) -> &dyn Any {
3287                self
3288            }
3289        }
3290
3291        let registry: Arc<dyn Registry> = Arc::new(FallbackErrorRegistry);
3292        let packages = vec![PackageName::new("flaky"), PackageName::new("not-found")];
3293
3294        let result = fetch_latest_versions_parallel(
3295            registry,
3296            with_registry_source(packages),
3297            &HashMap::new(),
3298            None,
3299            deps_core::freshness::FreshnessSettings::default(),
3300            5,
3301            10,
3302            &SelectionContext::none(),
3303            None,
3304        )
3305        .await;
3306
3307        assert!(result.versions.is_empty());
3308        assert_eq!(
3309            result.fetch_failed,
3310            HashMap::from([(PackageName::new("flaky"), FetchFailure::Transient)]),
3311            "the fallback's own non-not-found error must be recorded in fetch_failed, \
3312             but its not-found error must not"
3313        );
3314        assert_eq!(
3315            result.failed_count(),
3316            2,
3317            "both fallback failures count toward failed_count regardless of cause (S2)"
3318        );
3319    }
3320
3321    #[tokio::test]
3322    async fn test_fetch_fallback_timeout_recorded_as_fetch_failed() {
3323        // Timeout coverage for the `get_latest_matching` fallback path — a
3324        // timeout is never a "not found", so it must always land in
3325        // `fetch_failed` (and count toward `failed_count`, S2).
3326        use deps_core::{Metadata, Registry, Version};
3327        use std::any::Any;
3328        use std::time::Duration;
3329
3330        struct FallbackTimeoutRegistry;
3331
3332        impl Registry for FallbackTimeoutRegistry {
3333            fn get_versions<'a>(
3334                &'a self,
3335                _name: &'a deps_core::PackageName,
3336            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3337            {
3338                Box::pin(async move { Ok(vec![]) })
3339            }
3340
3341            fn get_latest_matching<'a>(
3342                &'a self,
3343                _name: &'a deps_core::PackageName,
3344                _req: &'a deps_core::VersionReq,
3345                _selection_context: &'a deps_core::SelectionContext,
3346            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3347            {
3348                Box::pin(async move {
3349                    tokio::time::sleep(Duration::from_secs(10)).await;
3350                    Ok(None)
3351                })
3352            }
3353
3354            fn search_raw<'a>(
3355                &'a self,
3356                _query: &'a str,
3357                _limit: usize,
3358            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3359            {
3360                Box::pin(async move { Ok(vec![]) })
3361            }
3362
3363            fn as_any(&self) -> &dyn Any {
3364                self
3365            }
3366        }
3367
3368        let registry: Arc<dyn Registry> = Arc::new(FallbackTimeoutRegistry);
3369        let packages = vec![PackageName::new("slow-fallback")];
3370
3371        let result = fetch_latest_versions_parallel(
3372            registry,
3373            with_registry_source(packages),
3374            &HashMap::new(),
3375            None,
3376            deps_core::freshness::FreshnessSettings::default(),
3377            1,
3378            10,
3379            &SelectionContext::none(),
3380            None,
3381        )
3382        .await;
3383
3384        assert!(result.versions.is_empty());
3385        assert_eq!(
3386            result.fetch_failed,
3387            HashMap::from([(PackageName::new("slow-fallback"), FetchFailure::Transient)])
3388        );
3389        assert_eq!(result.failed_count(), 1);
3390    }
3391
3392    #[tokio::test]
3393    async fn test_first_error_prefers_actionable_error_over_not_found_regardless_of_race_order() {
3394        // #480: before this fix, `first_error` was simply whichever concurrent fetch
3395        // finished first, so a fast not-found could outrank a slower but more actionable
3396        // error. Here not-found resolves immediately and the actionable error resolves
3397        // after a delay, winning the race — `priority_error` must still make it win.
3398        use deps_core::{Metadata, Registry, Version};
3399        use std::any::Any;
3400        use std::time::Duration;
3401
3402        struct MixedErrorRegistry;
3403
3404        impl Registry for MixedErrorRegistry {
3405            fn get_versions<'a>(
3406                &'a self,
3407                name: &'a deps_core::PackageName,
3408            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3409            {
3410                Box::pin(async move {
3411                    if name.as_str() == "typo-pkg" {
3412                        Err(deps_core::error::DepsError::PackageNotFound {
3413                            package: name.as_str().into(),
3414                            registry: "mock",
3415                        })
3416                    } else {
3417                        tokio::time::sleep(Duration::from_millis(50)).await;
3418                        Err(deps_core::error::DepsError::CacheError(
3419                            "rate limit exceeded".to_string(),
3420                        ))
3421                    }
3422                })
3423            }
3424
3425            fn get_latest_matching<'a>(
3426                &'a self,
3427                _name: &'a deps_core::PackageName,
3428                _req: &'a deps_core::VersionReq,
3429                _selection_context: &'a deps_core::SelectionContext,
3430            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3431            {
3432                Box::pin(async move { Ok(None) })
3433            }
3434
3435            fn search_raw<'a>(
3436                &'a self,
3437                _query: &'a str,
3438                _limit: usize,
3439            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3440            {
3441                Box::pin(async move { Ok(vec![]) })
3442            }
3443
3444            fn as_any(&self) -> &dyn Any {
3445                self
3446            }
3447        }
3448
3449        let registry: Arc<dyn Registry> = Arc::new(MixedErrorRegistry);
3450        let packages = vec![
3451            PackageName::new("typo-pkg"),
3452            PackageName::new("rate-limited"),
3453        ];
3454
3455        let result = fetch_latest_versions_parallel(
3456            registry,
3457            with_registry_source(packages),
3458            &HashMap::new(),
3459            None,
3460            deps_core::freshness::FreshnessSettings::default(),
3461            5,
3462            10,
3463            &SelectionContext::none(),
3464            None,
3465        )
3466        .await;
3467
3468        let err = result
3469            .failure_message()
3470            .expect("an actionable failure occurred and must be reported");
3471        assert!(
3472            err.contains("rate limit exceeded"),
3473            "the actionable error must win the toast over the faster-finishing not-found, \
3474             got: {err}"
3475        );
3476        assert!(
3477            !err.contains("not found"),
3478            "a not-found error must never outrank an actionable error, got: {err}"
3479        );
3480    }
3481
3482    #[tokio::test]
3483    async fn test_first_error_falls_back_to_not_found_when_no_actionable_error_occurred() {
3484        // #480 fallback path: `priority_error` is only populated by errors that also
3485        // count toward `fetch_failed` (non-not-found). A batch whose only failures are
3486        // not-found ones must still surface one via `first_error` instead of silently
3487        // reporting nothing.
3488        use deps_core::{Metadata, Registry, Version};
3489        use std::any::Any;
3490
3491        struct AllNotFoundRegistry;
3492
3493        impl Registry for AllNotFoundRegistry {
3494            fn get_versions<'a>(
3495                &'a self,
3496                name: &'a deps_core::PackageName,
3497            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3498            {
3499                Box::pin(async move {
3500                    Err(deps_core::error::DepsError::PackageNotFound {
3501                        package: name.as_str().into(),
3502                        registry: "mock",
3503                    })
3504                })
3505            }
3506
3507            fn get_latest_matching<'a>(
3508                &'a self,
3509                _name: &'a deps_core::PackageName,
3510                _req: &'a deps_core::VersionReq,
3511                _selection_context: &'a deps_core::SelectionContext,
3512            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3513            {
3514                Box::pin(async move { Ok(None) })
3515            }
3516
3517            fn search_raw<'a>(
3518                &'a self,
3519                _query: &'a str,
3520                _limit: usize,
3521            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3522            {
3523                Box::pin(async move { Ok(vec![]) })
3524            }
3525
3526            fn as_any(&self) -> &dyn Any {
3527                self
3528            }
3529        }
3530
3531        let registry: Arc<dyn Registry> = Arc::new(AllNotFoundRegistry);
3532        let packages = vec![
3533            PackageName::new("typo-pkg-1"),
3534            PackageName::new("typo-pkg-2"),
3535        ];
3536
3537        let result = fetch_latest_versions_parallel(
3538            registry,
3539            with_registry_source(packages),
3540            &HashMap::new(),
3541            None,
3542            deps_core::freshness::FreshnessSettings::default(),
3543            5,
3544            10,
3545            &SelectionContext::none(),
3546            None,
3547        )
3548        .await;
3549
3550        assert!(
3551            result.fetch_failed.is_empty(),
3552            "not-found errors must never be recorded in fetch_failed"
3553        );
3554        let err = result
3555            .failure_message()
3556            .expect("a not-found-only batch must still fall back to reporting one via first_error");
3557        assert!(err.contains("not found"), "got: {err}");
3558    }
3559
3560    #[tokio::test]
3561    async fn test_timeout_only_batch_reports_first_error_alongside_failed_count() {
3562        // #480 S1: the toast used to special-case a populated `first_error`, dropping
3563        // `failed_count` from the message — a timeout batch silently lost its count. Now
3564        // built unconditionally from both fields (#490): asserts `FetchResult` reports
3565        // `failed_count` equal to batch size *and* a populated `first_error` together.
3566        use deps_core::{Metadata, Registry, Version};
3567        use std::any::Any;
3568        use std::time::Duration;
3569
3570        struct AlwaysTimesOutRegistry;
3571
3572        impl Registry for AlwaysTimesOutRegistry {
3573            fn get_versions<'a>(
3574                &'a self,
3575                _name: &'a deps_core::PackageName,
3576            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3577            {
3578                Box::pin(async move {
3579                    tokio::time::sleep(Duration::from_secs(10)).await;
3580                    Ok(vec![])
3581                })
3582            }
3583
3584            fn get_latest_matching<'a>(
3585                &'a self,
3586                _name: &'a deps_core::PackageName,
3587                _req: &'a deps_core::VersionReq,
3588                _selection_context: &'a deps_core::SelectionContext,
3589            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3590            {
3591                Box::pin(async move {
3592                    tokio::time::sleep(Duration::from_secs(10)).await;
3593                    Ok(None)
3594                })
3595            }
3596
3597            fn search_raw<'a>(
3598                &'a self,
3599                _query: &'a str,
3600                _limit: usize,
3601            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3602            {
3603                Box::pin(async move { Ok(vec![]) })
3604            }
3605
3606            fn as_any(&self) -> &dyn Any {
3607                self
3608            }
3609        }
3610
3611        let registry: Arc<dyn Registry> = Arc::new(AlwaysTimesOutRegistry);
3612        let packages = vec![
3613            PackageName::new("slow-1"),
3614            PackageName::new("slow-2"),
3615            PackageName::new("slow-3"),
3616        ];
3617
3618        let result = fetch_latest_versions_parallel(
3619            registry,
3620            with_registry_source(packages),
3621            &HashMap::new(),
3622            None,
3623            deps_core::freshness::FreshnessSettings::default(),
3624            1,
3625            10,
3626            &SelectionContext::none(),
3627            None,
3628        )
3629        .await;
3630
3631        assert_eq!(
3632            result.failed_count(),
3633            3,
3634            "all 3 packages must count toward failed_count"
3635        );
3636        let err = result
3637            .failure_message()
3638            .expect("a timeout is actionable and must populate first_error, not just failed_count");
3639        assert!(
3640            err.contains("timed out"),
3641            "first_error must be the actionable timeout message, got: {err}"
3642        );
3643    }
3644
3645    // Composer-specific tests
3646    #[cfg(feature = "composer")]
3647    mod composer_tests {
3648        use super::*;
3649
3650        /// #1433: `prepare_fetch` must surface the manifest's `minimum-stability` field via
3651        /// the real `deps_composer::parser::parse_composer_json` -> `ComposerParseResult` ->
3652        /// `deps_core::ParseResult::selection_context` path, not just a hand-built fixture —
3653        /// this is the actual production call path from the fetch task.
3654        #[tokio::test]
3655        async fn test_prepare_fetch_selection_context_extracts_from_real_parse_result() {
3656            let json = r#"{
3657  "minimum-stability": "beta",
3658  "require": {
3659    "symfony/console": "^6.0"
3660  }
3661}"#;
3662            let uri = deps_core::test_util::test_uri("/test/composer.json");
3663            let parse_result = crate::setup::parse_composer_json(json, &uri).unwrap();
3664            let formatter = deps_composer::ComposerFormatter;
3665
3666            let prep = prepare_fetch(
3667                &parse_result as &dyn deps_core::ParseResult,
3668                &formatter,
3669                deps_core::EcosystemId::Composer,
3670                &HashMap::new(),
3671                &HashMap::new(),
3672            );
3673
3674            assert_eq!(
3675                prep.selection_context.minimum_stability(),
3676                Some(deps_core::StabilityFloor::Beta)
3677            );
3678        }
3679
3680        /// #1433: a `composer.json` with no `minimum-stability` field surfaces an empty
3681        /// `SelectionContext`, not a fabricated `"stable"`.
3682        #[tokio::test]
3683        async fn test_prepare_fetch_selection_context_none_when_absent() {
3684            let json = r#"{"require": {"symfony/console": "^6.0"}}"#;
3685            let uri = deps_core::test_util::test_uri("/test/composer.json");
3686            let parse_result = crate::setup::parse_composer_json(json, &uri).unwrap();
3687            let formatter = deps_composer::ComposerFormatter;
3688
3689            let prep = prepare_fetch(
3690                &parse_result as &dyn deps_core::ParseResult,
3691                &formatter,
3692                deps_core::EcosystemId::Composer,
3693                &HashMap::new(),
3694                &HashMap::new(),
3695            );
3696
3697            assert_eq!(prep.selection_context.minimum_stability(), None);
3698        }
3699    }
3700    mod yanked_check_tests {
3701        use super::*;
3702        use deps_core::test_util::MockVersion;
3703        use deps_core::{Metadata, Version};
3704        use std::any::Any;
3705        use std::sync::atomic::{AtomicUsize, Ordering};
3706
3707        /// Per-package outcome for the primary (and, under #206, only)
3708        /// `get_versions` fetch.
3709        enum FetchOutcome {
3710            Versions(Vec<(&'static str, bool)>),
3711            Error,
3712            Timeout,
3713        }
3714
3715        /// Configurable mock registry for exercising the yanked-check wiring
3716        /// in `fetch_latest_versions_parallel`. Under #206's single-fetch
3717        /// design, `get_versions` is both the source of "latest" (via
3718        /// `select_latest_matching`, mirrored here by picking the first
3719        /// non-yanked entry) and, in the same in-memory list, the source of
3720        /// the yanked check — there is no second registry call to mock.
3721        /// `latest_fallback` only feeds the `get_latest_matching` fallback
3722        /// path, exercised when `select_latest_matching` finds nothing (all
3723        /// yanked, or an empty list).
3724        struct MockRegistry {
3725            reports_yanked: bool,
3726            versions: HashMap<&'static str, FetchOutcome>,
3727            latest_fallback: HashMap<&'static str, (&'static str, bool)>,
3728            fetch_calls: Arc<AtomicUsize>,
3729        }
3730
3731        impl Registry for MockRegistry {
3732            fn get_versions<'a>(
3733                &'a self,
3734                name: &'a PackageName,
3735            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3736            {
3737                self.fetch_calls.fetch_add(1, Ordering::Relaxed);
3738                let outcome = self.versions.get(name.as_str());
3739                Box::pin(async move {
3740                    match outcome {
3741                        Some(FetchOutcome::Versions(vs)) => Ok(vs
3742                            .iter()
3743                            .map(|(v, y)| {
3744                                Box::new(MockVersion::new(*v).yanked(*y)) as Box<dyn Version>
3745                            })
3746                            .collect()),
3747                        Some(FetchOutcome::Error) => Err(deps_core::error::DepsError::CacheError(
3748                            "mock fetch error".to_string(),
3749                        )),
3750                        Some(FetchOutcome::Timeout) => {
3751                            tokio::time::sleep(std::time::Duration::from_secs(10)).await;
3752                            Ok(vec![])
3753                        }
3754                        None => Ok(vec![]),
3755                    }
3756                })
3757            }
3758
3759            fn select_latest_matching(
3760                &self,
3761                versions: &[Box<dyn Version>],
3762                _req: &VersionReq,
3763                _selection_context: &deps_core::SelectionContext,
3764            ) -> Option<usize> {
3765                versions
3766                    .iter()
3767                    .position(|v| !v.removal_status().blocks_resolution())
3768            }
3769
3770            fn get_latest_matching<'a>(
3771                &'a self,
3772                name: &'a PackageName,
3773                _req: &'a VersionReq,
3774                _selection_context: &'a deps_core::SelectionContext,
3775            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3776            {
3777                let outcome = self.latest_fallback.get(name.as_str()).copied();
3778                Box::pin(async move {
3779                    Ok(outcome
3780                        .map(|(v, y)| Box::new(MockVersion::new(v).yanked(y)) as Box<dyn Version>))
3781                })
3782            }
3783
3784            fn search_raw<'a>(
3785                &'a self,
3786                _query: &'a str,
3787                _limit: usize,
3788            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3789            {
3790                Box::pin(async move { Ok(vec![]) })
3791            }
3792
3793            fn reports_yanked(&self) -> bool {
3794                self.reports_yanked
3795            }
3796
3797            fn as_any(&self) -> &dyn Any {
3798                self
3799            }
3800        }
3801
3802        #[tokio::test]
3803        async fn reports_yanked_false_never_recorded() {
3804            // The fetched list carries a yanked in-use entry, but
3805            // `reports_yanked() == false` means `removal_status()` must never be
3806            // trusted, even though the data is already in hand for free.
3807            let fetch_calls = Arc::new(AtomicUsize::new(0));
3808            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3809                reports_yanked: false,
3810                versions: HashMap::from([(
3811                    "pkg",
3812                    FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", true)]),
3813                )]),
3814                latest_fallback: HashMap::new(),
3815                fetch_calls: Arc::clone(&fetch_calls),
3816            });
3817            let mut in_use = HashMap::new();
3818            in_use.insert(
3819                PackageName::new("pkg"),
3820                vec![ConcreteVersion::from("1.0.0")],
3821            );
3822
3823            let result = fetch_latest_versions_parallel(
3824                registry,
3825                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3826                &in_use,
3827                None,
3828                deps_core::freshness::FreshnessSettings::default(),
3829                5,
3830                10,
3831                &SelectionContext::none(),
3832                None,
3833            )
3834            .await;
3835
3836            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3837            assert!(result.yanked_versions.is_empty());
3838        }
3839
3840        #[tokio::test]
3841        async fn in_use_equal_to_latest_not_yanked() {
3842            let fetch_calls = Arc::new(AtomicUsize::new(0));
3843            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3844                reports_yanked: true,
3845                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", false)]))]),
3846                latest_fallback: HashMap::new(),
3847                fetch_calls: Arc::clone(&fetch_calls),
3848            });
3849            let mut in_use = HashMap::new();
3850            in_use.insert(
3851                PackageName::new("pkg"),
3852                vec![ConcreteVersion::from("1.0.0")],
3853            );
3854
3855            let result = fetch_latest_versions_parallel(
3856                registry,
3857                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3858                &in_use,
3859                None,
3860                deps_core::freshness::FreshnessSettings::default(),
3861                5,
3862                10,
3863                &SelectionContext::none(),
3864                None,
3865            )
3866            .await;
3867
3868            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3869            assert!(result.yanked_versions.is_empty());
3870        }
3871
3872        #[tokio::test]
3873        async fn no_known_in_use_version_skips_the_check() {
3874            let fetch_calls = Arc::new(AtomicUsize::new(0));
3875            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3876                reports_yanked: true,
3877                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("2.0.0", false)]))]),
3878                latest_fallback: HashMap::new(),
3879                fetch_calls: Arc::clone(&fetch_calls),
3880            });
3881
3882            let result = fetch_latest_versions_parallel(
3883                registry,
3884                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3885                &HashMap::new(),
3886                None,
3887                deps_core::freshness::FreshnessSettings::default(),
3888                5,
3889                10,
3890                &SelectionContext::none(),
3891                None,
3892            )
3893            .await;
3894
3895            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3896            assert!(result.yanked_versions.is_empty());
3897        }
3898
3899        #[tokio::test]
3900        async fn in_use_differs_and_yanked_is_recorded() {
3901            // No second registry call under #206: the in-use check is a
3902            // search over the same `versions` list already fetched for
3903            // "latest" — `fetch_calls` stays at 1.
3904            let fetch_calls = Arc::new(AtomicUsize::new(0));
3905            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3906                reports_yanked: true,
3907                versions: HashMap::from([(
3908                    "pkg",
3909                    FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", true)]),
3910                )]),
3911                latest_fallback: HashMap::new(),
3912                fetch_calls: Arc::clone(&fetch_calls),
3913            });
3914            let mut in_use = HashMap::new();
3915            in_use.insert(
3916                PackageName::new("pkg"),
3917                vec![ConcreteVersion::from("1.0.0")],
3918            );
3919
3920            let result = fetch_latest_versions_parallel(
3921                registry,
3922                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3923                &in_use,
3924                None,
3925                deps_core::freshness::FreshnessSettings::default(),
3926                5,
3927                10,
3928                &SelectionContext::none(),
3929                None,
3930            )
3931            .await;
3932
3933            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3934            assert_eq!(
3935                result.yanked_versions.get(&PackageName::new("pkg")),
3936                Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
3937            );
3938        }
3939
3940        #[tokio::test]
3941        async fn in_use_differs_and_not_yanked_is_not_recorded() {
3942            let fetch_calls = Arc::new(AtomicUsize::new(0));
3943            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3944                reports_yanked: true,
3945                versions: HashMap::from([(
3946                    "pkg",
3947                    FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", false)]),
3948                )]),
3949                latest_fallback: HashMap::new(),
3950                fetch_calls: Arc::clone(&fetch_calls),
3951            });
3952            let mut in_use = HashMap::new();
3953            in_use.insert(
3954                PackageName::new("pkg"),
3955                vec![ConcreteVersion::from("1.0.0")],
3956            );
3957
3958            let result = fetch_latest_versions_parallel(
3959                registry,
3960                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3961                &in_use,
3962                None,
3963                deps_core::freshness::FreshnessSettings::default(),
3964                5,
3965                10,
3966                &SelectionContext::none(),
3967                None,
3968            )
3969            .await;
3970
3971            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3972            assert!(result.yanked_versions.is_empty());
3973        }
3974
3975        #[tokio::test]
3976        async fn every_version_yanked_still_checks_in_use() {
3977            // Critique M2: every version filtered out by the wildcard
3978            // requirement (here, all yanked) is the most severe case, not a
3979            // silent skip. `select_latest_matching` finds nothing, the
3980            // `get_latest_matching` fallback also finds nothing (no entry in
3981            // `latest_fallback`), so `result.versions` stays empty — but the
3982            // yanked check still runs against the originally fetched list.
3983            let fetch_calls = Arc::new(AtomicUsize::new(0));
3984            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3985                reports_yanked: true,
3986                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
3987                latest_fallback: HashMap::new(),
3988                fetch_calls: Arc::clone(&fetch_calls),
3989            });
3990            let mut in_use = HashMap::new();
3991            in_use.insert(
3992                PackageName::new("pkg"),
3993                vec![ConcreteVersion::from("1.0.0")],
3994            );
3995
3996            let result = fetch_latest_versions_parallel(
3997                registry,
3998                vec![(PackageName::new("pkg"), DependencySource::Registry)],
3999                &in_use,
4000                None,
4001                deps_core::freshness::FreshnessSettings::default(),
4002                5,
4003                10,
4004                &SelectionContext::none(),
4005                None,
4006            )
4007            .await;
4008
4009            assert_eq!(
4010                result.yanked_versions.get(&PackageName::new("pkg")),
4011                Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
4012            );
4013            assert!(result.versions.is_empty());
4014        }
4015
4016        #[tokio::test]
4017        async fn latest_pick_needs_fallback_in_use_yanked_still_found() {
4018            // When the list-based pick fails (all yanked) and the
4019            // `get_latest_matching` fallback succeeds with a *different*,
4020            // non-yanked version, `result.versions` is populated from the
4021            // fallback — but the in-use yanked check still searches the
4022            // originally fetched list, not the fallback's single version.
4023            let fetch_calls = Arc::new(AtomicUsize::new(0));
4024            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
4025                reports_yanked: true,
4026                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
4027                latest_fallback: HashMap::from([("pkg", ("2.0.0", false))]),
4028                fetch_calls: Arc::clone(&fetch_calls),
4029            });
4030            let mut in_use = HashMap::new();
4031            in_use.insert(
4032                PackageName::new("pkg"),
4033                vec![ConcreteVersion::from("1.0.0")],
4034            );
4035
4036            let result = fetch_latest_versions_parallel(
4037                registry,
4038                vec![(PackageName::new("pkg"), DependencySource::Registry)],
4039                &in_use,
4040                None,
4041                deps_core::freshness::FreshnessSettings::default(),
4042                5,
4043                10,
4044                &SelectionContext::none(),
4045                None,
4046            )
4047            .await;
4048
4049            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
4050            assert_eq!(
4051                result
4052                    .versions
4053                    .get(&PackageName::new("pkg"))
4054                    .map(|v| v.latest.as_str()),
4055                Some("2.0.0")
4056            );
4057            assert_eq!(
4058                result.yanked_versions.get(&PackageName::new("pkg")),
4059                Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
4060            );
4061        }
4062
4063        /// Dedicated registry (not the shared `MockRegistry`, whose `latest_fallback` can
4064        /// only express `Ok(Some(_))`/`Ok(None)`): the initial `get_versions` fetch succeeds
4065        /// with an all-yanked list (forcing the `get_latest_matching` fallback), and that
4066        /// fallback then genuinely errors — exercising `PackageOutcome::yanked` being `Some`
4067        /// alongside `PackageStatus::Failed` (issue #1470's own doc rationale for keeping the
4068        /// two independent, previously untested).
4069        struct YankedThenFallbackErrorRegistry;
4070
4071        impl Registry for YankedThenFallbackErrorRegistry {
4072            fn get_versions<'a>(
4073                &'a self,
4074                _name: &'a PackageName,
4075            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4076            {
4077                Box::pin(async move {
4078                    Ok(vec![
4079                        Box::new(MockVersion::new("1.0.0").yanked(true)) as Box<dyn Version>
4080                    ])
4081                })
4082            }
4083
4084            fn select_latest_matching(
4085                &self,
4086                versions: &[Box<dyn Version>],
4087                _req: &VersionReq,
4088                _selection_context: &deps_core::SelectionContext,
4089            ) -> Option<usize> {
4090                versions
4091                    .iter()
4092                    .position(|v| !v.removal_status().blocks_resolution())
4093            }
4094
4095            fn get_latest_matching<'a>(
4096                &'a self,
4097                _name: &'a PackageName,
4098                _req: &'a VersionReq,
4099                _selection_context: &'a deps_core::SelectionContext,
4100            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4101            {
4102                Box::pin(async move {
4103                    Err(deps_core::error::DepsError::CacheError(
4104                        "mock fallback failure".to_string(),
4105                    ))
4106                })
4107            }
4108
4109            fn search_raw<'a>(
4110                &'a self,
4111                _query: &'a str,
4112                _limit: usize,
4113            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4114            {
4115                Box::pin(async move { Ok(vec![]) })
4116            }
4117
4118            fn reports_yanked(&self) -> bool {
4119                true
4120            }
4121
4122            fn as_any(&self) -> &dyn Any {
4123                self
4124            }
4125        }
4126
4127        #[tokio::test]
4128        async fn fallback_error_does_not_suppress_an_already_found_yanked_in_use_version() {
4129            let mut in_use = HashMap::new();
4130            in_use.insert(
4131                PackageName::new("pkg"),
4132                vec![ConcreteVersion::from("1.0.0")],
4133            );
4134
4135            let result = fetch_latest_versions_parallel(
4136                Arc::new(YankedThenFallbackErrorRegistry),
4137                vec![(PackageName::new("pkg"), DependencySource::Registry)],
4138                &in_use,
4139                None,
4140                deps_core::freshness::FreshnessSettings::default(),
4141                5,
4142                10,
4143                &SelectionContext::none(),
4144                None,
4145            )
4146            .await;
4147
4148            assert!(
4149                result.versions.is_empty(),
4150                "the fallback errored, so no version was resolved"
4151            );
4152            assert_eq!(
4153                result.yanked_versions.get(&PackageName::new("pkg")),
4154                Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked)),
4155                "the yanked in-use finding must survive even though the fallback pick failed"
4156            );
4157            assert_eq!(
4158                result.fetch_failed.get(&PackageName::new("pkg")),
4159                Some(&FetchFailure::Transient)
4160            );
4161            assert_eq!(result.failed_count(), 1);
4162        }
4163
4164        #[tokio::test]
4165        async fn in_use_checks_every_occurrence_of_a_duplicate_name() {
4166            // Regression guard for #394: a package can appear more than once
4167            // in a manifest under the same name (e.g. `[dependencies]` +
4168            // `[dev-dependencies]`), each pinned to a different in-use
4169            // version. Only one occurrence ("2.0.0") is yanked; the other
4170            // ("3.0.0", not fetched here, not yanked) must not shadow it.
4171            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
4172                reports_yanked: true,
4173                versions: HashMap::from([(
4174                    "pkg",
4175                    FetchOutcome::Versions(vec![
4176                        ("3.0.0", false),
4177                        ("2.0.0", true),
4178                        ("1.0.0", false),
4179                    ]),
4180                )]),
4181                latest_fallback: HashMap::new(),
4182                fetch_calls: Arc::new(AtomicUsize::new(0)),
4183            });
4184            let mut in_use = HashMap::new();
4185            in_use.insert(
4186                PackageName::new("pkg"),
4187                vec![
4188                    ConcreteVersion::from("1.0.0"),
4189                    ConcreteVersion::from("2.0.0"),
4190                ],
4191            );
4192
4193            let result = fetch_latest_versions_parallel(
4194                registry,
4195                vec![(PackageName::new("pkg"), DependencySource::Registry)],
4196                &in_use,
4197                None,
4198                deps_core::freshness::FreshnessSettings::default(),
4199                5,
4200                10,
4201                &SelectionContext::none(),
4202                None,
4203            )
4204            .await;
4205
4206            assert_eq!(
4207                result.yanked_versions.get(&PackageName::new("pkg")),
4208                Some(&(ConcreteVersion::new("2.0.0"), RemovalStatus::Yanked)),
4209                "the yanked occurrence must be found even though a name-keyed \
4210                 single-value map could have kept only the non-yanked \"1.0.0\" pin"
4211            );
4212        }
4213
4214        #[tokio::test]
4215        async fn latest_is_yanked_recorded_as_defense_in_depth() {
4216            // §4.7 row 1: a contract-violating registry (its wildcard
4217            // `get_latest_matching` fallback returns a yanked version) still
4218            // gets recorded, at zero extra cost. `select_latest_matching`
4219            // filters yanked entries by construction, so the list-based pick
4220            // finds nothing here and the fallback is what "lies".
4221            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
4222                reports_yanked: true,
4223                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
4224                latest_fallback: HashMap::from([("pkg", ("1.0.0", true))]),
4225                fetch_calls: Arc::new(AtomicUsize::new(0)),
4226            });
4227
4228            let result = fetch_latest_versions_parallel(
4229                registry,
4230                vec![(PackageName::new("pkg"), DependencySource::Registry)],
4231                &HashMap::new(),
4232                None,
4233                deps_core::freshness::FreshnessSettings::default(),
4234                5,
4235                10,
4236                &SelectionContext::none(),
4237                None,
4238            )
4239            .await;
4240
4241            assert_eq!(
4242                result.yanked_versions.get(&PackageName::new("pkg")),
4243                Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
4244            );
4245        }
4246
4247        #[tokio::test]
4248        async fn latest_is_yanked_not_recorded_when_reports_yanked_false() {
4249            // impl-critic M1: row 1 must respect the same `reports_yanked()`
4250            // gate as the in-memory in-use check. Harmless today only
4251            // because every opt-out registry also hardcodes `removal_status`
4252            // to `Available` — this guards against a follow-up (§8.2/§8.3)
4253            // making an opt-out registry's `removal_status()` real without
4254            // also flipping `reports_yanked()`, which would otherwise
4255            // silently reintroduce a #233-class bug through this exact row.
4256            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
4257                reports_yanked: false,
4258                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
4259                latest_fallback: HashMap::from([("pkg", ("1.0.0", true))]),
4260                fetch_calls: Arc::new(AtomicUsize::new(0)),
4261            });
4262
4263            let result = fetch_latest_versions_parallel(
4264                registry,
4265                vec![(PackageName::new("pkg"), DependencySource::Registry)],
4266                &HashMap::new(),
4267                None,
4268                deps_core::freshness::FreshnessSettings::default(),
4269                5,
4270                10,
4271                &SelectionContext::none(),
4272                None,
4273            )
4274            .await;
4275
4276            assert!(
4277                result.yanked_versions.is_empty(),
4278                "a `reports_yanked() == false` registry's `removal_status()` must never be \
4279                 trusted, even on the zero-cost row-1 path"
4280            );
4281            assert!(
4282                result
4283                    .versions
4284                    .get(&PackageName::new("pkg"))
4285                    .expect("pkg was fetched")
4286                    .yanked
4287                    .is_empty(),
4288                "`PackageVersions::yanked` must stay empty for a `reports_yanked() == false` \
4289                 registry, even though the fetched version is itself flagged"
4290            );
4291        }
4292
4293        #[tokio::test]
4294        async fn primary_fetch_error_counts_as_failed_no_yanked_data() {
4295            // Under #206's single-fetch design there is no separate "probe"
4296            // that can fail independently of the primary fetch — a
4297            // `get_versions` failure loses both the "latest" and the yanked
4298            // data together, and is counted as a real fetch failure (unlike
4299            // the pre-#206 best-effort probe).
4300            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
4301                reports_yanked: true,
4302                versions: HashMap::from([("pkg", FetchOutcome::Error)]),
4303                latest_fallback: HashMap::new(),
4304                fetch_calls: Arc::new(AtomicUsize::new(0)),
4305            });
4306            let mut in_use = HashMap::new();
4307            in_use.insert(
4308                PackageName::new("pkg"),
4309                vec![ConcreteVersion::from("1.0.0")],
4310            );
4311
4312            let result = fetch_latest_versions_parallel(
4313                registry,
4314                vec![(PackageName::new("pkg"), DependencySource::Registry)],
4315                &in_use,
4316                None,
4317                deps_core::freshness::FreshnessSettings::default(),
4318                5,
4319                10,
4320                &SelectionContext::none(),
4321                None,
4322            )
4323            .await;
4324
4325            assert!(result.yanked_versions.is_empty());
4326            assert_eq!(result.failed_count(), 1);
4327            assert!(result.versions.is_empty());
4328        }
4329
4330        #[tokio::test]
4331        async fn primary_fetch_timeout_counts_as_failed_no_yanked_data() {
4332            // Same reasoning as the error case above, for the timeout path.
4333            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
4334                reports_yanked: true,
4335                versions: HashMap::from([("pkg", FetchOutcome::Timeout)]),
4336                latest_fallback: HashMap::new(),
4337                fetch_calls: Arc::new(AtomicUsize::new(0)),
4338            });
4339            let mut in_use = HashMap::new();
4340            in_use.insert(
4341                PackageName::new("pkg"),
4342                vec![ConcreteVersion::from("1.0.0")],
4343            );
4344
4345            let result = fetch_latest_versions_parallel(
4346                registry,
4347                vec![(PackageName::new("pkg"), DependencySource::Registry)],
4348                &in_use,
4349                None,
4350                deps_core::freshness::FreshnessSettings::default(),
4351                1,
4352                10,
4353                &SelectionContext::none(),
4354                None,
4355            )
4356            .await;
4357
4358            assert!(result.yanked_versions.is_empty());
4359            assert_eq!(result.failed_count(), 1);
4360            assert!(result.versions.is_empty());
4361        }
4362    }
4363
4364    /// #205: the `fetch_latest_versions_parallel` wiring that derives `FetchResult::deprecations`
4365    /// from the `resolved`/"latest" pick, self-contained rather than extending
4366    /// `yanked_check_tests`'s shared `MockRegistry`/`FetchOutcome` (whose tuple shape has
4367    /// no room for a per-version `Deprecation` payload without touching its many existing
4368    /// call sites).
4369    mod deprecation_derivation_tests {
4370        use super::*;
4371        use deps_core::{Metadata, Version};
4372        use std::any::Any;
4373
4374        struct MockDeprecatedVersion {
4375            version: ConcreteVersion,
4376            deprecation: Option<Deprecation>,
4377        }
4378
4379        impl Version for MockDeprecatedVersion {
4380            fn version_string(&self) -> &ConcreteVersion {
4381                &self.version
4382            }
4383            fn removal_status(&self) -> RemovalStatus {
4384                RemovalStatus::from_advisory(self.deprecation.is_some())
4385            }
4386            fn deprecation(&self) -> Option<&Deprecation> {
4387                self.deprecation.as_ref()
4388            }
4389            fn as_any(&self) -> &dyn Any {
4390                self
4391            }
4392        }
4393
4394        /// Always resolves to its single configured version.
4395        struct SingleVersionRegistry {
4396            deprecation: Option<Deprecation>,
4397        }
4398
4399        impl Registry for SingleVersionRegistry {
4400            fn get_versions<'a>(
4401                &'a self,
4402                _name: &'a PackageName,
4403            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4404            {
4405                let deprecation = self.deprecation.clone();
4406                Box::pin(async move {
4407                    Ok(vec![Box::new(MockDeprecatedVersion {
4408                        version: "1.0.0".into(),
4409                        deprecation,
4410                    }) as Box<dyn Version>])
4411                })
4412            }
4413
4414            fn select_latest_matching(
4415                &self,
4416                versions: &[Box<dyn Version>],
4417                _req: &VersionReq,
4418                _selection_context: &deps_core::SelectionContext,
4419            ) -> Option<usize> {
4420                (!versions.is_empty()).then_some(0)
4421            }
4422
4423            fn get_latest_matching<'a>(
4424                &'a self,
4425                _name: &'a PackageName,
4426                _req: &'a VersionReq,
4427                _selection_context: &'a deps_core::SelectionContext,
4428            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4429            {
4430                Box::pin(async move { Ok(None) })
4431            }
4432
4433            fn search_raw<'a>(
4434                &'a self,
4435                _query: &'a str,
4436                _limit: usize,
4437            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4438            {
4439                Box::pin(async move { Ok(vec![]) })
4440            }
4441
4442            fn as_any(&self) -> &dyn Any {
4443                self
4444            }
4445        }
4446
4447        #[tokio::test]
4448        async fn fetch_result_carries_deprecation_from_resolved_pick() {
4449            let registry: Arc<dyn Registry> = Arc::new(SingleVersionRegistry {
4450                deprecation: Some(Deprecation {
4451                    reason: Some("archived".to_string()),
4452                    replacement: Some("other/pkg".to_string()),
4453                }),
4454            });
4455
4456            let result = fetch_latest_versions_parallel(
4457                registry,
4458                vec![(PackageName::new("pkg"), DependencySource::Registry)],
4459                &HashMap::new(),
4460                None,
4461                deps_core::freshness::FreshnessSettings::default(),
4462                5,
4463                10,
4464                &SelectionContext::none(),
4465                None,
4466            )
4467            .await;
4468
4469            assert_eq!(
4470                result.deprecations.get(&PackageName::new("pkg")),
4471                Some(&Deprecation {
4472                    reason: Some("archived".to_string()),
4473                    replacement: Some("other/pkg".to_string()),
4474                })
4475            );
4476        }
4477
4478        #[tokio::test]
4479        async fn fetch_result_has_no_deprecation_when_resolved_pick_is_clean() {
4480            let registry: Arc<dyn Registry> = Arc::new(SingleVersionRegistry { deprecation: None });
4481
4482            let result = fetch_latest_versions_parallel(
4483                registry,
4484                vec![(PackageName::new("pkg"), DependencySource::Registry)],
4485                &HashMap::new(),
4486                None,
4487                deps_core::freshness::FreshnessSettings::default(),
4488                5,
4489                10,
4490                &SelectionContext::none(),
4491                None,
4492            )
4493            .await;
4494
4495            assert!(result.deprecations.is_empty());
4496        }
4497    }
4498
4499    /// Spec 074 FR-003/FR-005: the fetch-level GOSSIP-cooldown filter and its attribution
4500    /// field, exercised through the public [`fetch_latest_versions_parallel`] entry point
4501    /// rather than the private [`fetch_and_classify_package`] directly.
4502    mod gossip_cooldown_filter_tests {
4503        use super::*;
4504        use deps_core::test_util::{MockVersion, stub_gossip_findings};
4505        use deps_core::{
4506            GossipCooldown, GossipRiskLevel, Metadata, PublishTime, Registry, Version,
4507        };
4508        use std::any::Any;
4509
4510        /// Returns a fixed, newest-first version list regardless of the queried package name.
4511        ///
4512        /// `fallback`, when set, is what `get_latest_matching` (the `get_latest_matching_from`
4513        /// registry endpoint the fetch loop falls back to when the list-based pick comes up
4514        /// empty) returns — used by the S1-residual test to model deps.dev's cooldown being
4515        /// silently bypassed when GOSSIP excludes the sole candidate and no in-use version
4516        /// protects it (spec 074 §6's documented, not-fixed residual gap).
4517        struct FixedListRegistry {
4518            versions: Vec<&'static str>,
4519            fallback: Option<&'static str>,
4520            /// When set, `select_latest_matching` rejects any version string containing `-`
4521            /// (a bare stand-in for a prerelease marker) — mirrors an ecosystem's own
4522            /// selection rules refusing to pick a prerelease as "latest" (Go's
4523            /// `select_latest_matching_impl`, registry.rs:926), used to model the round-2 S1
4524            /// scenario where a floor-filtered candidate set still yields no pick.
4525            reject_prerelease: bool,
4526            /// Counts calls to `get_latest_matching` (the `get_latest_matching_from` fallback
4527            /// endpoint) — used to assert it is never invoked as a consequence of GOSSIP's
4528            /// own filtering (round-2 S1 closure).
4529            fallback_calls: std::sync::atomic::AtomicUsize,
4530        }
4531
4532        impl FixedListRegistry {
4533            fn new(versions: Vec<&'static str>) -> Self {
4534                Self {
4535                    versions,
4536                    fallback: None,
4537                    reject_prerelease: false,
4538                    fallback_calls: std::sync::atomic::AtomicUsize::new(0),
4539                }
4540            }
4541
4542            fn with_fallback(versions: Vec<&'static str>, fallback: &'static str) -> Self {
4543                Self {
4544                    versions,
4545                    fallback: Some(fallback),
4546                    reject_prerelease: false,
4547                    fallback_calls: std::sync::atomic::AtomicUsize::new(0),
4548                }
4549            }
4550
4551            fn with_prerelease_rejection(versions: Vec<&'static str>) -> Self {
4552                Self {
4553                    versions,
4554                    fallback: None,
4555                    reject_prerelease: true,
4556                    fallback_calls: std::sync::atomic::AtomicUsize::new(0),
4557                }
4558            }
4559
4560            fn fallback_call_count(&self) -> usize {
4561                self.fallback_calls
4562                    .load(std::sync::atomic::Ordering::SeqCst)
4563            }
4564        }
4565
4566        impl Registry for FixedListRegistry {
4567            fn get_versions<'a>(
4568                &'a self,
4569                _name: &'a PackageName,
4570            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4571            {
4572                let versions = self.versions.clone();
4573                Box::pin(async move {
4574                    Ok(versions
4575                        .into_iter()
4576                        .map(|v| Box::new(MockVersion::new(v)) as Box<dyn Version>)
4577                        .collect())
4578                })
4579            }
4580
4581            fn get_latest_matching<'a>(
4582                &'a self,
4583                _name: &'a PackageName,
4584                _req: &'a VersionReq,
4585                _selection_context: &'a SelectionContext,
4586            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4587            {
4588                // Exercised only if the list-based pick finds nothing — not GOSSIP-aware,
4589                // exactly like the real `get_latest_matching_from` endpoints this models.
4590                self.fallback_calls
4591                    .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4592                let fallback = self.fallback;
4593                Box::pin(async move {
4594                    Ok(fallback.map(|v| Box::new(MockVersion::new(v)) as Box<dyn Version>))
4595                })
4596            }
4597
4598            fn search_raw<'a>(
4599                &'a self,
4600                _query: &'a str,
4601                _limit: usize,
4602            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4603            {
4604                Box::pin(async move { Ok(vec![]) })
4605            }
4606
4607            fn select_latest_matching(
4608                &self,
4609                versions: &[Box<dyn Version>],
4610                _req: &VersionReq,
4611                _selection_context: &SelectionContext,
4612            ) -> Option<usize> {
4613                if self.reject_prerelease {
4614                    versions
4615                        .iter()
4616                        .position(|v| !v.version_string().as_str().contains('-'))
4617                } else if versions.is_empty() {
4618                    None
4619                } else {
4620                    // Mirrors every real registry's contract: the list is already
4621                    // newest-first, so the first surviving entry (post-GOSSIP-filter) is
4622                    // "latest".
4623                    Some(0)
4624                }
4625            }
4626
4627            fn as_any(&self) -> &dyn Any {
4628                self
4629            }
4630        }
4631
4632        fn active_cooldown() -> GossipCooldown {
4633            GossipCooldown::new(
4634                PublishTime::from_unix_secs(i64::MAX / 2),
4635                GossipRiskLevel::High,
4636            )
4637        }
4638
4639        fn expired_cooldown() -> GossipCooldown {
4640            GossipCooldown::new(PublishTime::from_unix_secs(1), GossipRiskLevel::High)
4641        }
4642
4643        async fn fetch_pkg(
4644            registry: Arc<FixedListRegistry>,
4645            in_use: Vec<&'static str>,
4646            gossip: HashMap<PackageName, deps_core::GossipFindings>,
4647        ) -> Option<PackageVersions> {
4648            let registry: Arc<dyn Registry> = registry;
4649            let mut in_use_map = HashMap::new();
4650            if !in_use.is_empty() {
4651                in_use_map.insert(
4652                    PackageName::new("pkg"),
4653                    in_use.into_iter().map(ConcreteVersion::from).collect(),
4654                );
4655            }
4656            let result = fetch_latest_versions_parallel(
4657                registry,
4658                with_registry_source(vec![PackageName::new("pkg")]),
4659                &in_use_map,
4660                None,
4661                deps_core::freshness::FreshnessSettings::default(),
4662                5,
4663                10,
4664                &SelectionContext::none(),
4665                Some(&gossip),
4666            )
4667            .await;
4668            result.versions.get(&PackageName::new("pkg")).cloned()
4669        }
4670
4671        /// GOSSIP-only exclusion, floor-protected (round 2): a real in-use version (`1.0.0`)
4672        /// provides the protect floor, the newer registry-latest (`2.0.0`) is flagged with an
4673        /// active cooldown, so the floor's own version wins, and the attribution field names
4674        /// the excluded version (FR-005). The minimal 2-entry complement to
4675        /// `safe_intermediate_release_above_the_floor_is_picked`'s 3-entry case.
4676        #[tokio::test]
4677        async fn active_cooldown_on_the_latest_version_excludes_it() {
4678            let mut gossip = HashMap::new();
4679            gossip.insert(
4680                PackageName::new("pkg"),
4681                stub_gossip_findings("2.0.0", Some(active_cooldown())),
4682            );
4683
4684            let versions = fetch_pkg(
4685                Arc::new(FixedListRegistry::new(vec!["2.0.0", "1.0.0"])),
4686                vec!["1.0.0"],
4687                gossip,
4688            )
4689            .await
4690            .expect("pkg must resolve");
4691
4692            assert_eq!(versions.latest.as_str(), "1.0.0");
4693            assert_eq!(
4694                versions
4695                    .gossip_excluded_version
4696                    .as_ref()
4697                    .map(ConcreteVersion::as_str),
4698                Some("2.0.0")
4699            );
4700        }
4701
4702        /// Neither: no GOSSIP finding for this package at all — a no-op.
4703        #[tokio::test]
4704        async fn no_finding_for_package_is_a_no_op() {
4705            let versions = fetch_pkg(
4706                Arc::new(FixedListRegistry::new(vec!["2.0.0", "1.0.0"])),
4707                vec![],
4708                HashMap::new(),
4709            )
4710            .await
4711            .expect("pkg must resolve");
4712
4713            assert_eq!(versions.latest.as_str(), "2.0.0");
4714            assert!(versions.gossip_excluded_version.is_none());
4715        }
4716
4717        /// A finding with no cooldown at all (e.g. only a `low_usage` signal) is a no-op —
4718        /// mirrors [`GossipCooldown::is_active`]'s contract of only ever gating on a `Some`
4719        /// cooldown.
4720        #[tokio::test]
4721        async fn finding_without_a_cooldown_is_a_no_op() {
4722            let mut gossip = HashMap::new();
4723            gossip.insert(PackageName::new("pkg"), stub_gossip_findings("2.0.0", None));
4724
4725            let versions = fetch_pkg(
4726                Arc::new(FixedListRegistry::new(vec!["2.0.0", "1.0.0"])),
4727                vec![],
4728                gossip,
4729            )
4730            .await
4731            .expect("pkg must resolve");
4732
4733            assert_eq!(versions.latest.as_str(), "2.0.0");
4734            assert!(versions.gossip_excluded_version.is_none());
4735        }
4736
4737        /// An expired cooldown (`end` in the past) is a no-op — `GossipCooldown::is_active`
4738        /// returns `false`.
4739        #[tokio::test]
4740        async fn expired_cooldown_is_a_no_op() {
4741            let mut gossip = HashMap::new();
4742            gossip.insert(
4743                PackageName::new("pkg"),
4744                stub_gossip_findings("2.0.0", Some(expired_cooldown())),
4745            );
4746
4747            let versions = fetch_pkg(
4748                Arc::new(FixedListRegistry::new(vec!["2.0.0", "1.0.0"])),
4749                vec![],
4750                gossip,
4751            )
4752            .await
4753            .expect("pkg must resolve");
4754
4755            assert_eq!(versions.latest.as_str(), "2.0.0");
4756            assert!(versions.gossip_excluded_version.is_none());
4757        }
4758
4759        /// Version-string mismatch: GOSSIP's flagged version isn't in this registry's list at
4760        /// all (deps.dev's view of "latest" lagging or leading the registry) — treated as no
4761        /// signal (spec 074 edge case table), never a fuzzy fallback match.
4762        #[tokio::test]
4763        async fn version_string_mismatch_is_a_no_op() {
4764            let mut gossip = HashMap::new();
4765            gossip.insert(
4766                PackageName::new("pkg"),
4767                stub_gossip_findings("3.0.0", Some(active_cooldown())),
4768            );
4769
4770            let versions = fetch_pkg(
4771                Arc::new(FixedListRegistry::new(vec!["2.0.0", "1.0.0"])),
4772                vec![],
4773                gossip,
4774            )
4775            .await
4776            .expect("pkg must resolve");
4777
4778            assert_eq!(versions.latest.as_str(), "2.0.0");
4779            assert!(versions.gossip_excluded_version.is_none());
4780        }
4781
4782        /// **C1 regression** (spec 074 round-1 critique): the in-use/already-declared version
4783        /// is itself the one GOSSIP flags, and it is also the true registry-latest. The
4784        /// protect floor covers this version's own position, so the exclusion is fully
4785        /// neutralized — the pick must stay exactly what it already was, `deps-cli update`
4786        /// must never see a downgrade target, and no attribution is set (nothing was actually
4787        /// held back).
4788        #[tokio::test]
4789        async fn in_use_version_itself_flagged_neutralizes_the_exclusion() {
4790            let mut gossip = HashMap::new();
4791            gossip.insert(
4792                PackageName::new("pkg"),
4793                stub_gossip_findings("2.0.0", Some(active_cooldown())),
4794            );
4795
4796            let versions = fetch_pkg(
4797                Arc::new(FixedListRegistry::new(vec!["2.0.0", "1.0.0"])),
4798                vec!["2.0.0"],
4799                gossip,
4800            )
4801            .await
4802            .expect("pkg must resolve");
4803
4804            assert_eq!(
4805                versions.latest.as_str(),
4806                "2.0.0",
4807                "must never regress below the already-declared/in-use version"
4808            );
4809            assert!(
4810                versions.gossip_excluded_version.is_none(),
4811                "the floor neutralized the exclusion — nothing was actually held back"
4812            );
4813        }
4814
4815        /// **T2** (spec 074 round-1 edge case table): the in-use version is older and safe,
4816        /// but GOSSIP flags only the newest release, while a safe intermediate release exists
4817        /// above the protect floor. The flagged release alone is excluded; the safe
4818        /// intermediate release is picked, never the in-use version itself (this is forward
4819        /// progress, not a no-op).
4820        #[tokio::test]
4821        async fn safe_intermediate_release_above_the_floor_is_picked() {
4822            let mut gossip = HashMap::new();
4823            gossip.insert(
4824                PackageName::new("pkg"),
4825                stub_gossip_findings("3.0.0", Some(active_cooldown())),
4826            );
4827
4828            let versions = fetch_pkg(
4829                Arc::new(FixedListRegistry::new(vec!["3.0.0", "2.0.0", "1.0.0"])),
4830                vec!["1.0.0"],
4831                gossip,
4832            )
4833            .await
4834            .expect("pkg must resolve");
4835
4836            assert_eq!(
4837                versions.latest.as_str(),
4838                "2.0.0",
4839                "the safe intermediate release must win, not the flagged 3.0.0 nor a regression to the in-use 1.0.0"
4840            );
4841            assert_eq!(
4842                versions
4843                    .gossip_excluded_version
4844                    .as_ref()
4845                    .map(ConcreteVersion::as_str),
4846                Some("3.0.0")
4847            );
4848        }
4849
4850        /// **C1b** (spec 074 round 2): no in-use version is found in the fetched list at all —
4851        /// the common case for a range requirement with no lockfile (a fresh dependency add,
4852        /// Cargo's `AlwaysRange` policy, an unlocked npm/PyPI range) — and GOSSIP flags the
4853        /// sole list-based candidate. There is no floor to construct any protection from, so
4854        /// round 2 makes this a **deliberate no-op**: GOSSIP excludes nothing this fetch, the
4855        /// unfiltered pick is used exactly as it would be without this feature, and the
4856        /// network fallback (`get_latest_matching_from`) is never even invoked — this
4857        /// replaces round 1's "documented residual fallback-bypass" premise, which
4858        /// independent re-verification found was actually the common case, not a rare edge.
4859        ///
4860        /// This test hand-feeds an empty `in_use` to [`fetch_pkg`] rather than going through
4861        /// the real `prepare_fetch`/`collect_in_use_versions` path (`classify/resolved.rs`) —
4862        /// that upstream function's own, already-existing test suite
4863        /// (`collect_in_use_versions_skips_non_concrete_requirement_with_no_lockfile`,
4864        /// `classify/osv.rs`) independently proves it returns an empty map for exactly this
4865        /// scenario (Cargo `AlwaysRange`, a bare/range requirement, no lockfile). Composed
4866        /// together, the two suites cover the full C1b path end to end without needing a
4867        /// third, heavier integration test through a real ecosystem parser — flagged in the
4868        /// handoff in case an integration test is still wanted for extra confidence.
4869        #[tokio::test]
4870        async fn no_in_use_version_at_all_is_a_deliberate_no_op() {
4871            let mut gossip = HashMap::new();
4872            gossip.insert(
4873                PackageName::new("pkg"),
4874                stub_gossip_findings("2.0.0", Some(active_cooldown())),
4875            );
4876
4877            let registry = Arc::new(FixedListRegistry::with_fallback(vec!["2.0.0"], "2.0.0"));
4878            let versions = fetch_pkg(Arc::clone(&registry), vec![], gossip)
4879                .await
4880                .expect("pkg must resolve from the unfiltered list-based pick");
4881
4882            assert_eq!(
4883                versions.latest.as_str(),
4884                "2.0.0",
4885                "no floor exists, so GOSSIP must not exclude anything this run"
4886            );
4887            assert!(
4888                versions.gossip_excluded_version.is_none(),
4889                "nothing was actually excluded — no attribution"
4890            );
4891            assert_eq!(
4892                registry.fallback_call_count(),
4893                0,
4894                "the network fallback must never be invoked as a consequence of GOSSIP's own \
4895                 filtering when the unfiltered list-based pick already succeeded"
4896            );
4897        }
4898
4899        /// **S1, round 2**: a real protect floor exists (in-use `1.0.0-rc.1`), but after
4900        /// filtering out the flagged stable release above it, only a prerelease remains —
4901        /// which this mock registry's own selection rules (mirroring Go's
4902        /// `select_latest_matching_impl` refusing to pick a prerelease as "latest") reject.
4903        /// The filtered pick therefore comes back `None` for a reason unrelated to "no floor"
4904        /// at all. THE SYSTEM must fall back to the unfiltered pick, set no attribution, and
4905        /// never invoke the network fallback on GOSSIP's account.
4906        #[tokio::test]
4907        async fn floor_exists_but_ecosystem_selection_rejects_the_remainder_is_a_no_op() {
4908            let mut gossip = HashMap::new();
4909            gossip.insert(
4910                PackageName::new("pkg"),
4911                stub_gossip_findings("1.0.0", Some(active_cooldown())),
4912            );
4913
4914            let registry = Arc::new(FixedListRegistry::with_prerelease_rejection(vec![
4915                "1.0.0",
4916                "1.0.0-rc.1",
4917            ]));
4918            let versions = fetch_pkg(Arc::clone(&registry), vec!["1.0.0-rc.1"], gossip)
4919                .await
4920                .expect("pkg must resolve via the recovered unfiltered pick");
4921
4922            assert_eq!(
4923                versions.latest.as_str(),
4924                "1.0.0",
4925                "the ecosystem rejected the filtered remainder (an all-prerelease set), so the \
4926                 unfiltered pick must be used instead"
4927            );
4928            assert!(
4929                versions.gossip_excluded_version.is_none(),
4930                "nothing was actually excluded from the final pick — no attribution"
4931            );
4932            assert_eq!(
4933                registry.fallback_call_count(),
4934                0,
4935                "the network fallback must never be invoked as a consequence of GOSSIP's own \
4936                 filtering — only when the unfiltered pick itself finds nothing, which it did not"
4937            );
4938        }
4939
4940        /// **S4** (spec 074 round 3): the floor itself survives filtering (its own position
4941        /// always satisfies `idx >= floor`) but is rejected by the ecosystem's own selection
4942        /// rules (an in-use prerelease), and an even-older stable release exists below it. A
4943        /// filtered pick that lands there would be a genuine downgrade below the floor —
4944        /// distinct from `floor_exists_but_ecosystem_selection_rejects_the_remainder_is_a_no_op`,
4945        /// where filtering leaves nothing selectable at all. THE SYSTEM must treat the floor as
4946        /// a hard lower bound on the *final* pick, not merely on what gets excluded: recover
4947        /// the unfiltered pick instead of ever accepting a pick older than the floor.
4948        #[tokio::test]
4949        async fn filtered_pick_below_the_floor_is_rejected_in_favor_of_the_unfiltered_pick() {
4950            let mut gossip = HashMap::new();
4951            gossip.insert(
4952                PackageName::new("pkg"),
4953                stub_gossip_findings("1.0.0", Some(active_cooldown())),
4954            );
4955
4956            let registry = Arc::new(FixedListRegistry::with_prerelease_rejection(vec![
4957                "1.0.0",
4958                "1.0.0-rc.1",
4959                "0.9.0",
4960            ]));
4961            let versions = fetch_pkg(Arc::clone(&registry), vec!["1.0.0-rc.1"], gossip)
4962                .await
4963                .expect("pkg must resolve via the recovered unfiltered pick");
4964
4965            assert_eq!(
4966                versions.latest.as_str(),
4967                "1.0.0",
4968                "must never regress to 0.9.0 — a downgrade below the floor (1.0.0-rc.1) — even \
4969                 though 0.9.0 is a legitimate, ecosystem-selectable filtered pick"
4970            );
4971            assert!(
4972                versions.gossip_excluded_version.is_none(),
4973                "no attribution — the recovered pick is identical to the unfiltered one"
4974            );
4975            assert_eq!(
4976                registry.fallback_call_count(),
4977                0,
4978                "the network fallback must never be invoked as a consequence of GOSSIP's own filtering"
4979            );
4980        }
4981    }
4982
4983    /// Spec 075 (`deps-cli update` cooldown fallback): `PackageVersions::cooldown_fallback`
4984    /// computation (FR-001/FR-002), tested through the same `fetch_latest_versions_parallel`
4985    /// entry point `gossip_cooldown_filter_tests` uses for its sibling spec 074 feature.
4986    mod cooldown_fallback_tests {
4987        use super::*;
4988        use deps_core::test_util::MockVersion;
4989        use deps_core::{PublishTime, Registry, Version};
4990        use std::any::Any;
4991
4992        /// A fixed, newest-first version list — `select_latest_matching` mirrors a real
4993        /// ecosystem's own selection rules (skip yanked/prerelease), same contract
4994        /// `gossip_cooldown_filter_tests::FixedListRegistry` documents for its own mock.
4995        struct FixedRegistry(Vec<MockVersion>);
4996
4997        impl Registry for FixedRegistry {
4998            fn get_versions<'a>(
4999                &'a self,
5000                _name: &'a PackageName,
5001            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
5002            {
5003                let versions: Vec<Box<dyn Version>> = self
5004                    .0
5005                    .iter()
5006                    .cloned()
5007                    .map(|v| Box::new(v) as Box<dyn Version>)
5008                    .collect();
5009                Box::pin(async move { Ok(versions) })
5010            }
5011
5012            fn get_latest_matching<'a>(
5013                &'a self,
5014                _name: &'a PackageName,
5015                _req: &'a VersionReq,
5016                _selection_context: &'a SelectionContext,
5017            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
5018            {
5019                Box::pin(async move { Ok(None) })
5020            }
5021
5022            fn search_raw<'a>(
5023                &'a self,
5024                _query: &'a str,
5025                _limit: usize,
5026            ) -> deps_core::ecosystem::BoxFuture<
5027                'a,
5028                deps_core::Result<Vec<Box<dyn deps_core::Metadata>>>,
5029            > {
5030                Box::pin(async move { Ok(vec![]) })
5031            }
5032
5033            fn select_latest_matching(
5034                &self,
5035                versions: &[Box<dyn Version>],
5036                _req: &VersionReq,
5037                _selection_context: &SelectionContext,
5038            ) -> Option<usize> {
5039                versions
5040                    .iter()
5041                    .position(|v| !v.removal_status().blocks_resolution() && !v.is_prerelease())
5042            }
5043
5044            fn as_any(&self) -> &dyn Any {
5045                self
5046            }
5047        }
5048
5049        async fn fetch_pkg(
5050            versions: Vec<MockVersion>,
5051            in_use: Vec<&'static str>,
5052            cooldown_secs: u64,
5053        ) -> Option<PackageVersions> {
5054            fetch_pkg_with_registry(Arc::new(FixedRegistry(versions)), in_use, cooldown_secs).await
5055        }
5056
5057        /// Shared by [`fetch_pkg`] and the finding-4 regression test below, which needs a
5058        /// registry other than [`FixedRegistry`].
5059        async fn fetch_pkg_with_registry(
5060            registry: Arc<dyn Registry>,
5061            in_use: Vec<&'static str>,
5062            cooldown_secs: u64,
5063        ) -> Option<PackageVersions> {
5064            let mut in_use_map = HashMap::new();
5065            if !in_use.is_empty() {
5066                in_use_map.insert(
5067                    PackageName::new("pkg"),
5068                    in_use.into_iter().map(ConcreteVersion::from).collect(),
5069                );
5070            }
5071            let result = fetch_latest_versions_parallel(
5072                registry,
5073                with_registry_source(vec![PackageName::new("pkg")]),
5074                &in_use_map,
5075                None,
5076                deps_core::freshness::FreshnessSettings::Enabled {
5077                    cooldown: deps_core::CooldownWindow::from_secs(cooldown_secs),
5078                },
5079                5,
5080                10,
5081                &SelectionContext::none(),
5082                None,
5083            )
5084            .await;
5085            result.versions.get(&PackageName::new("pkg")).cloned()
5086        }
5087
5088        /// A registry whose list-based `select_latest_matching` rejects the ACTUAL fetched
5089        /// [`Version`] objects it's asked to pick from (mirroring Go's real `/@v/list`-vs-
5090        /// `/@latest` split, issue #1551 finding 4's tester regression): the list-based pick can
5091        /// find nothing over the genuine fetched entries even though a cooldown-cleared,
5092        /// ecosystem-safe candidate exists among them, and the network `get_latest_matching`
5093        /// fallback still resolves a real `latest`.
5094        ///
5095        /// `compute_cooldown_fallback`'s own cleared-subset search re-ranks through synthetic
5096        /// `CooldownCandidate` values (this file's own type, never [`MockVersion`]), so this
5097        /// mock discriminates by concrete type via `as_any()` — refusing outright whenever ANY
5098        /// candidate downcasts to [`MockVersion`] (the full, unfiltered list), falling back to
5099        /// the ordinary yanked/prerelease rule otherwise (the re-ranked cleared subset) — to
5100        /// reproduce the real divergence without depending on `deps-go`'s own internals.
5101        struct NoListPickRegistry(Vec<MockVersion>);
5102
5103        impl Registry for NoListPickRegistry {
5104            fn get_versions<'a>(
5105                &'a self,
5106                _name: &'a PackageName,
5107            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
5108            {
5109                let versions: Vec<Box<dyn Version>> = self
5110                    .0
5111                    .iter()
5112                    .cloned()
5113                    .map(|v| Box::new(v) as Box<dyn Version>)
5114                    .collect();
5115                Box::pin(async move { Ok(versions) })
5116            }
5117
5118            fn get_latest_matching<'a>(
5119                &'a self,
5120                _name: &'a PackageName,
5121                _req: &'a VersionReq,
5122                _selection_context: &'a SelectionContext,
5123            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
5124            {
5125                let resolved = self.0.first().cloned();
5126                Box::pin(async move { Ok(resolved.map(|v| Box::new(v) as Box<dyn Version>)) })
5127            }
5128
5129            fn search_raw<'a>(
5130                &'a self,
5131                _query: &'a str,
5132                _limit: usize,
5133            ) -> deps_core::ecosystem::BoxFuture<
5134                'a,
5135                deps_core::Result<Vec<Box<dyn deps_core::Metadata>>>,
5136            > {
5137                Box::pin(async move { Ok(vec![]) })
5138            }
5139
5140            fn select_latest_matching(
5141                &self,
5142                versions: &[Box<dyn Version>],
5143                _req: &VersionReq,
5144                _selection_context: &SelectionContext,
5145            ) -> Option<usize> {
5146                if versions
5147                    .iter()
5148                    .any(|v| v.as_any().downcast_ref::<MockVersion>().is_some())
5149                {
5150                    return None;
5151                }
5152                versions
5153                    .iter()
5154                    .position(|v| !v.removal_status().blocks_resolution() && !v.is_prerelease())
5155            }
5156
5157            fn as_any(&self) -> &dyn Any {
5158                self
5159            }
5160        }
5161
5162        /// Spec 075 SC-003/FR-001 (S1 repro): the newest cooldown-cleared candidate (1.1.0) is
5163        /// yanked — the ecosystem-safety guard rejects it with no retry down to a further,
5164        /// also-cooldown-cleared candidate (1.0.0, itself yanked and the in-use floor).
5165        #[tokio::test]
5166        async fn yanked_top_ranked_cleared_candidate_yields_no_fallback() {
5167            let now = PublishTime::now();
5168            let cooldown_secs = 3 * 24 * 60 * 60;
5169            let recent = PublishTime::from_unix_secs(now.as_unix_secs() - 60); // within cooldown
5170            let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60); // cleared
5171
5172            let versions = vec![
5173                MockVersion::new("1.2.0").with_published_at(recent),
5174                MockVersion::new("1.1.0")
5175                    .with_published_at(old)
5176                    .yanked(true),
5177                MockVersion::new("1.0.0")
5178                    .with_published_at(old)
5179                    .yanked(true),
5180            ];
5181
5182            let package_versions = fetch_pkg(versions, vec!["1.0.0"], cooldown_secs)
5183                .await
5184                .expect("pkg must resolve");
5185
5186            assert!(
5187                package_versions.cooldown_fallback.is_none(),
5188                "a yanked top-ranked cooldown-cleared candidate must not be recovered by \
5189                 retrying further down the list: {:?}",
5190                package_versions.cooldown_fallback
5191            );
5192        }
5193
5194        /// The positive case sanity check: with the same shape but the newest cleared
5195        /// candidate NOT yanked, a fallback is computed.
5196        #[tokio::test]
5197        async fn cleared_safe_candidate_above_the_floor_is_the_fallback() {
5198            let now = PublishTime::now();
5199            let cooldown_secs = 3 * 24 * 60 * 60;
5200            let recent = PublishTime::from_unix_secs(now.as_unix_secs() - 60);
5201            let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60);
5202
5203            let versions = vec![
5204                MockVersion::new("1.2.0").with_published_at(recent),
5205                MockVersion::new("1.1.0").with_published_at(old),
5206                MockVersion::new("1.0.0").with_published_at(old),
5207            ];
5208
5209            let package_versions = fetch_pkg(versions, vec!["1.0.0"], cooldown_secs)
5210                .await
5211                .expect("pkg must resolve");
5212
5213            let fallback = package_versions
5214                .cooldown_fallback
5215                .expect("a safe, cooldown-cleared, above-floor candidate must be recovered");
5216            assert_eq!(fallback.version.as_str(), "1.1.0");
5217        }
5218
5219        /// Issue #1564 repro (crates.io `bevy_brp_mcp`-shaped): a prerelease newest (excluded),
5220        /// a within-cooldown stable `latest` candidate, a cleared candidate one patch below it,
5221        /// and the in-use floor. Empirically confirms/refutes the reporter's hypothesis that
5222        /// `compute_cooldown_fallback` itself never finds a fallback here — it does; the actual
5223        /// bug (fixed separately) was downstream in `deps-cli update`'s own
5224        /// `fallback_satisfies_requirement` planner guard, not this engine-level computation.
5225        #[tokio::test]
5226        async fn permissive_range_repro_1564_still_computes_the_cleared_fallback() {
5227            let now = PublishTime::now();
5228            let cooldown_secs = 3 * 24 * 60 * 60;
5229            let recent = PublishTime::from_unix_secs(now.as_unix_secs() - 60); // within cooldown
5230            let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60); // cleared
5231
5232            let versions = vec![
5233                MockVersion::new("0.23.0-rc.1")
5234                    .with_published_at(recent)
5235                    .with_prerelease(true),
5236                MockVersion::new("0.22.8").with_published_at(recent),
5237                MockVersion::new("0.22.7").with_published_at(old),
5238                MockVersion::new("0.22.6").with_published_at(old), // in-use floor
5239            ];
5240
5241            let package_versions = fetch_pkg(versions, vec!["0.22.6"], cooldown_secs)
5242                .await
5243                .expect("pkg must resolve");
5244
5245            let fallback = package_versions
5246                .cooldown_fallback
5247                .expect("a safe, cooldown-cleared, above-floor candidate must be recovered");
5248            assert_eq!(fallback.version.as_str(), "0.22.7");
5249        }
5250
5251        /// Fix-cycle item 5/S5: the newest cooldown-cleared candidate by publish date is a
5252        /// prerelease (a canary/nightly a frequent publisher ships between stable releases).
5253        /// Ranking through `registry.select_latest_matching` over the cooled subset — instead
5254        /// of the old "first cleared entry by raw list position, no retry" — naturally skips
5255        /// it and lands on the next cooled, stable release, closing the exact starvation
5256        /// scenario FR-001 exists to prevent (a naive by-date pick would have rejected the
5257        /// prerelease with no retry and returned `None`).
5258        #[tokio::test]
5259        async fn prerelease_top_ranked_by_date_is_skipped_for_the_next_cooled_stable_release() {
5260            let now = PublishTime::now();
5261            let cooldown_secs = 3 * 24 * 60 * 60;
5262            let recent = PublishTime::from_unix_secs(now.as_unix_secs() - 60);
5263            let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60);
5264            let older = PublishTime::from_unix_secs(now.as_unix_secs() - 31 * 24 * 60 * 60);
5265
5266            let versions = vec![
5267                MockVersion::new("2.0.0").with_published_at(recent), // fresh, not cooled
5268                MockVersion::new("2.0.0-rc.1")
5269                    .with_published_at(old)
5270                    .with_prerelease(true), // cooled, but a prerelease
5271                MockVersion::new("1.9.0").with_published_at(older),  // cooled, stable
5272                MockVersion::new("1.8.0").with_published_at(older),  // in-use floor
5273            ];
5274
5275            let package_versions = fetch_pkg(versions, vec!["1.8.0"], cooldown_secs)
5276                .await
5277                .expect("pkg must resolve");
5278
5279            let fallback = package_versions.cooldown_fallback.expect(
5280                "the prerelease must be skipped in favor of the next cooled stable release",
5281            );
5282            assert_eq!(fallback.version.as_str(), "1.9.0");
5283        }
5284
5285        /// Spec 076 FR-017/FR-018 (inverts spec 075's
5286        /// `no_in_use_version_yields_no_fallback_even_with_a_safe_cleared_candidate`, SC-021):
5287        /// no lockfile-resolved in-use version now classifies as `InUseFloor::Absent`, which
5288        /// computes the fallback with NO positional floor rather than yielding `None` — the
5289        /// #1544 fix this spec exists to deliver. The engine-level candidate is unconditional;
5290        /// `deps-cli`'s `fallback_edit_excludes_newer` guard (spec 076 FR-023), not this floor,
5291        /// is what fails closed for an auto-following ecosystem's in-range case.
5292        #[tokio::test]
5293        async fn no_in_use_version_yields_a_fallback_when_a_safe_cleared_candidate_exists() {
5294            let now = PublishTime::now();
5295            let cooldown_secs = 3 * 24 * 60 * 60;
5296            let recent = PublishTime::from_unix_secs(now.as_unix_secs() - 60);
5297            let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60);
5298
5299            let versions = vec![
5300                MockVersion::new("1.2.0").with_published_at(recent),
5301                MockVersion::new("1.1.0").with_published_at(old),
5302            ];
5303
5304            let package_versions = fetch_pkg(versions, Vec::new(), cooldown_secs)
5305                .await
5306                .expect("pkg must resolve");
5307
5308            let fallback = package_versions
5309                .cooldown_fallback
5310                .expect("Absent floor must no longer suppress a safe, cleared candidate");
5311            assert_eq!(fallback.version.as_str(), "1.1.0");
5312        }
5313
5314        /// Spec 076 FR-018/SC-010 (round-1 critic M2): a partial in-use-version match — one
5315        /// entry locatable in `versions`, one not (e.g. a mixed Go pseudo-version alongside a
5316        /// resolvable one) — classifies as `InUseFloor::Unlocatable`, which yields NO fallback
5317        /// at all, stricter than the prior `.min()?` behavior that silently floored at only the
5318        /// locatable entry and ignored the unplaceable one.
5319        #[tokio::test]
5320        async fn partial_in_use_version_match_yields_no_fallback() {
5321            let now = PublishTime::now();
5322            let cooldown_secs = 3 * 24 * 60 * 60;
5323            let recent = PublishTime::from_unix_secs(now.as_unix_secs() - 60);
5324            let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60);
5325
5326            let versions = vec![
5327                MockVersion::new("1.2.0").with_published_at(recent),
5328                MockVersion::new("1.1.0").with_published_at(old),
5329            ];
5330
5331            let package_versions = fetch_pkg(
5332                versions,
5333                vec!["1.1.0", "not-a-resolvable-pseudo-version"],
5334                cooldown_secs,
5335            )
5336            .await
5337            .expect("pkg must resolve");
5338
5339            assert!(
5340                package_versions.cooldown_fallback.is_none(),
5341                "a partial in-use-version match (one locatable, one not) must fail closed, not \
5342                 silently floor at the locatable entry: {:?}",
5343                package_versions.cooldown_fallback
5344            );
5345        }
5346
5347        /// Spec 076 SC-018/M5 (fix-cycle, tester gap): a non-normalized lockfile-resolved
5348        /// in-use pin (e.g. a bare `1.0` against the registry's own `1.0.0` spelling) fails
5349        /// string equality in `in_use_floor`, landing in `InUseFloor::Unlocatable` — no
5350        /// fallback, fail closed. `in_use_floor` compares raw version strings and has no
5351        /// ecosystem-specific normalization step, so this single test proves the mechanism for
5352        /// every `Concrete`-policy ecosystem's non-normalized-pin case uniformly (spec 076
5353        /// SC-018's per-ecosystem requirement is satisfied by construction here, not by
5354        /// duplicating this fixture 7 times — `InUseFloor` is `deps-engine`-private and never
5355        /// sees which ecosystem produced the in-use string).
5356        #[tokio::test]
5357        async fn non_normalized_in_use_pin_yields_no_fallback() {
5358            let now = PublishTime::now();
5359            let cooldown_secs = 3 * 24 * 60 * 60;
5360            let recent = PublishTime::from_unix_secs(now.as_unix_secs() - 60);
5361            let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60);
5362
5363            let versions = vec![
5364                MockVersion::new("1.2.0").with_published_at(recent),
5365                MockVersion::new("1.1.0").with_published_at(old),
5366                MockVersion::new("1.0.0").with_published_at(old),
5367            ];
5368
5369            // The lockfile/manifest pin is the non-normalized bare "1.0" — the registry's own
5370            // list spells the same release "1.0.0". `in_use_floor` does raw string equality,
5371            // so this never resolves to a position.
5372            let package_versions = fetch_pkg(versions, vec!["1.0"], cooldown_secs)
5373                .await
5374                .expect("pkg must resolve");
5375
5376            assert!(
5377                package_versions.cooldown_fallback.is_none(),
5378                "a non-normalized in-use pin must fail closed (Unlocatable), never silently \
5379                 treated as Absent or matched loosely: {:?}",
5380                package_versions.cooldown_fallback
5381            );
5382        }
5383
5384        /// Tester regression (issue #1551 finding 4): when the list-based pick finds nothing
5385        /// (`unfiltered_pick_idx` is `None`, e.g. Go's `/@v/list` never enumerating
5386        /// pseudo-versions) but the network `get_latest_matching` fallback still resolves a
5387        /// real `latest`, the full floor+cleared-candidates search must still run — a `None`
5388        /// pick must never be treated as "cleared" and short-circuit to no fallback.
5389        #[tokio::test]
5390        async fn unknown_list_based_pick_still_runs_the_full_fallback_search() {
5391            let now = PublishTime::now();
5392            let cooldown_secs = 3 * 24 * 60 * 60;
5393            let recent = PublishTime::from_unix_secs(now.as_unix_secs() - 60); // within cooldown
5394            let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60); // cleared
5395
5396            let versions = vec![
5397                MockVersion::new("1.2.0").with_published_at(recent),
5398                MockVersion::new("1.1.0").with_published_at(old),
5399                MockVersion::new("1.0.0").with_published_at(old),
5400            ];
5401
5402            let package_versions = fetch_pkg_with_registry(
5403                Arc::new(NoListPickRegistry(versions)),
5404                vec!["1.0.0"],
5405                cooldown_secs,
5406            )
5407            .await
5408            .expect("pkg must resolve via the get_latest_matching network fallback");
5409
5410            assert_eq!(
5411                package_versions.latest.as_str(),
5412                "1.2.0",
5413                "latest must come from the get_latest_matching fallback, not the list-based pick"
5414            );
5415            let fallback = package_versions.cooldown_fallback.expect(
5416                "an unknown list-based pick must still run the full search and find the \
5417                 cooldown-cleared, above-floor candidate",
5418            );
5419            assert_eq!(fallback.version.as_str(), "1.1.0");
5420        }
5421
5422        /// Spec 076 SC-012 (T001): confirms #1553's shipped fetch-time gate — a known
5423        /// unfiltered pick already `Cleared` by [`deps_core::lsp_helpers::cooldown_precedence`]
5424        /// skips the full fallback scan (`cooldown_fallback: None`), even though an older,
5425        /// separately cooldown-cleared candidate exists below it.
5426        #[tokio::test]
5427        async fn known_cleared_unfiltered_pick_yields_no_fallback() {
5428            let now = PublishTime::now();
5429            let cooldown_secs = 3 * 24 * 60 * 60;
5430            let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60); // cleared
5431
5432            // `latest` (1.2.0) is itself already cooldown-cleared, so the gate must skip the
5433            // scan entirely rather than compute a (redundant) fallback below it.
5434            let versions = vec![
5435                MockVersion::new("1.2.0").with_published_at(old),
5436                MockVersion::new("1.1.0").with_published_at(old),
5437            ];
5438
5439            let package_versions = fetch_pkg(versions, vec!["1.1.0"], cooldown_secs)
5440                .await
5441                .expect("pkg must resolve");
5442
5443            assert!(
5444                package_versions.cooldown_fallback.is_none(),
5445                "a known, already-cleared unfiltered pick must skip the fallback scan: {:?}",
5446                package_versions.cooldown_fallback
5447            );
5448        }
5449
5450        /// Spec 076 SC-013 (FR-021 gate-superset invariant, proof case "unfiltered pick is
5451        /// latest"): with `now_read == now_fetch` and an unchanged `cooldown_secs`, a fetch-time
5452        /// gate that saw `latest` as `Cleared` (no fallback stored) must never read back as
5453        /// `Blocked` — the fetch-time and read-time precedence share the same
5454        /// `cooldown_precedence` primitive and the same inputs, so they cannot diverge.
5455        #[tokio::test]
5456        async fn read_time_disposition_agrees_with_a_cleared_fetch_time_gate() {
5457            let now = PublishTime::now();
5458            let cooldown_secs = 3 * 24 * 60 * 60;
5459            let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60);
5460
5461            let versions = vec![
5462                MockVersion::new("1.2.0").with_published_at(old),
5463                MockVersion::new("1.1.0").with_published_at(old),
5464            ];
5465
5466            let package_versions = fetch_pkg(versions, vec!["1.1.0"], cooldown_secs)
5467                .await
5468                .expect("pkg must resolve");
5469            assert!(package_versions.cooldown_fallback.is_none());
5470
5471            let disposition = deps_core::lsp_helpers::cooldown_disposition(
5472                &package_versions,
5473                &PackageName::new("pkg"),
5474                deps_core::freshness::FreshnessSettings::Enabled {
5475                    cooldown: deps_core::CooldownWindow::from_secs(cooldown_secs),
5476                },
5477                None,
5478                now,
5479            );
5480            assert_eq!(
5481                disposition,
5482                deps_core::lsp_helpers::CooldownDisposition::Cleared,
5483                "read time must agree with the fetch-time gate under unchanged inputs: {disposition:?}"
5484            );
5485        }
5486
5487        /// Spec 076 SC-013 (FR-021's one permitted exception): a `cooldown_secs` narrowed
5488        /// between fetch and read can flip a fetch-time `Cleared` pick to read-time `Blocked` —
5489        /// but since the fetch-time gate skipped the scan, there is no stored fallback to
5490        /// unsafely surface. The outcome is a stricter skip, never an unsafe write.
5491        #[tokio::test]
5492        async fn narrowed_cooldown_window_between_fetch_and_read_yields_a_safe_skip_not_a_write() {
5493            let now = PublishTime::now();
5494            let fetch_cooldown_secs = 3 * 24 * 60 * 60;
5495            // Published 1 day ago: cleared under a 3-day window at fetch time, but still
5496            // within a narrowed 2-day window at read time.
5497            let one_day_ago = PublishTime::from_unix_secs(now.as_unix_secs() - 24 * 60 * 60);
5498
5499            let versions = vec![
5500                MockVersion::new("1.2.0").with_published_at(one_day_ago),
5501                MockVersion::new("1.1.0").with_published_at(one_day_ago),
5502            ];
5503
5504            let package_versions = fetch_pkg(versions, vec!["1.1.0"], fetch_cooldown_secs)
5505                .await
5506                .expect("pkg must resolve");
5507            assert!(
5508                package_versions.cooldown_fallback.is_none(),
5509                "the fetch-time gate saw latest as Cleared under the wider window, so no \
5510                 fallback was ever computed or stored"
5511            );
5512
5513            let narrowed_cooldown_secs = 2 * 24 * 60 * 60;
5514            let disposition = deps_core::lsp_helpers::cooldown_disposition(
5515                &package_versions,
5516                &PackageName::new("pkg"),
5517                deps_core::freshness::FreshnessSettings::Enabled {
5518                    cooldown: deps_core::CooldownWindow::from_secs(narrowed_cooldown_secs),
5519                },
5520                None,
5521                now,
5522            );
5523            match disposition {
5524                deps_core::lsp_helpers::CooldownDisposition::Blocked { fallback, .. } => {
5525                    assert!(
5526                        fallback.is_none(),
5527                        "a narrowed window must never surface a fallback the fetch-time gate \
5528                         never computed — that would be an unsafe write, not a stricter skip"
5529                    );
5530                }
5531                other => panic!("expected a stricter read-time Blocked skip, got {other:?}"),
5532            }
5533        }
5534
5535        /// Spec 076 SC-013 (FR-021 gate-superset invariant, proof case "`get_latest_matching_from`
5536        /// branch"): when the list-based pick is unknown and `latest` is resolved via the
5537        /// network fallback instead, the fallback candidate the fetch-time full scan stored
5538        /// still agrees with the read-time disposition under unchanged inputs.
5539        #[tokio::test]
5540        async fn read_time_disposition_agrees_with_the_network_fallback_branch() {
5541            let now = PublishTime::now();
5542            let cooldown_secs = 3 * 24 * 60 * 60;
5543            let recent = PublishTime::from_unix_secs(now.as_unix_secs() - 60);
5544            let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60);
5545
5546            let versions = vec![
5547                MockVersion::new("1.2.0").with_published_at(recent),
5548                MockVersion::new("1.1.0").with_published_at(old),
5549                MockVersion::new("1.0.0").with_published_at(old),
5550            ];
5551
5552            let package_versions = fetch_pkg_with_registry(
5553                Arc::new(NoListPickRegistry(versions)),
5554                vec!["1.0.0"],
5555                cooldown_secs,
5556            )
5557            .await
5558            .expect("pkg must resolve via the get_latest_matching network fallback");
5559
5560            let disposition = deps_core::lsp_helpers::cooldown_disposition(
5561                &package_versions,
5562                &PackageName::new("pkg"),
5563                deps_core::freshness::FreshnessSettings::Enabled {
5564                    cooldown: deps_core::CooldownWindow::from_secs(cooldown_secs),
5565                },
5566                None,
5567                now,
5568            );
5569            match disposition {
5570                deps_core::lsp_helpers::CooldownDisposition::Blocked {
5571                    fallback: Some(fallback),
5572                    ..
5573                } => {
5574                    assert_eq!(fallback.version.as_str(), "1.1.0");
5575                }
5576                other => panic!(
5577                    "expected read time to agree with the fetch-time-stored fallback, got {other:?}"
5578                ),
5579            }
5580        }
5581
5582        /// Fix-cycle (tester gap, SC-013's 3rd named proof case): the "spec-074-substituted-latest"
5583        /// branch — the unfiltered top pick is GOSSIP-`Active` (flagged), so `fetch_and_classify_package`
5584        /// substitutes a floor-protected, GOSSIP-cleared filtered pick as `latest` instead. Since the
5585        /// RAW unfiltered pick's `cooldown_precedence` is `Blocked(Gossip)`, SC-012's short-circuit never
5586        /// fires (`unfiltered_pick_flagged` implies not-cleared), so the fetch-time full scan always runs
5587        /// here — proven directly, not by code-reading alone: the substituted `latest` ("2.0.0") is
5588        /// itself locally within-cooldown (fresh, no GOSSIP finding of its own), and read-time
5589        /// `cooldown_disposition` evaluated against THAT substituted `latest` must agree with the
5590        /// fetch-time-computed fallback ("1.5.0", above the "1.0.0" floor).
5591        #[tokio::test]
5592        async fn read_time_disposition_agrees_with_the_gossip_substituted_latest_branch() {
5593            let now = PublishTime::now();
5594            let cooldown_secs = 3 * 24 * 60 * 60;
5595            let recent = PublishTime::from_unix_secs(now.as_unix_secs() - 60);
5596            let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60);
5597
5598            // Newest-first: "3.0.0" (GOSSIP-flagged, excluded), "2.0.0" (fresh, no GOSSIP finding,
5599            // locally within cooldown once substituted in as `latest`), "1.5.0" (cleared, the
5600            // expected fallback), "1.0.0" (the in-use floor).
5601            let versions = vec![
5602                MockVersion::new("3.0.0").with_published_at(recent),
5603                MockVersion::new("2.0.0").with_published_at(recent),
5604                MockVersion::new("1.5.0").with_published_at(old),
5605                MockVersion::new("1.0.0").with_published_at(old),
5606            ];
5607            let mut gossip = HashMap::new();
5608            gossip.insert(
5609                PackageName::new("pkg"),
5610                deps_core::test_util::stub_gossip_findings(
5611                    "3.0.0",
5612                    Some(deps_core::GossipCooldown::new(
5613                        PublishTime::from_unix_secs(i64::MAX / 2),
5614                        deps_core::GossipRiskLevel::High,
5615                    )),
5616                ),
5617            );
5618
5619            let registry: Arc<dyn Registry> = Arc::new(FixedRegistry(versions));
5620            let mut in_use_map = HashMap::new();
5621            in_use_map.insert(
5622                PackageName::new("pkg"),
5623                vec![ConcreteVersion::from("1.0.0")],
5624            );
5625            let result = fetch_latest_versions_parallel(
5626                registry,
5627                with_registry_source(vec![PackageName::new("pkg")]),
5628                &in_use_map,
5629                None,
5630                deps_core::freshness::FreshnessSettings::Enabled {
5631                    cooldown: deps_core::CooldownWindow::from_secs(cooldown_secs),
5632                },
5633                5,
5634                10,
5635                &SelectionContext::none(),
5636                Some(&gossip),
5637            )
5638            .await;
5639            let package_versions = result
5640                .versions
5641                .get(&PackageName::new("pkg"))
5642                .cloned()
5643                .expect("pkg must resolve");
5644
5645            assert_eq!(
5646                package_versions.latest.as_str(),
5647                "2.0.0",
5648                "the GOSSIP floor-protected filter must substitute the filtered pick as latest"
5649            );
5650            assert_eq!(
5651                package_versions
5652                    .gossip_excluded_version
5653                    .as_ref()
5654                    .map(ConcreteVersion::as_str),
5655                Some("3.0.0")
5656            );
5657            let fetch_time_fallback = package_versions
5658                .cooldown_fallback
5659                .as_ref()
5660                .expect("the full scan must have run (SC-012's short-circuit cannot fire here)");
5661            assert_eq!(fetch_time_fallback.version.as_str(), "1.5.0");
5662
5663            let disposition = deps_core::lsp_helpers::cooldown_disposition(
5664                &package_versions,
5665                &PackageName::new("pkg"),
5666                deps_core::freshness::FreshnessSettings::Enabled {
5667                    cooldown: deps_core::CooldownWindow::from_secs(cooldown_secs),
5668                },
5669                Some(&gossip),
5670                now,
5671            );
5672            match disposition {
5673                deps_core::lsp_helpers::CooldownDisposition::Blocked {
5674                    fallback: Some(fallback),
5675                    ..
5676                } => {
5677                    assert_eq!(
5678                        fallback.version.as_str(),
5679                        "1.5.0",
5680                        "read time, evaluated against the substituted latest, must agree with \
5681                         the fetch-time-computed fallback"
5682                    );
5683                }
5684                other => panic!(
5685                    "expected read time to agree with the fetch-time-stored fallback for the \
5686                     substituted latest, got {other:?}"
5687                ),
5688            }
5689        }
5690    }
5691}