Skip to main content

deps_lsp/document/
lifecycle.rs

1//! New simplified document lifecycle using ecosystem registry.
2//!
3//! This module provides unified open/change/close handlers that work with
4//! the ecosystem trait architecture, eliminating per-ecosystem duplication.
5
6use super::loader::{MAX_FILE_SIZE, load_document_from_disk};
7use super::state::{DocumentState, ServerState};
8use crate::config::DepsConfig;
9use crate::handlers::diagnostics;
10use crate::progress::{ProgressSender, RegistryProgress};
11use deps_core::ConcreteVersion;
12use deps_core::DependencyOutcomes;
13use deps_core::Deprecation;
14use deps_core::Ecosystem;
15use deps_core::EcosystemId;
16use deps_core::FetchFailure;
17use deps_core::PackageName;
18use deps_core::PackageVersions;
19use deps_core::Registry;
20use deps_core::RemovalStatus;
21use deps_core::Result;
22use deps_core::VersionReq;
23use deps_core::lsp_helpers::in_use_version;
24use std::collections::{HashMap, HashSet};
25use std::sync::Arc;
26use std::time::{Duration, Instant};
27use tokio::sync::RwLock;
28use tokio::task::JoinHandle;
29use tower_lsp_server::Client;
30use tower_lsp_server::ls_types::{MessageType, Uri};
31
32/// A dependency name paired with the resolved source to route its registry fetch through
33/// (spec FR-001), as built by [`dedup_dependencies_by_source`].
34type DepSources = Vec<(PackageName, deps_core::parser::DependencySource)>;
35
36/// Resolves the typed `EcosystemId` for an ecosystem trait object.
37///
38/// `ecosystem.id()` always originates from a statically registered ecosystem
39/// (see `crate::register_ecosystems`), so parsing it back to `EcosystemId` can
40/// only fail on an internal registration bug, not on user input.
41fn resolve_ecosystem_id(ecosystem: &dyn Ecosystem) -> EcosystemId {
42    ecosystem
43        .id()
44        .parse()
45        .expect("ecosystem.id() must be a registered EcosystemId")
46}
47
48/// Pairs each distinct dependency name in `parse_result` with the source its occurrence(s)
49/// resolve to, for the background registry fetch to route through
50/// `Registry::get_versions_from`/`get_latest_matching_from` (spec FR-001).
51///
52/// Two gates, applied in order:
53///
54/// 1. **Resolvability** (closes a review-flagged leak): a dependency whose source is not
55///    resolvable at all (`!formatter.can_resolve_source(source)` — Git, Path, an unresolved
56///    `CustomRegistry` alias, ...) is dropped from the result entirely, never reaching the
57///    fetch. Without this gate, `CargoRegistry`'s (and every other source-aware registry's)
58///    `_ =>` default-to-crates.io arm would silently look up a private/unresolvable name
59///    against the ecosystem's *public* registry — exactly the leak this feature's own
60///    hover/code-actions/diagnostics gating was built to close, just reached through the
61///    highest-traffic path (the background fetch feeding inlay hints and cached
62///    diagnostics) instead.
63/// 2. **Collision** (spec FR-011): when two occurrences of the same name both resolve
64///    (gate 1 passed for both) to two *different* sources — e.g. a genuine resolution bug
65///    producing two distinct index URLs for what should be one registry — both are dropped
66///    from the map and the name is added to the returned collision set instead of being
67///    fetched. The fetch result is shared across every occurrence of a name
68///    (`FetchResult::versions` is name-keyed), so silently picking a source here would
69///    silently apply it to occurrences whose author may have intended a different
70///    registry. A `tracing::warn!` names both resolved sources, using message text
71///    distinguishable from `deps-cargo`'s own FR-003 unresolved-alias warning.
72///
73/// Returns `(sources, collided)`: `sources` is ready to fetch as-is; `collided` must be
74/// merged into `DocumentState::outcomes`' fetch-failure channel by the caller so
75/// `generate_diagnostics_from_cache` reports "lookup could not be determined" rather than
76/// a false "Unknown package" for a dependency that was never actually queried.
77fn dedup_dependencies_by_source(
78    parse_result: &dyn deps_core::ParseResult,
79    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
80) -> (
81    HashMap<PackageName, deps_core::parser::DependencySource>,
82    HashSet<PackageName>,
83) {
84    use std::collections::hash_map::Entry;
85
86    let mut by_name: HashMap<PackageName, deps_core::parser::DependencySource> = HashMap::new();
87    let mut collided: HashSet<PackageName> = HashSet::new();
88
89    for dep in parse_result
90        .dependencies()
91        .into_iter()
92        .filter(|dep| formatter.can_resolve_source(&dep.source()))
93    {
94        let name = dep.name().clone();
95        let source = dep.source();
96        match by_name.entry(name.clone()) {
97            Entry::Vacant(entry) => {
98                entry.insert(source);
99            }
100            Entry::Occupied(entry) => {
101                if *entry.get() != source && collided.insert(name.clone()) {
102                    tracing::warn!(
103                        package = %name,
104                        source_a = ?entry.get(),
105                        source_b = ?source,
106                        "dependency declared against two different resolved registries; \
107                         skipping version resolution for all occurrences"
108                    );
109                }
110            }
111        }
112    }
113
114    for name in &collided {
115        by_name.remove(name);
116    }
117    (by_name, collided)
118}
119
120/// Composer's own `minimum-stability` manifest setting, when `parse_result` is a parsed
121/// `composer.json` (#424 S1).
122///
123/// Downcasts via [`deps_core::ParseResult::as_any`] rather than widening the generic
124/// `ParseResult`/`Registry` traits with an ecosystem-specific field: every other ecosystem has
125/// no equivalent manifest-level stability floor, so this stays local to the one call site
126/// (`fetch_latest_versions_parallel`'s caller) that needs to bridge a Composer-specific
127/// manifest value into the generic `Registry::*_with_context` trait hook.
128#[cfg(feature = "composer")]
129fn composer_minimum_stability(parse_result: &dyn deps_core::ParseResult) -> Option<String> {
130    parse_result
131        .as_any()
132        .downcast_ref::<crate::ComposerParseResult>()
133        .and_then(|r| r.minimum_stability.clone())
134}
135
136/// No-op when the `composer` feature is disabled — `crate::ComposerParseResult` does not
137/// exist in that build, so `parse_result` can never downcast to it.
138#[cfg(not(feature = "composer"))]
139fn composer_minimum_stability(_parse_result: &dyn deps_core::ParseResult) -> Option<String> {
140    None
141}
142
143/// Rejects document content larger than [`MAX_FILE_SIZE`].
144///
145/// Content from `textDocument/didOpen`/`didChange` reaches this crate directly
146/// over the LSP protocol, with no filesystem `metadata()` size check to gate on
147/// beforehand (unlike [`load_document_from_disk`], which checks before reading).
148/// This applies the same bound so an oversized payload is rejected before it
149/// ever reaches `ecosystem.parse_manifest`.
150///
151/// # Errors
152///
153/// Returns `Err(DepsError::CacheError)` if `content` exceeds `MAX_FILE_SIZE`.
154fn check_content_size(content: &str, uri: &Uri) -> Result<()> {
155    let size = content.len() as u64;
156    if size > MAX_FILE_SIZE {
157        tracing::error!(
158            "Document content exceeds maximum size: {} bytes (limit: {} bytes) for {:?}",
159            size,
160            MAX_FILE_SIZE,
161            uri
162        );
163        return Err(deps_core::error::DepsError::CacheError(format!(
164            "document too large: {size} bytes (max: {MAX_FILE_SIZE} bytes)"
165        )));
166    }
167    Ok(())
168}
169
170/// Preserves cached version data from old document state to new state.
171/// Called during document updates to avoid re-fetching versions for unchanged deps.
172fn preserve_cache(new_state: &mut DocumentState, old_state: &DocumentState) {
173    tracing::trace!(
174        cached = old_state.cached_versions.len(),
175        resolved = old_state.resolved_versions.len(),
176        vulnerabilities = old_state.vulnerabilities.len(),
177        outcomes_yanked = old_state.outcomes.yanked_count(),
178        outcomes_deprecated = old_state.outcomes.deprecation_count(),
179        outcomes_fetch_failed = old_state.outcomes.fetch_failure_count(),
180        "preserving version cache"
181    );
182    new_state
183        .cached_versions
184        .clone_from(&old_state.cached_versions);
185    new_state
186        .resolved_versions
187        .clone_from(&old_state.resolved_versions);
188    // DocumentState is rebuilt on every change, so without this the OSV scan
189    // result would be wiped on every keystroke — `run_osv_scan` overwrites it
190    // once the (cheap, cache-backed) rescan completes, see §4.
191    new_state
192        .vulnerabilities
193        .clone_from(&old_state.vulnerabilities);
194    // Same rationale as `vulnerabilities` above — without this the yanked/deprecation/
195    // fetch-failure diagnostics would flicker off on every keystroke until the next fetch
196    // (or, for a registry-outage package, flip back to a misleading "Unknown package"
197    // diagnostic until the next fetch cycle re-populates it, #267).
198    new_state.outcomes.clone_from(&old_state.outcomes);
199}
200
201/// Drops previously cached version and fetch-failure data ahead of a forced re-fetch
202/// (`RefetchPolicy::AllDependencies`, issue #592): the routing itself changed, so data
203/// obtained under the old routing can no longer be vouched for. Leaves
204/// `resolved_versions` (lockfile-derived, registry-independent) and `vulnerabilities`
205/// (OSV is registry-independent) untouched — dropping either would flicker diagnostics
206/// off for no security benefit.
207///
208/// **Critic S1 fix**: every dependency in `deps_to_fetch` is marked
209/// [`FetchFailure::NotAttempted`] rather than left with no outcome entry at all. The gap
210/// this closes: the real fetch this drop precedes doesn't complete synchronously (it's
211/// behind a 100ms debounce plus network latency), so a concurrent or subsequent plain edit
212/// with unchanged content (`RefetchPolicy::Diff`, empty diff) can commit and
213/// `preserve_cache` forward the just-cleared state before the fetch ever merges real
214/// results. Without a placeholder, that commit's `outcomes` would have no entry at all for
215/// the dropped dependency — indistinguishable from "checked, nothing found" — and
216/// `handlers::diagnostics`' R5 rule renders that as the misleading "Unknown package"
217/// instead of "registry lookup failed" (the same class of bug #267 introduced
218/// `fetch_failed` to prevent in the first place). The placeholder is superseded the moment
219/// the real fetch completes: `merge_registry_fetch_result` calls `set_fetch_failure`
220/// (unconditional overwrite) for a genuine failure or inserts into `cached_versions` for a
221/// success, and a dependency with a `cached_versions` entry never reaches the R5 rule this
222/// placeholder guards regardless of what `outcomes` still says.
223fn drop_cache_for_forced_refetch(
224    doc: &mut DocumentState,
225    deps_to_fetch: &[PackageName],
226    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
227) {
228    doc.cached_versions.clear();
229    doc.outcomes.clear_all_fetch_failures();
230    for name in deps_to_fetch {
231        doc.outcomes.set_fetch_failure_if_absent(
232            formatter.normalize_package_name(name),
233            FetchFailure::NotAttempted,
234        );
235    }
236}
237
238/// Merges a partial fetch's #205 deprecation findings into `doc.outcomes`' deprecation
239/// channel (incremental didChange path — S1).
240///
241/// Without the clearing half of this (S1), a package that stops being deprecated
242/// (`npm deprecate pkg ""`) would keep a stale finding for the document's lifetime —
243/// nothing else ever removes one (a package-level finding does not become stale on a
244/// version-only edit — see the comment above `diff.version_changed`'s pruning loop in
245/// `handle_document_change`).
246///
247/// `fetched_names` — every raw package name successfully fetched this round (i.e. the
248/// keys of `fetch_result.versions`, captured before it is consumed) — must clear any
249/// previously-recorded finding when `fetched_deprecations` has no entry for it ("fetched
250/// and clean"); a name *not* fetched this round (untouched by `deps_to_fetch`) must not
251/// be touched at all, which is why this takes the explicit fetched-name list rather than
252/// iterating `doc.outcomes` itself.
253fn merge_deprecations_after_fetch(
254    doc: &mut DocumentState,
255    fetched_names: &[PackageName],
256    mut fetched_deprecations: HashMap<PackageName, Deprecation>,
257    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
258) {
259    // I2: decided per *normalized* name in one pass, not applied incrementally per raw
260    // name — `fetched_names` iterates a `HashMap`'s keys, so its order is unspecified,
261    // and two raw names that normalize to the same key (e.g. Composer's case-insensitive
262    // `require` keys) would otherwise let "insert B's finding, then clear it because A
263    // (processed after) had none" flip on iteration order alone. Collecting first means
264    // "any fetched raw name under this key reported a finding" wins deterministically,
265    // regardless of which one is visited first.
266    let mut per_normalized: HashMap<String, Option<Deprecation>> = HashMap::new();
267    for name in fetched_names {
268        let normalized = formatter.normalize_package_name(name);
269        let found = fetched_deprecations.remove(name);
270        let entry = per_normalized.entry(normalized).or_insert(None);
271        if entry.is_none() {
272            *entry = found;
273        }
274    }
275    for (normalized, deprecation) in per_normalized {
276        match deprecation {
277            Some(deprecation) => {
278                doc.outcomes.set_deprecation(normalized, deprecation);
279            }
280            None => {
281                doc.outcomes.clear_deprecation(&normalized);
282            }
283        }
284    }
285}
286
287/// Merges a partial fetch's #550 no-comparable-versions findings into `doc.outcomes`'
288/// corresponding channel (incremental didChange path).
289///
290/// Package-level, like [`merge_deprecations_after_fetch`] (not tied to the declared
291/// version, unlike `yanked`/`fetch_failure` — see the `diff.version_changed` pruning
292/// loop in [`handle_document_change`] for why those two, but not this one, are cleared
293/// on a version-only edit): if a package's registry situation improves between fetches
294/// (a real tag gets published), a stale marker must not survive for the document's
295/// lifetime.
296///
297/// `attempted_names` — every raw package name a fetch was actually attempted for this
298/// round (`dep_sources`' keys, captured before it is consumed) — rather than
299/// [`merge_deprecations_after_fetch`]'s `fetched_names` (`fetch_result.versions`'
300/// keys): a no-comparable-versions package is by definition never a member of
301/// `fetch_result.versions`, so deriving "attempted" from that map's keys would miss
302/// every package this function exists to clear or set.
303fn merge_no_comparable_versions_after_fetch(
304    doc: &mut DocumentState,
305    attempted_names: &[PackageName],
306    mut fetched_no_comparable_versions: HashSet<PackageName>,
307    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
308) {
309    // Same normalized-name dedup rationale as `merge_deprecations_after_fetch` (I2):
310    // decided per normalized name in one pass so two raw names sharing a normalized
311    // key can't flip the outcome based on `attempted_names`' unspecified iteration
312    // order.
313    let mut per_normalized: HashMap<String, bool> = HashMap::new();
314    for name in attempted_names {
315        let normalized = formatter.normalize_package_name(name);
316        let found = fetched_no_comparable_versions.remove(name);
317        let entry = per_normalized.entry(normalized).or_insert(false);
318        *entry = *entry || found;
319    }
320    for (normalized, found) in per_normalized {
321        if found {
322            doc.outcomes.set_no_comparable_versions(normalized);
323        } else {
324            doc.outcomes.clear_no_comparable_versions(&normalized);
325        }
326    }
327}
328
329/// Ceiling on the OSV scan timeout, independent of the configured
330/// `fetch_timeout_secs`: the shared `reqwest` client behind `HttpCache`
331/// already imposes its own client-wide 30s timeout (`cache.rs`), so a
332/// per-phase timeout longer than that would never actually bind.
333const OSV_SCAN_TIMEOUT_CEILING_SECS: u64 = 30;
334
335/// Builds `dep_name -> [in_use_version, ...]` (§4.5/§4.6) for every
336/// dependency with a known in-use version, for the yanked-check probe in
337/// `fetch_latest_versions_parallel`. Skips non-registry dependencies
338/// (git/path forks, step 0 of [`build_scan_targets`]'s ladder) so a patched
339/// fork is never flagged for a registry version it does not contain.
340///
341/// One entry per *occurrence* of a name, not a single collapsed value: the
342/// same dependency name can appear more than once in a manifest (the same
343/// crate under `[dependencies]`/`[dev-dependencies]` or multiple
344/// `[target.'cfg(...)'.dependencies]` blocks — #394). A HashMap keyed by
345/// name alone would silently drop all but the last occurrence's in-use
346/// version from the yanked probe below.
347fn collect_in_use_versions(
348    parse_result: &dyn deps_core::ParseResult,
349    resolved_versions: &HashMap<PackageName, ConcreteVersion>,
350    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
351    ecosystem: EcosystemId,
352) -> HashMap<PackageName, Vec<String>> {
353    let mut map: HashMap<PackageName, Vec<String>> = HashMap::new();
354    for dep in parse_result
355        .dependencies()
356        .into_iter()
357        .filter(|dep| formatter.source_is_public_registry_content(&dep.source()))
358    {
359        let normalized_name = formatter.normalize_package_name(dep.name());
360        if let Some(v) = in_use_version(
361            dep,
362            &normalized_name,
363            resolved_versions,
364            formatter,
365            ecosystem,
366        ) {
367            map.entry(dep.name().clone()).or_default().push(v);
368        }
369    }
370    map
371}
372
373/// Builds the OSV scan targets for one manifest's dependencies, applying the
374/// version-selection policy from `architecture.md` §3 in order:
375///
376/// 0. Skip unless `formatter.source_is_public_registry_content(&dep.source())` — a patched
377///    git/path fork must never be flagged with a CVE for a version it does
378///    not actually contain, and neither must a genuinely different private registry's
379///    dependency (only a verified crates.io mirror counts as public-registry content,
380///    F1/F1b).
381/// 1. Use the lock-file-resolved version if present.
382/// 2. Otherwise use the declared requirement, if it is already concrete.
383/// 3. Otherwise skip — querying a fabricated version is a silent false
384///    negative, which is worse than not scanning at all.
385///
386/// **Go exception** (#228 follow-up, unified with #235's
387/// [`deps_core::lsp_helpers::RequirementResolution::manifest_requirement_is_resolved_version`]):
388/// step 1 is skipped entirely for a dependency whose manifest requirement is
389/// itself the resolved version (a Go `require`-directive dependency), going
390/// straight to step 2. Go's `go.mod` `require` line is already an exact
391/// pinned version, never a range, unlike Cargo/npm where the manifest is a
392/// range and the lockfile holds the pin. go.sum-derived `resolved_versions`
393/// is unreliable here: go.sum is a checksum ledger that `go get`/`go build`
394/// only ever append to (only `go mod tidy` prunes it), so its
395/// last-occurrence-wins parse can surface a version still recorded in the
396/// file but no longer selected by Go's MVS — silently querying OSV against
397/// the wrong version. Routing through the formatter hook (rather than a bare
398/// `ecosystem == EcosystemId::Go` check) also excludes Go's `exclude`/
399/// `replace` directive pseudo-dependencies, whose `version_requirement()` is
400/// not an in-use version.
401///
402/// Every dependency that does **not** become a [`deps_core::osv::ScanTarget`]
403/// gets an explicit [`deps_core::osv::ScanOutcome::Skipped`] entry in the
404/// returned map instead of silently vanishing (critique C1) — absence from
405/// [`deps_core::osv::VulnerabilityMap`] must never happen for an input this
406/// function considered.
407///
408/// Each dependency's map/target key comes from
409/// [`deps_core::osv::vulnerability_keys`] rather than a bare
410/// `formatter.normalize_package_name(dep.name())` (#394 S2): when two
411/// occurrences of one name resolve to different in-use versions (or mix a
412/// registry source with a git/path fork), their keys are disambiguated so
413/// one occurrence's OSV result never overwrites another's in the shared
414/// [`deps_core::osv::VulnerabilityMap`]. Occurrences that share both a name
415/// and an identical in-use version keep the plain key and are scanned once —
416/// a dedup, not a gap, since the OSV result would be identical either way.
417fn build_scan_targets(
418    parse_result: &dyn deps_core::ParseResult,
419    resolved_versions: &HashMap<PackageName, ConcreteVersion>,
420    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
421    ecosystem: EcosystemId,
422) -> (
423    Vec<deps_core::osv::ScanTarget>,
424    deps_core::osv::VulnerabilityMap,
425) {
426    use deps_core::osv::{ScanOutcome, SkipReason};
427
428    let mut targets = Vec::new();
429    let mut skipped = deps_core::osv::VulnerabilityMap::new();
430    let keys =
431        deps_core::osv::vulnerability_keys(parse_result, resolved_versions, formatter, ecosystem);
432
433    for dep in parse_result.dependencies() {
434        let normalized_name = formatter.normalize_package_name(dep.name());
435        let key = keys
436            .get(&dep.name_range())
437            .cloned()
438            .unwrap_or_else(|| normalized_name.clone());
439
440        if !formatter.source_is_public_registry_content(&dep.source()) {
441            skipped.insert(key, ScanOutcome::Skipped(SkipReason::NonRegistrySource));
442            continue;
443        }
444
445        // lockfile holds the pin) — so for a Go `require` dependency the
446        // manifest itself is the authoritative version, not go.sum. go.sum is
447        // a checksum ledger that `go get`/`go build` only ever append to
448        // (only `go mod tidy` prunes it), so its last-occurrence-wins parse
449        // can yield a stale version still recorded in the file but no longer
450        // selected by Go's MVS, silently mismatching whatever's actually in
451        // use. Skipping the lockfile lookup avoids feeding that stale version
452        // to OSV (excludes/replaces fall through to the lockfile lookup below
453        // like any other ecosystem, since their `version_requirement()` is
454        // not an in-use version — see `manifest_requirement_is_resolved_version`).
455        let version = in_use_version(
456            dep,
457            &normalized_name,
458            resolved_versions,
459            formatter,
460            ecosystem,
461        );
462
463        let Some(version) = version else {
464            skipped.insert(key, ScanOutcome::Skipped(SkipReason::NoConcreteVersion));
465            continue;
466        };
467
468        let Some(osv_name) = formatter.osv_package_name(dep) else {
469            skipped.insert(key, ScanOutcome::Skipped(SkipReason::UnmappableName));
470            continue;
471        };
472
473        targets.push(deps_core::osv::ScanTarget {
474            key,
475            osv_name,
476            version: formatter.osv_version(&version),
477            display_version: version,
478        });
479    }
480
481    (targets, skipped)
482}
483
484/// Logs the per-document scan summary unconditionally (critique C1) —
485/// including when every dependency was filtered out before ever reaching
486/// [`deps_core::osv::OsvClient::scan`], which previously produced no log line
487/// at all and defeated §8 invariant 0's purpose of making "not scanned"
488/// observable.
489fn log_osv_run_summary(vulnerabilities: &deps_core::osv::VulnerabilityMap) {
490    let mut clean = 0usize;
491    let mut vulnerable = 0usize;
492    let mut skipped = 0usize;
493    for outcome in vulnerabilities.values() {
494        match outcome {
495            deps_core::osv::ScanOutcome::Clean => clean += 1,
496            deps_core::osv::ScanOutcome::Vulnerable(_) => vulnerable += 1,
497            deps_core::osv::ScanOutcome::Skipped(_) => skipped += 1,
498        }
499    }
500    tracing::info!(
501        "OSV: document scan complete, {} dependencies considered, {clean} clean, {vulnerable} vulnerable, {skipped} skipped",
502        vulnerabilities.len(),
503    );
504}
505
506/// Phase A output, carried from the concurrently-spawned scan task into
507/// phase B (run later, after the registry fetch resolves — critique S1).
508struct OsvScanResult {
509    /// Document content at the moment the scan started, to guard the
510    /// eventual write against a cross-generation stale commit (critique M4).
511    content_snapshot: String,
512    vulnerabilities: deps_core::osv::VulnerabilityMap,
513    /// `key -> osv_name`, needed to build phase B candidates.
514    osv_name_by_key: HashMap<String, String>,
515    /// `key -> dep.name()` (raw, pre-normalization), the fallback
516    /// `cached_versions` lookup needs since that map is keyed by the raw
517    /// name while `key` is normalized (critique S2) — they differ for
518    /// Composer/Swift/NuGet-style ecosystems.
519    raw_name_by_key: HashMap<String, String>,
520}
521
522/// Phase A: builds scan targets, runs [`deps_core::osv::OsvClient::scan`], and
523/// merges in the pre-filter skips — all before the registry fetch is known
524/// to have completed, so this must be `tokio::spawn`ed by the caller and run
525/// concurrently with it, never awaited inline (critique S2/original design
526/// note: joining here would gate the inlay-hint refresh that must happen
527/// immediately after the registry fetch).
528///
529/// Returns `None` only when there is nothing to report at all (no
530/// dependencies reached any of steps 0-3, including the pre-filter skips —
531/// i.e. an empty manifest).
532async fn run_osv_scan_phase_a(
533    uri: Uri,
534    state: Arc<ServerState>,
535    ecosystem: Arc<dyn Ecosystem>,
536    fetch_timeout_secs: u64,
537) -> Option<OsvScanResult> {
538    let ecosystem_id = resolve_ecosystem_id(ecosystem.as_ref());
539
540    let (content_snapshot, targets, mut vulnerabilities, raw_name_by_key) = {
541        let doc = state.get_document(&uri)?;
542        let parse_result = doc.parse_result()?;
543        let (targets, skipped) = build_scan_targets(
544            parse_result,
545            &doc.resolved_versions,
546            ecosystem.formatter(),
547            ecosystem_id,
548        );
549        // Keyed the same way `targets`/`skipped` are (#394 S2: possibly
550        // version-qualified, not just the plain normalized name) so phase B's
551        // `raw_name_by_key.get(key)` fallback below still finds this
552        // occurrence's raw name when its key was disambiguated.
553        let vuln_keys = deps_core::osv::vulnerability_keys(
554            parse_result,
555            &doc.resolved_versions,
556            ecosystem.formatter(),
557            ecosystem_id,
558        );
559        let raw_name_by_key: HashMap<String, String> = parse_result
560            .dependencies()
561            .into_iter()
562            .map(|d| {
563                let key = vuln_keys
564                    .get(&d.name_range())
565                    .cloned()
566                    .unwrap_or_else(|| ecosystem.formatter().normalize_package_name(d.name()));
567                (key, d.name().to_string())
568            })
569            .collect();
570        (doc.content.clone(), targets, skipped, raw_name_by_key)
571    };
572
573    if targets.is_empty() && vulnerabilities.is_empty() {
574        return None;
575    }
576
577    let osv_name_by_key: HashMap<String, String> = targets
578        .iter()
579        .map(|t| (t.key.clone(), t.osv_name.clone()))
580        .collect();
581
582    if !targets.is_empty() {
583        let timeout_duration =
584            Duration::from_secs(fetch_timeout_secs.min(OSV_SCAN_TIMEOUT_CEILING_SECS));
585        let scanned = state
586            .osv
587            .scan(ecosystem_id, &targets, timeout_duration)
588            .await;
589        vulnerabilities.extend(scanned);
590    }
591
592    log_osv_run_summary(&vulnerabilities);
593
594    Some(OsvScanResult {
595        content_snapshot,
596        vulnerabilities,
597        osv_name_by_key,
598        raw_name_by_key,
599    })
600}
601
602/// Phase B: for every dependency phase A flagged [`deps_core::osv::ScanOutcome::Vulnerable`],
603/// checks whether the version currently recommended (the registry's latest,
604/// now that the registry fetch has resolved — critique S1) is itself
605/// affected (B.1), then independently verifies each dependency's recommended
606/// *fix target* F (B.2, #462 — see [`run_osv_fix_target_verification`]),
607/// before committing the result into `DocumentState.vulnerabilities`.
608///
609/// Must be called only *after* the registry fetch has updated
610/// `doc.cached_versions`: calling it concurrently with that fetch (as the
611/// original implementation did, by folding phase B into the same spawned
612/// task as phase A) reads `cached_versions` before it holds the registry's
613/// actual latest version, so hover could report the *already-installed*
614/// version as "also affected" instead of the true latest.
615///
616/// The write is guarded against a cross-generation stale commit (critique
617/// M4): `spawn_background_task` aborts the *previous* task only after the
618/// new `DocumentState` is already installed, so an in-flight scan from stale
619/// content could otherwise commit advisories computed against content the
620/// document no longer has. B.1 and B.2 share one `phase_b_deadline` (#462
621/// critic S2/M1) rather than each getting a fresh `fetch_timeout_secs`
622/// budget: B.2 is a second sequential network round-trip added *before* this
623/// guard, so giving it its own full budget would both double phase B's
624/// worst-case wall-clock time (contradicting NFR-002's singular "existing...
625/// budget" framing) and widen the window in which a mid-scan edit discards
626/// this whole result, `upgrade_status` included. Sharing one deadline caps
627/// the total at the original ceiling, same as before this fix existed.
628async fn run_osv_phase_b_and_commit(
629    uri: &Uri,
630    state: &Arc<ServerState>,
631    ecosystem_id: EcosystemId,
632    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
633    fetch_timeout_secs: u64,
634    mut result: OsvScanResult,
635) {
636    let vulnerable_keys: Vec<String> = result
637        .vulnerabilities
638        .iter()
639        .filter(|(_, outcome)| matches!(outcome, deps_core::osv::ScanOutcome::Vulnerable(_)))
640        .map(|(key, _)| key.clone())
641        .collect();
642
643    if !vulnerable_keys.is_empty() {
644        let phase_b_deadline = Instant::now()
645            + Duration::from_secs(fetch_timeout_secs.min(OSV_SCAN_TIMEOUT_CEILING_SECS));
646
647        // B.1 (US-002, unchanged by #462): checks the registry's "latest" candidate.
648        // `latest_native_by_key` is kept for B.2 below, which needs the same native
649        // "latest" string to detect a fix target F that coincides with latest (FR-002)
650        // without re-deriving it from `doc.cached_versions` a second time.
651        let latest_native_by_key: HashMap<String, String> = {
652            let Some(doc) = state.get_document(uri) else {
653                return;
654            };
655            vulnerable_keys
656                .iter()
657                .filter_map(|key| {
658                    let latest = doc
659                        .cached_versions
660                        .get(key.as_str())
661                        .or_else(|| {
662                            let raw = result.raw_name_by_key.get(key)?;
663                            doc.cached_versions.get(raw.as_str())
664                        })?
665                        .latest
666                        .clone();
667                    Some((key.clone(), latest.to_string()))
668                })
669                .collect()
670        };
671
672        let candidates: Vec<deps_core::osv::ScanTarget> = vulnerable_keys
673            .iter()
674            .filter_map(|key| {
675                let osv_name = result.osv_name_by_key.get(key)?.clone();
676                let latest_native = latest_native_by_key.get(key)?.clone();
677                Some(deps_core::osv::ScanTarget {
678                    key: key.clone(),
679                    osv_name,
680                    version: formatter.osv_version(&latest_native),
681                    display_version: latest_native,
682                })
683            })
684            .collect();
685
686        if !candidates.is_empty() {
687            let timeout_duration = phase_b_deadline.saturating_duration_since(Instant::now());
688            let statuses = state
689                .osv
690                .check_candidates(ecosystem_id, &candidates, timeout_duration)
691                .await;
692            for (key, status) in statuses {
693                if let Some(deps_core::osv::ScanOutcome::Vulnerable(dv)) =
694                    result.vulnerabilities.get_mut(&key)
695                {
696                    dv.upgrade_status = status;
697                }
698            }
699        }
700
701        run_osv_fix_target_verification(
702            &mut result.vulnerabilities,
703            &vulnerable_keys,
704            &result.osv_name_by_key,
705            &latest_native_by_key,
706            ecosystem_id,
707            formatter,
708            &state.osv,
709            phase_b_deadline,
710        )
711        .await;
712    }
713
714    if let Some(mut doc) = state.documents.get_mut(uri) {
715        if doc.content == result.content_snapshot {
716            doc.update_vulnerabilities(result.vulnerabilities);
717        } else {
718            tracing::debug!("dropping stale OSV scan result: document content changed mid-scan");
719        }
720    }
721}
722
723/// Synthetic [`deps_core::osv::ScanTarget::key`] suffix marking a fix-target (F) live-check
724/// candidate as distinct from the same dependency's "latest" candidate (B.1) within the
725/// shared `VulnerabilityMap` key space — see [`run_osv_fix_target_verification`].
726const FIX_TARGET_KEY_SUFFIX: &str = "\u{0}fix";
727
728/// B.2 (#462): independently verifies each vulnerable dependency's recommended fix target F
729/// (`DependencyVulnerabilities::recommended_fix`'s `version`), which B.1 above never scans —
730/// B.1 only ever checks the registry's "latest" candidate, and F is frequently a different,
731/// older version (the minimal version that clears the advisories B.1's "latest" check did
732/// not already exclude). Without this, `generate_code_actions` could offer F as a verified
733/// fix when OSV was never asked about F itself — see
734/// `deps_core::osv::DependencyVulnerabilities::fix_target_status`'s doc, which this function
735/// populates, and the orphaned-TODO history in this function's git blame (formerly tracked
736/// only by a `// TODO(critic): ... see #216 critique D1` comment; now #462).
737///
738/// Resolution order per dependency, cheapest first:
739/// 1. F equals the already-checked "latest" candidate (FR-002) — reuse `upgrade_status`,
740///    no extra call.
741/// 2. Otherwise, queue a live [`deps_core::osv::OsvClient::check_candidates`] check for F,
742///    batched into a single call across every dependency that reaches this branch (NFR-001)
743///    — never one call per dependency. There is no data-derived shortcut here: a proof
744///    checking F against the advisories `recommended_fix()` computed F *from* is a
745///    tautology at its only call site (critic C1) — phase A only ever queries the
746///    dependency's declared version, so an advisory affecting some version strictly
747///    between declared and F, but not the declared version itself, is invisible to any
748///    check built only from `self.advisories`. Only a live query of F itself can surface
749///    that advisory (the actual gap #462 exists to close).
750///
751/// A dependency whose F fails [`deps_core::lsp_helpers::is_safe_version_string`], or whose
752/// `osv_name` is unavailable, is skipped (logged at `debug`), never crashes the scan. A
753/// dependency whose live check above times out or fails simply keeps `fix_target_status`
754/// left at `NotChecked` (case 2's `HashMap` result never carries that key) — the safe,
755/// fail-closed degradation FR-004/NFR-002 call for: `deps_core::lsp_helpers::code_actions::fix_target_is_verified`
756/// treats an unresolved status as unverified and omits the fix action, so a stalled
757/// verification never surfaces a fix action that was never actually checked.
758#[allow(clippy::too_many_arguments)]
759async fn run_osv_fix_target_verification(
760    vulnerabilities: &mut deps_core::osv::VulnerabilityMap,
761    vulnerable_keys: &[String],
762    osv_name_by_key: &HashMap<String, String>,
763    latest_native_by_key: &HashMap<String, String>,
764    ecosystem_id: EcosystemId,
765    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
766    osv: &deps_core::osv::OsvClient,
767    phase_b_deadline: Instant,
768) {
769    use deps_core::osv::ScanOutcome;
770
771    let (resolved, live_check_candidates) = collect_fix_target_resolutions(
772        vulnerabilities,
773        vulnerable_keys,
774        osv_name_by_key,
775        latest_native_by_key,
776        formatter,
777    );
778
779    for (key, status) in resolved {
780        if let Some(ScanOutcome::Vulnerable(dv)) = vulnerabilities.get_mut(key.as_str()) {
781            dv.fix_target_status = status;
782        }
783    }
784
785    if live_check_candidates.is_empty() {
786        return;
787    }
788
789    let timeout_duration = phase_b_deadline.saturating_duration_since(Instant::now());
790    let statuses = osv
791        .check_candidates(ecosystem_id, &live_check_candidates, timeout_duration)
792        .await;
793
794    apply_live_fix_target_statuses(vulnerabilities, statuses);
795}
796
797/// Outcome of [`resolve_fix_target`] for one vulnerable dependency.
798#[derive(Debug, PartialEq, Eq)]
799enum FixTargetResolution {
800    /// No fix recommended, F failed [`deps_core::lsp_helpers::is_safe_version_string`], or no
801    /// `osv_name` is on record for this key — nothing to verify or record; `fix_target_status`
802    /// stays untouched (left at `NotChecked`).
803    Skip,
804    /// F's status was resolved without a network call by reusing the already-checked
805    /// "latest" candidate's result (FR-002, F == latest).
806    Resolved(deps_core::osv::UpgradeStatus),
807    /// F differs from latest and needs a live [`deps_core::osv::OsvClient::check_candidates`]
808    /// check — carries the [`deps_core::osv::ScanTarget`] to batch into the caller's single
809    /// combined call (NFR-001), keyed with [`FIX_TARGET_KEY_SUFFIX`] so its result cannot
810    /// collide with the same dependency's "latest" candidate in the same `VulnerabilityMap`
811    /// key space.
812    NeedsLiveCheck(deps_core::osv::ScanTarget),
813}
814
815/// Pure (network-free) decision logic for [`run_osv_fix_target_verification`]'s per-dependency
816/// resolution order — see that function's doc for the two cases and their rationale. Split
817/// out so each case is unit-testable without an `OsvClient`/network dependency.
818fn resolve_fix_target(
819    dv: &deps_core::osv::DependencyVulnerabilities,
820    key: &str,
821    latest_native_by_key: &HashMap<String, String>,
822    osv_name_by_key: &HashMap<String, String>,
823    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
824) -> FixTargetResolution {
825    use deps_core::lsp_helpers::is_safe_version_string;
826    use deps_core::osv::ScanTarget;
827
828    let Some(fix) = dv.recommended_fix() else {
829        return FixTargetResolution::Skip;
830    };
831    let version_native = formatter.osv_version_to_native(&fix.version);
832    if !is_safe_version_string(&version_native) {
833        tracing::debug!(
834            key,
835            version = %fix.version,
836            "OSV #462: fix-target version failed validation, skipping verification"
837        );
838        return FixTargetResolution::Skip;
839    }
840
841    if latest_native_by_key.get(key) == Some(&version_native) {
842        return FixTargetResolution::Resolved(dv.upgrade_status.clone());
843    }
844
845    let Some(osv_name) = osv_name_by_key.get(key).cloned() else {
846        tracing::debug!(
847            key,
848            "OSV #462: no osv_name on record for fix-target verification, skipping"
849        );
850        return FixTargetResolution::Skip;
851    };
852    FixTargetResolution::NeedsLiveCheck(ScanTarget {
853        key: format!("{key}{FIX_TARGET_KEY_SUFFIX}"),
854        osv_name,
855        version: fix.version,
856        display_version: version_native,
857    })
858}
859
860/// Pure aggregation step of [`run_osv_fix_target_verification`]: resolves every vulnerable
861/// dependency's fix target via [`resolve_fix_target`], splitting immediately-resolvable
862/// results (`resolved`) from the ones that need a live check (`live_check_candidates`) — the
863/// latter collected into one `Vec` across *every* dependency before the caller's single
864/// `check_candidates` call, so multiple dependencies needing a live check always batch into
865/// one network round-trip rather than one per dependency (NFR-001). Split out from the async
866/// orchestrator specifically so this batching/aggregation behavior is unit-testable without
867/// an `OsvClient`.
868fn collect_fix_target_resolutions(
869    vulnerabilities: &deps_core::osv::VulnerabilityMap,
870    vulnerable_keys: &[String],
871    osv_name_by_key: &HashMap<String, String>,
872    latest_native_by_key: &HashMap<String, String>,
873    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
874) -> (
875    Vec<(String, deps_core::osv::UpgradeStatus)>,
876    Vec<deps_core::osv::ScanTarget>,
877) {
878    use deps_core::osv::ScanOutcome;
879
880    let mut resolved = Vec::new();
881    let mut live_check_candidates = Vec::new();
882
883    for key in vulnerable_keys {
884        let Some(ScanOutcome::Vulnerable(dv)) = vulnerabilities.get(key.as_str()) else {
885            continue;
886        };
887        match resolve_fix_target(dv, key, latest_native_by_key, osv_name_by_key, formatter) {
888            FixTargetResolution::Skip => {}
889            FixTargetResolution::Resolved(status) => resolved.push((key.clone(), status)),
890            FixTargetResolution::NeedsLiveCheck(target) => live_check_candidates.push(target),
891        }
892    }
893
894    (resolved, live_check_candidates)
895}
896
897/// Applies a live [`deps_core::osv::OsvClient::check_candidates`] result keyed with
898/// [`FIX_TARGET_KEY_SUFFIX`] back onto the matching dependency's `fix_target_status`.
899///
900/// A key absent from `statuses` (timeout, OSV outage, or a chunk `check_candidates` itself
901/// dropped) simply leaves that dependency's `fix_target_status` untouched — still
902/// `NotChecked` if it was never set, which is exactly the fail-closed degradation
903/// FR-004/NFR-002 call for (never a panic, never a fabricated "verified" status).
904fn apply_live_fix_target_statuses(
905    vulnerabilities: &mut deps_core::osv::VulnerabilityMap,
906    statuses: HashMap<String, deps_core::osv::UpgradeStatus>,
907) {
908    use deps_core::osv::ScanOutcome;
909
910    for (synthetic_key, status) in statuses {
911        let Some(key) = synthetic_key.strip_suffix(FIX_TARGET_KEY_SUFFIX) else {
912            continue;
913        };
914        if let Some(ScanOutcome::Vulnerable(dv)) = vulnerabilities.get_mut(key) {
915            dv.fix_target_status = status;
916        }
917    }
918}
919
920/// Diff between old and new dependency sets.
921///
922/// `version_changed` exists because [`Self::added`]/[`Self::removed`] alone
923/// are name-set diffs: editing a dependency's version requirement in place
924/// (e.g. `time = "0.1.43"` -> `"0.1.44"`) changes neither set, so gating the
925/// OSV rescan on `added` alone would silently skip re-scanning the one
926/// dependency whose version just changed (critique S1).
927#[derive(Debug, Clone, Default)]
928struct DependencyDiff {
929    added: Vec<PackageName>,
930    removed: Vec<PackageName>,
931    version_changed: Vec<PackageName>,
932}
933
934impl DependencyDiff {
935    /// `old`/`new` map each dependency name to the declared version
936    /// requirements (`Dependency::version_requirement()`) of *every*
937    /// occurrence of that name at parse time — not a single collapsed value.
938    /// A name can appear more than once within a manifest: the same crate
939    /// declared under both `[dependencies]`/`[dev-dependencies]`, or under
940    /// two different `[target.'cfg(...)'.dependencies]` blocks (see #394).
941    /// Comparing the full per-name `Vec` rather than a name-keyed single
942    /// requirement ensures an edit to *any* occurrence changes the value the
943    /// diff compares, instead of silently no-opping when a HashMap collapse
944    /// would have kept the "winning" occurrence's requirement unchanged.
945    ///
946    /// Occurrence order within a `Vec` is deterministic per parser but is
947    /// **not** necessarily document/source order — e.g. `deps-cargo` walks
948    /// `toml_span::Table`, a `BTreeMap` ordered by key, so multiple
949    /// `[target.*]` blocks come out sorted by their cfg-expression string,
950    /// not by which one appears first in the file. This only affects which
951    /// index an occurrence lands at (never which name it's grouped under),
952    /// so it cannot cause a missed or misattributed diff — see
953    /// [`dependency_version_map`]'s doc for the consequence of reordering
954    /// across an edit.
955    fn compute(
956        old: &HashMap<PackageName, Vec<Option<VersionReq>>>,
957        new: &HashMap<PackageName, Vec<Option<VersionReq>>>,
958    ) -> Self {
959        let old_names: HashSet<&PackageName> = old.keys().collect();
960        let new_names: HashSet<&PackageName> = new.keys().collect();
961
962        let added = new_names
963            .difference(&old_names)
964            .map(|s| (*s).clone())
965            .collect();
966        let removed = old_names
967            .difference(&new_names)
968            .map(|s| (*s).clone())
969            .collect();
970        let version_changed = new_names
971            .intersection(&old_names)
972            .filter(|name| old.get(**name) != new.get(**name))
973            .map(|s| (*s).clone())
974            .collect();
975
976        Self {
977            added,
978            removed,
979            version_changed,
980        }
981    }
982
983    /// Whether the registry fetch (and therefore the yanked-version probe,
984    /// #233) has any reason to run: a new dependency, or an existing one
985    /// whose declared version changed. A version-only edit still needs the
986    /// fetch — the "latest" value itself does not change, but a dependency
987    /// edited from a safe pin to a yanked one (or vice versa) must be
988    /// re-probed against its new in-use version, and any stale finding
989    /// against the *old* version must not linger (security F1 / impl-critic
990    /// S1).
991    #[cfg(test)]
992    fn needs_fetch(&self) -> bool {
993        !self.added.is_empty() || !self.version_changed.is_empty()
994    }
995
996    /// Whether the OSV rescan (§4) has any reason to run: a new dependency,
997    /// or an existing one whose declared version changed. Identical to
998    /// [`Self::needs_fetch`] today (both gate on `added`/`version_changed`);
999    /// kept as separate methods since they answer different questions and
1000    /// could diverge again if either gate changes independently.
1001    fn needs_osv_rescan(&self) -> bool {
1002        !self.added.is_empty() || !self.version_changed.is_empty()
1003    }
1004}
1005
1006/// Builds `name -> [version_requirement, ...]` for every dependency in `pr`,
1007/// one entry per occurrence, the shape [`DependencyDiff::compute`] needs. A
1008/// `HashMap<PackageName, Option<VersionReq>>` (single value per name) would
1009/// silently collapse a duplicate name to its last occurrence, losing any
1010/// edit made to an earlier one (#394).
1011///
1012/// Occurrence order is whatever `pr.dependencies()` returns, which for
1013/// `deps-cargo` is *not* document order for multiple `[target.*]` blocks
1014/// (see [`DependencyDiff::compute`]'s doc). A consequence worth knowing: if
1015/// an edit only renames a `[target.'cfg(...)'.dependencies]` expression
1016/// (no version change), that occurrence can sort into a different position
1017/// in the new `Vec` than the old one, so `old.get(name) != new.get(name)`
1018/// trips even though every individual version requirement is unchanged —
1019/// a spurious but harmless `version_changed` (one extra registry
1020/// refetch/OSV rescan for that name, never a missed or misattributed one).
1021fn dependency_version_map(
1022    pr: &dyn deps_core::ParseResult,
1023) -> HashMap<PackageName, Vec<Option<VersionReq>>> {
1024    let mut map: HashMap<PackageName, Vec<Option<VersionReq>>> = HashMap::new();
1025    for d in pr.dependencies() {
1026        map.entry(d.name().clone())
1027            .or_default()
1028            .push(d.version_requirement().cloned());
1029    }
1030    map
1031}
1032
1033/// Result of parallel version fetching.
1034struct FetchResult {
1035    /// Successfully fetched versions (package -> latest + full version list)
1036    versions: HashMap<PackageName, PackageVersions>,
1037    /// Yanked-version findings, keyed by **raw** package name (unlike
1038    /// `DocumentState::outcomes`, which is normalized-keyed — see
1039    /// §3.1 of the design), to (the version string found yanked, its
1040    /// `RemovalStatus`). The status rides alongside so #205's package-level
1041    /// deprecation diagnostic can gate its yanked-check suppression on
1042    /// `AdvisoryDeprecated` specifically, never a genuine `Yanked` finding.
1043    /// Callers must re-key through `EcosystemFormatter::normalize_package_name`
1044    /// before merging into document state.
1045    yanked_versions: HashMap<PackageName, (ConcreteVersion, RemovalStatus)>,
1046    /// Package-level deprecation findings (issue #205), keyed by **raw** package name
1047    /// (same raw/normalized split as `yanked_versions` above). Derived from the
1048    /// `resolved`/"latest" pick in the fetch loop below, not by scanning the full
1049    /// `versions` list — see that loop's comments for why.
1050    deprecations: HashMap<PackageName, Deprecation>,
1051    /// Packages whose registry fetch errored or timed out, keyed by **raw**
1052    /// package name (same raw/normalized split as `yanked_versions` above).
1053    /// Lets diagnostic generation (#267) distinguish "the registry said this
1054    /// package doesn't exist" from "the registry couldn't be asked" instead
1055    /// of conflating both into a misleading "Unknown package" diagnostic.
1056    fetch_failed: HashMap<PackageName, FetchFailure>,
1057    /// Packages whose registry fetch succeeded but produced zero comparable versions
1058    /// (#550), keyed by **raw** package name (same raw/normalized split as
1059    /// `yanked_versions` above). Distinct from `fetch_failed`: the registry was
1060    /// successfully asked and the package demonstrably exists — it just has nothing a
1061    /// version-comparison rule can use — so `generate_diagnostics_from_cache` must
1062    /// report neither "Registry lookup failed" nor "Unknown package" for it.
1063    no_comparable_versions: HashSet<PackageName>,
1064    /// Number of packages whose registry fetch did not succeed, counting both a genuine
1065    /// fetch failure (timeout, error — recorded in `fetch_failed` above) and a not-found
1066    /// lookup (the registry answered "no such package", never recorded in `fetch_failed`,
1067    /// see #267 C1). Only the `fetch_failed` subset produces an inline "Registry lookup
1068    /// failed" diagnostic, so this count can exceed `fetch_failed.len()` (#276 S2, #490).
1069    failed_count: usize,
1070    /// First actionable error message (shown to user via `window/showMessage`)
1071    first_error: Option<String>,
1072}
1073
1074/// Fetches latest versions for multiple packages in parallel with progress reporting.
1075///
1076/// Returns a [`FetchResult`] containing successfully fetched versions and failure count.
1077/// Packages that fail to fetch are omitted from the versions map.
1078///
1079/// This function executes all registry requests concurrently with per-dependency
1080/// timeout isolation, preventing slow packages from blocking others.
1081///
1082/// Alongside the primary fetch, checks whether the in-use version of a
1083/// dependency has been yanked (#233), for registries that [report yank
1084/// data](Registry::reports_yanked). Unlike the original design, this is not
1085/// a second registry round trip: `registry.get_versions` below already
1086/// fetches the full, unfiltered version list once per package (see
1087/// [`PackageVersions`]), so the in-use-version check is a zero-cost
1088/// in-memory search over a list already in hand, run for every dependency
1089/// with a known in-use version rather than only when it differs from
1090/// `latest`.
1091///
1092/// # Arguments
1093///
1094/// * `registry` - Package registry to fetch from
1095/// * `package_names` - List of package names to fetch
1096/// * `in_use` - Raw dependency name -> the version(s) this project actually
1097///   has (lockfile-resolved or a concrete pin) for every occurrence of that
1098///   name in the manifest, checked against the fetched version list for
1099///   yank status
1100/// * `progress` - Optional progress tracker (will be updated after each fetch)
1101/// * `timeout_secs` - Timeout for each individual package fetch (default: 10s)
1102/// * `max_concurrent` - Maximum concurrent fetches (default: 20)
1103///
1104/// # Timeout Behavior
1105///
1106/// Each package fetch is wrapped in an individual timeout. If a package
1107/// takes longer than `timeout_secs` to fetch, it fails fast with a warning
1108/// and does NOT block other packages.
1109///
1110/// # Performance
1111///
1112/// With 50 dependencies and 100ms per request:
1113/// - Sequential: 50 × 100ms = 5000ms
1114/// - Parallel (no timeout): max(100ms) ≈ 150ms
1115/// - Parallel (10s timeout, 1 slow package at 30s): max(10s) ≈ 10s
1116#[allow(
1117    clippy::too_many_arguments,
1118    reason = "internal (non-pub) call-site-controlled fetch tuning + ecosystem-context \
1119              parameters; grouping into a config struct would only move, not reduce, the \
1120              per-call-site churn across this module's ~15 production and test call sites"
1121)]
1122async fn fetch_latest_versions_parallel(
1123    registry: Arc<dyn Registry>,
1124    package_sources: DepSources,
1125    in_use: &HashMap<PackageName, Vec<String>>,
1126    progress_sender: Option<ProgressSender>,
1127    freshness: deps_core::freshness::FreshnessSettings,
1128    timeout_secs: u64,
1129    max_concurrent: usize,
1130    minimum_stability: Option<&str>,
1131) -> FetchResult {
1132    use futures::stream::{self, StreamExt};
1133    use std::time::Duration;
1134
1135    let fetched = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1136    let failed = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1137    let first_error: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
1138    // Separate from `first_error` (#480): a not-found error is deliberately excluded
1139    // from `fetch_failed` below (it isn't evidence of a registry-side problem), but
1140    // without this, whichever concurrent fetch happened to finish first could still win
1141    // the `first_error` race and put a misleading "not found" message in the one-time
1142    // toast even when the batch's real, actionable failure is e.g. a rate limit hit by
1143    // 20 other dependencies. Any error that *does* count toward `fetch_failed`
1144    // (including a timeout) always wins the toast over a not-found, regardless of
1145    // finishing order; only a not-found-only batch falls back to `first_error`.
1146    //
1147    // Unlike `first_error`, this is not a shared `Arc<Mutex>` written from inside the
1148    // four match arms below — each task instead returns its own `(name, message)` via
1149    // `failed_name`, and the priority error is derived by folding those in completion
1150    // order once every task has finished (see the loop below). `fetch_failed` and the
1151    // priority error are thereby always in sync by construction: both come from the
1152    // same `failed_name` value, so a future edit to one can no longer silently drift
1153    // from the other, which two independently hand-maintained writes could (#480).
1154    let timeout = Duration::from_secs(timeout_secs);
1155    let wildcard_req = deps_core::VersionReq::new("*");
1156    let check_yanked = registry.reports_yanked();
1157
1158    let results: Vec<_> = stream::iter(package_sources)
1159        .map(|(name, source)| {
1160            let registry = Arc::clone(&registry);
1161            let fetched = Arc::clone(&fetched);
1162            let failed = Arc::clone(&failed);
1163            let first_error = Arc::clone(&first_error);
1164            let progress_sender = progress_sender.clone();
1165            let wildcard_req = &wildcard_req;
1166            let in_use_versions = in_use.get(&name).cloned().unwrap_or_default();
1167            async move {
1168                fetch_and_classify_package(
1169                    registry.as_ref(),
1170                    name,
1171                    source,
1172                    in_use_versions,
1173                    wildcard_req,
1174                    freshness,
1175                    timeout,
1176                    minimum_stability,
1177                    check_yanked,
1178                    &fetched,
1179                    &failed,
1180                    &first_error,
1181                    progress_sender.as_ref(),
1182                )
1183                .await
1184            }
1185        })
1186        .buffer_unordered(max_concurrent)
1187        .collect()
1188        .await;
1189
1190    let mut versions = HashMap::with_capacity(results.len());
1191    let mut yanked_versions = HashMap::new();
1192    let mut fetch_failed = HashMap::new();
1193    let mut deprecations = HashMap::new();
1194    let mut no_comparable_versions = HashSet::new();
1195    // First actionable failure in completion order — `results` is collected from
1196    // `buffer_unordered`, so its order already reflects real finishing order, the same
1197    // order a shared `Arc<Mutex>` written from inside each task would have observed.
1198    let mut priority_error: Option<String> = None;
1199    for (version, yanked, failed_name, deprecation, no_comparable_versions_name) in results {
1200        if let Some((name, v)) = version {
1201            versions.insert(name, v);
1202        }
1203        if let Some((name, v, status)) = yanked {
1204            yanked_versions.insert(name, (v, status));
1205        }
1206        if let Some((name, failure, message)) = failed_name {
1207            fetch_failed.insert(name, failure);
1208            if priority_error.is_none() {
1209                priority_error = Some(message);
1210            }
1211        }
1212        if let Some((name, d)) = deprecation {
1213            deprecations.insert(name, d);
1214        }
1215        if let Some(name) = no_comparable_versions_name {
1216            no_comparable_versions.insert(name);
1217        }
1218    }
1219
1220    // `priority_error` (an actual fetch failure — rate limit, timeout, outage, ...)
1221    // always wins the toast over `first_error` (which may be a not-found race winner);
1222    // `first_error` is the fallback only for a batch whose only failures were
1223    // not-found (#480).
1224    let error_message =
1225        priority_error.or_else(|| first_error.lock().unwrap_or_else(|p| p.into_inner()).take());
1226
1227    FetchResult {
1228        versions,
1229        yanked_versions,
1230        fetch_failed,
1231        deprecations,
1232        no_comparable_versions,
1233        failed_count: failed.load(std::sync::atomic::Ordering::Relaxed),
1234        first_error: error_message,
1235    }
1236}
1237
1238/// Per-package outcome returned by [`fetch_and_classify_package`]: the resolved
1239/// `(name, PackageVersions)` entry, a yanked finding, a fetch failure, a package-level
1240/// deprecation finding, and a name whose fetch succeeded with no comparable versions
1241/// (#550) — folded into [`fetch_latest_versions_parallel`]'s aggregate `FetchResult`
1242/// once every package in the stream has finished.
1243type PackageFetchOutcome = (
1244    Option<(PackageName, PackageVersions)>,
1245    Option<(PackageName, ConcreteVersion, RemovalStatus)>,
1246    Option<(PackageName, FetchFailure, String)>,
1247    Option<(PackageName, Deprecation)>,
1248    Option<PackageName>,
1249);
1250
1251/// Fetches, classifies, and version-selects a single package within
1252/// [`fetch_latest_versions_parallel`]'s concurrent stream: one round trip for the full
1253/// version list, an in-memory "latest" pick with a `get_latest_matching_from` fallback
1254/// when the list-based pick fails on a non-empty list, yanked/deprecation extraction,
1255/// and updates to the shared `fetched`/`failed`/`first_error` counters the stream
1256/// aggregates across every package.
1257#[allow(
1258    clippy::too_many_arguments,
1259    reason = "mirrors the per-package async closure this was extracted from — every \
1260              parameter is either call-site fetch tuning already threaded through \
1261              fetch_latest_versions_parallel or a counter/sender shared across the \
1262              whole stream; grouping into a struct would only move, not reduce, churn"
1263)]
1264async fn fetch_and_classify_package(
1265    registry: &dyn Registry,
1266    name: PackageName,
1267    source: deps_core::parser::DependencySource,
1268    in_use_versions: Vec<String>,
1269    wildcard_req: &VersionReq,
1270    freshness: deps_core::freshness::FreshnessSettings,
1271    timeout: Duration,
1272    minimum_stability: Option<&str>,
1273    check_yanked: bool,
1274    fetched: &std::sync::atomic::AtomicUsize,
1275    failed: &std::sync::atomic::AtomicUsize,
1276    first_error: &std::sync::Mutex<Option<String>>,
1277    progress_sender: Option<&ProgressSender>,
1278) -> PackageFetchOutcome {
1279    // Single round trip: the full version list is fetched once, and "latest"
1280    // is a pure in-memory pick over it (`Registry::select_latest_matching`) —
1281    // no second registry call, so the retained full list costs nothing extra
1282    // over the network (see `PackageVersions`). `get_versions_from` (source-
1283    // aware, spec FR-001) rather than `get_versions`: this populates
1284    // `published_at` for registries that support it (#339), matching hover's
1285    // existing freshness-aware call, AND routes a resolved
1286    // `DependencySource::AlternateRegistry` to its own index instead of the
1287    // ecosystem's default registry — registries with no override forward
1288    // straight to `get_versions` at zero extra cost either way.
1289    let result = tokio::time::timeout(
1290        timeout,
1291        registry.get_versions_from(&name, &source, freshness),
1292    )
1293    .await;
1294
1295    let mut yanked: Option<(PackageName, ConcreteVersion, RemovalStatus)> = None;
1296    let mut failed_name: Option<(PackageName, FetchFailure, String)> = None;
1297    let mut deprecation: Option<(PackageName, Deprecation)> = None;
1298    // Set only when the fetch (and its `get_latest_matching` fallback) both
1299    // genuinely succeeded yet resolved to no version at all (#550) — see the
1300    // `Ok(Ok(None))` fallback arm below.
1301    let mut no_comparable_versions = false;
1302    let version = match result {
1303        Ok(Ok(versions)) => {
1304            let available: Arc<[ConcreteVersion]> = versions
1305                .iter()
1306                .map(|v| v.version_string().clone())
1307                .collect();
1308            // Retained alongside `available` so `generate_diagnostics_from_cache`
1309            // can flag a requirement satisfiable only by a yanked version — see
1310            // `PackageVersions::yanked`. Gated on `check_yanked`: a registry that
1311            // cannot answer `removal_status()` (§#298) must not populate this list
1312            // with an untrustworthy always-`Available` signal. Carries each
1313            // entry's own `RemovalStatus` (#437) so the #247 diagnostic path can
1314            // gate its package-level-deprecation suppression on `AdvisoryDeprecated`
1315            // specifically, never on a genuine `Yanked` finding.
1316            let yanked_list: Arc<[(ConcreteVersion, RemovalStatus)]> = if check_yanked {
1317                versions
1318                    .iter()
1319                    .filter_map(|v| {
1320                        let status = v.removal_status();
1321                        status
1322                            .is_flagged()
1323                            .then(|| (v.version_string().clone(), status))
1324                    })
1325                    .collect()
1326            } else {
1327                Arc::from([])
1328            };
1329            // `.get(idx)` rather than `versions[idx]`: `select_latest_matching`
1330            // is a public `Registry` trait method, so an out-of-tree
1331            // implementation returning a stale index must not panic this task.
1332            // `_with_context` (not the plain method) so a registry with
1333            // manifest-level stability state (Composer's `minimum-stability`,
1334            // #424 S1) can apply it — every other registry's default
1335            // implementation just forwards to the plain method unchanged.
1336            let resolved = if let Some(v) = registry
1337                .select_latest_matching_with_context(&versions, wildcard_req, minimum_stability)
1338                .and_then(|idx| versions.get(idx))
1339            {
1340                let latest = v.version_string().clone();
1341                tracing::debug!(package = %name, version = %latest, "fetched");
1342                Some((
1343                    latest,
1344                    v.removal_status(),
1345                    v.published_at(),
1346                    v.deprecation().cloned(),
1347                ))
1348            } else {
1349                // The pure list-based pick found nothing — for most
1350                // ecosystems this genuinely means "no version found", but
1351                // for a registry whose list endpoint can be incomplete
1352                // (e.g. Go's `/@v/list`, which never enumerates
1353                // pseudo-versions and can be entirely empty for an
1354                // untagged module) it may just mean the list alone isn't
1355                // enough. Fall back to the registry's own
1356                // `get_latest_matching`, which some registries answer from
1357                // a different, more complete source (Go's `/@latest`). This
1358                // costs a second network call, but only in this already-rare
1359                // "list-based pick failed" case, not the common path.
1360                let fallback = tokio::time::timeout(
1361                    timeout,
1362                    registry.get_latest_matching_from(
1363                        &name,
1364                        &source,
1365                        wildcard_req,
1366                        minimum_stability,
1367                    ),
1368                )
1369                .await;
1370                match fallback {
1371                    Ok(Ok(Some(v))) => {
1372                        let latest = v.version_string().clone();
1373                        tracing::debug!(
1374                            package = %name,
1375                            version = %latest,
1376                            "fetched via get_latest_matching fallback"
1377                        );
1378                        Some((
1379                            latest,
1380                            v.removal_status(),
1381                            v.published_at(),
1382                            v.deprecation().cloned(),
1383                        ))
1384                    }
1385                    Ok(Ok(None)) => {
1386                        tracing::debug!(package = %name, "no version found");
1387                        // Both the list-based pick and this fallback
1388                        // genuinely succeeded and found nothing — the
1389                        // package demonstrably exists (the fetch itself
1390                        // never errored), it just has zero versions this
1391                        // registry can compare against (#550), e.g. a
1392                        // repository whose only tags don't parse as full
1393                        // semver. Distinct from every branch below that
1394                        // sets `failed_name`.
1395                        no_comparable_versions = true;
1396                        None
1397                    }
1398                    Ok(Err(e)) => {
1399                        tracing::warn!(
1400                            package = %name,
1401                            error = %e,
1402                            "fetch fallback failed"
1403                        );
1404                        failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1405                        let mut fe = first_error.lock().unwrap_or_else(|p| p.into_inner());
1406                        if fe.is_none() {
1407                            *fe = Some(e.to_string());
1408                        }
1409                        drop(fe);
1410                        // A genuine not-found (the registry was
1411                        // successfully asked and said "no such
1412                        // package") is not a fetch failure — only
1413                        // an unanswerable request is (#267 C1).
1414                        if !e.is_not_found() {
1415                            failed_name = Some((name.clone(), e.fetch_failure(), e.to_string()));
1416                        }
1417                        None
1418                    }
1419                    Err(_) => {
1420                        tracing::warn!(
1421                            package = %name,
1422                            "fetch fallback timed out ({}s)",
1423                            timeout.as_secs()
1424                        );
1425                        failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1426                        failed_name = Some((
1427                            name.clone(),
1428                            FetchFailure::Transient,
1429                            format!(
1430                                "{name}: registry request timed out after {}s",
1431                                timeout.as_secs()
1432                            ),
1433                        ));
1434                        None
1435                    }
1436                }
1437            };
1438
1439            if check_yanked {
1440                // Row 1 (§4.7): the picked "latest" itself yanked —
1441                // zero extra cost, since it's already in hand.
1442                // Unreachable in production for an *enabled*
1443                // registry under today's hardcoded wildcard (one
1444                // never returns a yanked version for `*`), but
1445                // stays correct as a defense-in-depth check.
1446                if let Some((latest, status, _, _)) = &resolved
1447                    && status.is_flagged()
1448                {
1449                    yanked = Some((name.clone(), latest.clone(), *status));
1450                }
1451
1452                // Row 2/3 (§4.7, revised under #206): `versions`
1453                // is the full, already-fetched, unfiltered list —
1454                // no second registry round trip is needed to
1455                // check whether the in-use version was yanked,
1456                // unlike the pre-#206 probe design. Checked for
1457                // every dependency with a known in-use version,
1458                // not just when it differs from `latest`, since
1459                // it's now a free in-memory lookup either way. A
1460                // yanked in-use version wins over an already
1461                // -recorded yanked `latest` — it's the version
1462                // the user actually has.
1463                //
1464                // Multiple occurrences of the same name (#394,
1465                // e.g. under both `[dependencies]` and
1466                // `[target.*.dependencies]`) can carry different
1467                // in-use versions — every one is checked so a
1468                // yanked pin on any occurrence is never missed
1469                // just because another occurrence happens to
1470                // share the registry lookup.
1471                // Filters on `is_flagged()` inside the `find` predicate itself
1472                // (not via a separate `.filter()` on the first version-string
1473                // match) so a registry response with more than one entry sharing
1474                // `iv`'s version string still finds a flagged one if any exists —
1475                // mirroring the pre-#205 `.any(matches && flagged)` scan rather
1476                // than narrowing to "is the *first* same-string entry flagged".
1477                if let Some((iv, status)) = in_use_versions.iter().find_map(|iv| {
1478                    versions
1479                        .iter()
1480                        .find(|v| {
1481                            v.version_string() == iv.as_str() && v.removal_status().is_flagged()
1482                        })
1483                        .map(|v| (iv, v.removal_status()))
1484                }) {
1485                    yanked = Some((name.clone(), iv.as_str().into(), status));
1486                }
1487            }
1488
1489            // #205: the package-level deprecation finding is derived from the
1490            // same `Version` `resolved` already picked as "latest" — covering
1491            // the `get_latest_matching_with_context` fallback branch above too,
1492            // whose returned `Version` is not a member of `versions` at all. See
1493            // `FetchResult::deprecations`'s docs for why this must not instead
1494            // scan `versions`.
1495            if let Some((_, _, _, dep_info)) = &resolved
1496                && let Some(dep_info) = dep_info
1497            {
1498                deprecation = Some((name.clone(), dep_info.clone()));
1499            }
1500
1501            resolved.map(|(latest, _, published_at, _)| {
1502                (
1503                    name.clone(),
1504                    PackageVersions {
1505                        latest,
1506                        available,
1507                        yanked: yanked_list,
1508                        published_at,
1509                    },
1510                )
1511            })
1512        }
1513        Ok(Err(e)) => {
1514            // Issue #483: while offline, every fetch fails by design — this
1515            // would otherwise log a per-dependency WARNING for every open/edit,
1516            // contradicting the toast suppression two call sites away in this
1517            // same file for being "unusable".
1518            if e.is_offline() {
1519                tracing::debug!(package = %name, "fetch skipped: offline");
1520            } else {
1521                tracing::warn!(package = %name, error = %e, "fetch failed");
1522            }
1523            failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1524            let mut fe = first_error.lock().unwrap_or_else(|p| p.into_inner());
1525            if fe.is_none() {
1526                *fe = Some(e.to_string());
1527            }
1528            drop(fe);
1529            // A genuine not-found (the registry was successfully
1530            // asked and said "no such package") is not a fetch
1531            // failure — only an unanswerable request is (#267
1532            // C1). Marking it `fetch_failed` here would make
1533            // `generate_diagnostics_from_cache` report "Registry
1534            // lookup failed" for the common typo'd-name case
1535            // instead of "Unknown package", inverting the bug
1536            // this field exists to fix.
1537            if !e.is_not_found() {
1538                failed_name = Some((name.clone(), e.fetch_failure(), e.to_string()));
1539            }
1540            None
1541        }
1542        Err(_) => {
1543            tracing::warn!(package = %name, "fetch timed out ({}s)", timeout.as_secs());
1544            failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1545            failed_name = Some((
1546                name.clone(),
1547                FetchFailure::Transient,
1548                format!(
1549                    "{name}: registry request timed out after {}s",
1550                    timeout.as_secs()
1551                ),
1552            ));
1553            None
1554        }
1555    };
1556
1557    let count = fetched.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
1558    if let Some(sender) = progress_sender {
1559        sender.send(count);
1560    }
1561
1562    let no_comparable_versions_name = no_comparable_versions.then(|| name.clone());
1563    (
1564        version,
1565        yanked,
1566        failed_name,
1567        deprecation,
1568        no_comparable_versions_name,
1569    )
1570}
1571
1572/// Decides whether a fetch-failure toast should be shown for this fetch cycle, and what
1573/// its message should be — a pure decision, factored out of the two call sites in
1574/// [`handle_document_open`] and [`handle_document_change`] so both share one policy and the
1575/// policy itself is unit-testable without an LSP transport.
1576///
1577/// `failed_count` counts both genuine fetch failures and not-found lookups (see #276 S2),
1578/// so the message deliberately says "could not be resolved" rather than "failed to fetch"
1579/// or anything containing "lookup failed" — that phrasing is the exact inline "Registry
1580/// lookup failed" diagnostic text, which excludes not-found by design (#267 C1), so reusing
1581/// it here would recreate the same overcount confusion in different words. "could not be
1582/// resolved" covers both the "Registry lookup failed" and "Unknown package" diagnostic
1583/// outcomes, so the count stays checkable against their union (#490).
1584///
1585/// Returns `None` when there were no failures at all, or when `offline` is set (issue
1586/// #483): every fetch fails by design while `network.offline` is set, so toasting on every
1587/// document open/change would make offline mode unusable.
1588fn fetch_failure_toast(
1589    failed_count: usize,
1590    first_error: Option<&str>,
1591    offline: bool,
1592) -> Option<String> {
1593    if failed_count == 0 || offline {
1594        return None;
1595    }
1596    Some(format!(
1597        "deps-lsp: {failed_count} package(s) could not be resolved: {}",
1598        first_error.unwrap_or("timeout or network error")
1599    ))
1600}
1601
1602/// Generic document open handler using ecosystem registry.
1603///
1604/// Parses manifest using the ecosystem's parser, creates document state,
1605/// and spawns a background task to fetch version information from the registry.
1606pub async fn handle_document_open(
1607    uri: Uri,
1608    content: String,
1609    version: Option<i32>,
1610    state: Arc<ServerState>,
1611    client: Client,
1612    config: Arc<RwLock<DepsConfig>>,
1613) -> Result<JoinHandle<()>> {
1614    // Find appropriate ecosystem for this URI
1615    let ecosystem = match state.ecosystem_registry.get_for_uri(&uri) {
1616        Some(e) => e,
1617        None => {
1618            tracing::debug!("No ecosystem handler for {:?}", uri);
1619            return Err(deps_core::error::DepsError::UnsupportedEcosystem(format!(
1620                "{uri:?}"
1621            )));
1622        }
1623    };
1624
1625    check_content_size(&content, &uri)?;
1626
1627    tracing::info!(
1628        "Opening {:?} with ecosystem: {}",
1629        uri,
1630        ecosystem.display_name()
1631    );
1632
1633    // Try to parse manifest (may fail for incomplete syntax)
1634    let parse_result = ecosystem.parse_manifest(&content, &uri).await.ok();
1635
1636    // Create document state (parse_result may be None)
1637    let mut doc_state = if let Some(pr) = parse_result {
1638        DocumentState::new_from_parse_result(resolve_ecosystem_id(&*ecosystem), content, pr)
1639    } else {
1640        tracing::debug!("Failed to parse manifest, storing document without parse result");
1641        DocumentState::new_without_parse_result(resolve_ecosystem_id(&*ecosystem), content)
1642    };
1643    doc_state.set_version(version);
1644
1645    state.update_document(uri.clone(), doc_state);
1646
1647    // Clone cache, diagnostics, and freshness config before spawning background task
1648    // (all read here, before any OSV request is built, so disabling the feature
1649    // suppresses the network call itself — FR-011).
1650    let (cache_config, vulnerabilities_enabled, freshness_settings, diagnostic_severities, offline) = {
1651        let cfg = config.read().await;
1652        (
1653            cfg.cache.clone(),
1654            cfg.diagnostics.vulnerabilities_enabled,
1655            cfg.freshness.to_settings(),
1656            cfg.diagnostics.to_severities(),
1657            cfg.network.offline,
1658        )
1659    };
1660
1661    // Spawn background task to fetch versions
1662    let task = tokio::spawn(run_document_open_background_task(
1663        uri.clone(),
1664        Arc::clone(&state),
1665        Arc::clone(&ecosystem),
1666        client.clone(),
1667        cache_config,
1668        vulnerabilities_enabled,
1669        freshness_settings,
1670        diagnostic_severities,
1671        offline,
1672    ));
1673
1674    Ok(task)
1675}
1676
1677/// The background task [`handle_document_open`] spawns: loads lockfile-resolved
1678/// versions instantly (no network), seeds them as cached versions, kicks off the OSV
1679/// Phase A scan concurrently with the registry fetch, runs the registry fetch,
1680/// commits its results (cached versions, outcomes, loading state), then refreshes
1681/// inlay hints, joins OSV Phase B, and publishes diagnostics.
1682#[allow(
1683    clippy::too_many_arguments,
1684    reason = "mirrors the async move closure this was extracted from — every parameter \
1685              is config already read (and thus fixed) before the task was spawned in \
1686              handle_document_open, so a config struct here would only relocate, not \
1687              reduce, the parameter list"
1688)]
1689async fn run_document_open_background_task(
1690    uri: Uri,
1691    state: Arc<ServerState>,
1692    ecosystem: Arc<dyn Ecosystem>,
1693    client: Client,
1694    cache_config: crate::config::CacheConfig,
1695    vulnerabilities_enabled: bool,
1696    freshness_settings: deps_core::freshness::FreshnessSettings,
1697    diagnostic_severities: deps_core::DiagnosticSeverities,
1698    offline: bool,
1699) {
1700    tracing::debug!("background task started");
1701
1702    // Load resolved versions from lock file first (instant, no network)
1703    let resolved_versions = load_resolved_versions(&uri, &state, ecosystem.as_ref()).await;
1704
1705    // Update document state with resolved versions immediately
1706    if !resolved_versions.is_empty()
1707        && let Some(mut doc) = state.documents.get_mut(&uri)
1708    {
1709        doc.update_resolved_versions(resolved_versions.clone());
1710
1711        // Use resolved versions as cached versions for instant display,
1712        // except for a dependency whose manifest requirement is itself
1713        // already the resolved version (Go's `require` lines) — for
1714        // those, go.sum can hold a stale, no-longer-selected version
1715        // (#235), so seeding it as the "latest" comparison operand would
1716        // desync hover/inlay-hint status against the go.mod-accurate
1717        // `resolved` value during the cold-open window before the
1718        // registry fetch completes (critique S1).
1719        let formatter = ecosystem.formatter();
1720        let instant_resolved: HashMap<PackageName, ConcreteVersion> = match doc.parse_result() {
1721            Some(parse_result) => {
1722                let deps = parse_result.dependencies();
1723                resolved_versions
1724                    .iter()
1725                    .filter(|(name, _)| {
1726                        deps.iter()
1727                            .find(|d| d.name() == *name)
1728                            .is_none_or(|d| !formatter.manifest_requirement_is_resolved_version(*d))
1729                    })
1730                    .map(|(name, version)| (name.clone(), version.clone()))
1731                    .collect()
1732            }
1733            None => resolved_versions.clone(),
1734        };
1735        doc.update_cached_versions(cached_versions_from_lockfile(&instant_resolved));
1736    }
1737
1738    // Phase A OSV scan, spawned so it runs concurrently with the
1739    // registry fetch below rather than gating the inlay-hint refresh
1740    // that must happen immediately after it (critique S2).
1741    let osv_task = vulnerabilities_enabled.then(|| {
1742        tokio::spawn(run_osv_scan_phase_a(
1743            uri.clone(),
1744            Arc::clone(&state),
1745            Arc::clone(&ecosystem),
1746            cache_config.fetch_timeout_secs,
1747        ))
1748    });
1749
1750    // Collect dependency names+sources and the in-use-version map (§4.6) in one
1751    // pass while holding the reference (can't hold across await).
1752    let (dep_sources, in_use, minimum_stability, collided_names): (
1753        DepSources,
1754        HashMap<PackageName, Vec<String>>,
1755        Option<String>,
1756        HashSet<PackageName>,
1757    ) = {
1758        let doc = match state.get_document(&uri) {
1759            Some(d) => d,
1760            None => {
1761                tracing::warn!("document not found, aborting fetch");
1762                return;
1763            }
1764        };
1765        let parse_result = match doc.parse_result() {
1766            Some(p) => p,
1767            None => {
1768                tracing::warn!("no parse result, aborting fetch");
1769                return;
1770            }
1771        };
1772        // Deduped by name (critique M3): a duplicated name shares one
1773        // registry fetch across all its occurrences — the result is
1774        // name-keyed anyway (`FetchResult::versions`), so fetching it
1775        // more than once would only issue wasted extra registry calls
1776        // and inflate `RegistryProgress`'s total. A non-resolvable source is
1777        // dropped entirely, and two occurrences of the same name resolving to
1778        // *different* sources are dropped and recorded as collided instead
1779        // (spec FR-011) — see `dedup_dependencies_by_source`.
1780        let (sources_map, collided_names) =
1781            dedup_dependencies_by_source(parse_result, ecosystem.formatter());
1782        let dep_sources: Vec<_> = sources_map.into_iter().collect();
1783        let in_use = collect_in_use_versions(
1784            parse_result,
1785            &resolved_versions,
1786            ecosystem.formatter(),
1787            resolve_ecosystem_id(ecosystem.as_ref()),
1788        );
1789        let minimum_stability = composer_minimum_stability(parse_result);
1790        (dep_sources, in_use, minimum_stability, collided_names)
1791    };
1792
1793    tracing::debug!(count = dep_sources.len(), "starting registry fetch");
1794
1795    // Bounds total outbound fetch concurrency across every open/changed document
1796    // server-wide (issue #592 critic S2/M1). Acquired *before* `set_loading()`/
1797    // `RegistryProgress::start` below, not just around the fetch: acquiring only around
1798    // the fetch would set every queued document to `Loading` (and open a progress bar for
1799    // each) up front during a cold-start burst, before any permit arrives — reproducing
1800    // the same diagnostic-suppression shape this cap exists to bound, plus N stuck
1801    // progress notifications. `P` is deliberately independent of
1802    // `cache.max_concurrent_fetches` (that bounds dependencies within one document's
1803    // fetch; this bounds documents fetching at once).
1804    let fetch_permit = state
1805        .fetch_permits
1806        .acquire()
1807        .await
1808        .expect("fetch_permits semaphore is never closed");
1809
1810    // Mark as loading and start progress
1811    if let Some(mut doc) = state.documents.get_mut(&uri) {
1812        doc.set_loading();
1813    }
1814
1815    let (progress, progress_sender) = if state.supports_progress() {
1816        match tokio::time::timeout(
1817            std::time::Duration::from_secs(2),
1818            RegistryProgress::start(client.clone(), uri.as_str(), dep_sources.len()),
1819        )
1820        .await
1821        {
1822            Ok(Ok((p, s))) => (Some(p), Some(s)),
1823            _ => (None, None),
1824        }
1825    } else {
1826        (None, None)
1827    };
1828
1829    tracing::debug!("progress started, fetching versions");
1830
1831    // Fetch latest versions from registry in parallel (for update hints)
1832    let registry = ecosystem.registry();
1833    let fetch_result = fetch_latest_versions_parallel(
1834        registry,
1835        dep_sources,
1836        &in_use,
1837        progress_sender,
1838        freshness_settings,
1839        cache_config.fetch_timeout_secs,
1840        cache_config.max_concurrent_fetches,
1841        minimum_stability.as_deref(),
1842    )
1843    .await;
1844    drop(fetch_permit);
1845
1846    let success = !fetch_result.versions.is_empty();
1847    tracing::debug!(
1848        fetched = fetch_result.versions.len(),
1849        failed = fetch_result.failed_count,
1850        yanked = fetch_result.yanked_versions.len(),
1851        "registry fetch complete"
1852    );
1853
1854    // Update document state with cached versions (latest from registry)
1855    if let Some(mut doc) = state.documents.get_mut(&uri) {
1856        doc.update_cached_versions(fetch_result.versions);
1857        // Re-key raw -> normalized (§3.1): `FetchResult`'s three fields are
1858        // raw-keyed, `DocumentState::outcomes` is normalized.
1859        let formatter = ecosystem.formatter();
1860        let mut outcomes = DependencyOutcomes::new();
1861        for (name, d) in fetch_result.deprecations {
1862            outcomes.set_deprecation(formatter.normalize_package_name(&name), d);
1863        }
1864        for (name, v) in fetch_result.yanked_versions {
1865            outcomes.set_yanked(formatter.normalize_package_name(&name), v);
1866        }
1867        for (name, failure) in fetch_result.fetch_failed {
1868            outcomes.set_fetch_failure(formatter.normalize_package_name(&name), failure);
1869        }
1870        for name in fetch_result.no_comparable_versions {
1871            outcomes.set_no_comparable_versions(formatter.normalize_package_name(&name));
1872        }
1873        // `collided_names` (spec FR-011) are merged in alongside genuine fetch
1874        // failures so `generate_diagnostics_from_cache` reports "lookup could not
1875        // be determined" rather than a false "Unknown package" for a name that
1876        // was deliberately never queried, not one that doesn't exist. Genuine
1877        // failures are inserted first and `collided_names` uses
1878        // `set_fetch_failure_if_absent` so a collided name that normalizes to the
1879        // same key as a genuine `Actionable`/`Transient` failure never clobbers it
1880        // (impl-critic M2).
1881        for name in collided_names {
1882            outcomes.set_fetch_failure_if_absent(
1883                formatter.normalize_package_name(&name),
1884                FetchFailure::NotAttempted,
1885            );
1886        }
1887        doc.replace_outcomes(outcomes);
1888        if success {
1889            doc.set_loaded();
1890        } else {
1891            doc.set_failed();
1892        }
1893    }
1894
1895    // End progress
1896    if let Some(progress) = progress {
1897        progress.end(success).await;
1898    }
1899
1900    // Notify user about failed packages — suppressed when offline, see
1901    // `fetch_failure_toast`'s docs. `fetch_result.first_error` is always populated
1902    // by `fetch_latest_versions_parallel` whenever `failed_count > 0` (#480: every
1903    // site that increments `failed_count` also sets either `priority_error` or
1904    // `first_error`, and the two are merged into this field before returning).
1905    match fetch_failure_toast(
1906        fetch_result.failed_count,
1907        fetch_result.first_error.as_deref(),
1908        state.cache.is_offline(),
1909    ) {
1910        Some(message) => {
1911            client.show_message(MessageType::WARNING, message).await;
1912        }
1913        None if fetch_result.failed_count > 0 => {
1914            tracing::debug!(
1915                failed_count = fetch_result.failed_count,
1916                "suppressing fetch-failure toast: offline"
1917            );
1918        }
1919        None => {}
1920    }
1921
1922    // Kick off inlay hint / code lens refresh as soon as loading completes, so
1923    // clients see updated hints as early as possible — typically before
1924    // diagnostics, which may take longer due to additional network calls, though
1925    // that ordering is scheduler-dependent, not guaranteed, since the requests
1926    // are detached (issue #493: nothing downstream depends on their result, and a
1927    // client that never declared refresh support, or stops replying, must not
1928    // hang this task's critical path — including the OSV commit and diagnostics
1929    // publish below — forever).
1930    state.spawn_refresh_requests(&client);
1931
1932    // Join phase A (already running concurrently since it was spawned
1933    // above) and, only now that `cached_versions` holds the registry's
1934    // actual latest (not the lockfile-seeded placeholder — critique S1),
1935    // run phase B and commit before generating diagnostics.
1936    if let Some(osv_task) = osv_task {
1937        match osv_task.await {
1938            Ok(Some(phase_a_result)) => {
1939                let ecosystem_id = resolve_ecosystem_id(ecosystem.as_ref());
1940                run_osv_phase_b_and_commit(
1941                    &uri,
1942                    &state,
1943                    ecosystem_id,
1944                    ecosystem.formatter(),
1945                    cache_config.fetch_timeout_secs,
1946                    phase_a_result,
1947                )
1948                .await;
1949            }
1950            Ok(None) => {}
1951            Err(e) => tracing::warn!("OSV scan task failed: {e}"),
1952        }
1953    }
1954
1955    // Publish diagnostics (may be slower, runs after hints are already visible)
1956    let diags = diagnostics::generate_diagnostics_internal(
1957        Arc::clone(&state),
1958        &uri,
1959        freshness_settings,
1960        diagnostic_severities,
1961        offline,
1962    )
1963    .await;
1964
1965    client.publish_diagnostics(uri.clone(), diags, None).await;
1966}
1967
1968/// Parses the freshly-edited manifest content and diffs its dependencies against the
1969/// document's previously stored parse result, so the caller can react to what actually
1970/// changed (added/removed/version-changed) instead of unconditionally re-fetching and
1971/// re-scanning everything on every keystroke.
1972async fn parse_and_diff_manifest(
1973    uri: &Uri,
1974    content: &str,
1975    state: &ServerState,
1976    ecosystem: &dyn Ecosystem,
1977) -> (Option<Box<dyn deps_core::ParseResult>>, DependencyDiff) {
1978    // Extract old dependency name -> version_requirement map before parsing
1979    // (for diff computation)
1980    let old_deps: HashMap<PackageName, Vec<Option<VersionReq>>> =
1981        state.get_document(uri).map_or_else(HashMap::new, |doc| {
1982            doc.parse_result()
1983                .map(dependency_version_map)
1984                .unwrap_or_default()
1985        });
1986
1987    // Try to parse manifest (may fail for incomplete syntax)
1988    let parse_result = ecosystem.parse_manifest(content, uri).await.ok();
1989
1990    // Extract new dependency name -> version_requirement map for diff
1991    let new_deps: HashMap<PackageName, Vec<Option<VersionReq>>> = parse_result
1992        .as_ref()
1993        .map(|pr| dependency_version_map(pr.as_ref()))
1994        .unwrap_or_default();
1995
1996    // Compute dependency diff
1997    let diff = DependencyDiff::compute(&old_deps, &new_deps);
1998    tracing::debug!(
1999        added = diff.added.len(),
2000        removed = diff.removed.len(),
2001        version_changed = diff.version_changed.len(),
2002        "dependency diff"
2003    );
2004
2005    (parse_result, diff)
2006}
2007
2008/// Whether [`commit_parsed_document`] should verify the document's current version before
2009/// committing — see that function's doc for why this exists.
2010#[derive(Debug, Clone, Copy)]
2011pub(crate) enum CommitGuard {
2012    /// Always commit — a real edit's own version is authoritative, nothing to guard against.
2013    Unconditional,
2014    /// Commit only if the document's *current* version in `state` still equals this value;
2015    /// otherwise skip the commit entirely (impl-critic S1).
2016    ExpectVersion(Option<i32>),
2017}
2018
2019/// Bundles [`commit_parsed_document`]'s two commit-behavior parameters — kept as one struct
2020/// (rather than two more positional parameters) to stay under `clippy::too_many_arguments`.
2021struct CommitOptions<'a> {
2022    diff: &'a DependencyDiff,
2023    guard: CommitGuard,
2024}
2025
2026/// Whether a reparse should only re-fetch what [`DependencyDiff`] calls for, or force a
2027/// full re-fetch of every dependency regardless of diff (issue #592).
2028///
2029/// A config change that alters registry *routing* (`registries.workspace_registries`,
2030/// `registries.nuget_user_profile_sources`) feeds `DependencyDiff::compute` an unchanged
2031/// dependency set — the manifest didn't change, only where its dependencies resolve from —
2032/// so the default [`Self::Diff`] policy's `deps_to_fetch` would stay empty and
2033/// [`run_document_change_task`]'s `deps_to_fetch.is_empty()` early return would leave the
2034/// document displaying versions resolved under the *old* routing indefinitely (the same bug
2035/// class #424 already documents for `minimum-stability`).
2036#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2037pub(crate) enum RefetchPolicy {
2038    /// Re-fetch only what `DependencyDiff` calls for (added / version-changed dependencies)
2039    /// — correct for every real document edit, where the diff already answers "what needs
2040    /// re-checking".
2041    Diff,
2042    /// Force a re-fetch of every dependency in the new parse result, and drop any
2043    /// previously cached version/fetch-failure data before doing so (in
2044    /// [`fetch_registry_versions_for_change`]) — the routing itself changed, so data
2045    /// obtained under the old routing can no longer be vouched for.
2046    AllDependencies,
2047}
2048
2049/// Builds the new `DocumentState` from the parsed manifest (or a parse-result-less
2050/// placeholder when parsing failed), carries over cache entries the previous state already
2051/// held, prunes/invalidates entries `options.diff` says are now stale, and commits the result
2052/// into `state` — unless `options.guard` is [`CommitGuard::ExpectVersion`] and the document's
2053/// *current* version in `state` no longer matches, in which case the commit is skipped
2054/// entirely and `false` is returned.
2055///
2056/// The guard exists for a reparse whose trigger is *not* the document's own edit stream —
2057/// e.g. [`crate::server::Backend::handle_watched_config_change`] (issue #590), which reparses
2058/// every open document of an ecosystem using content it snapshotted before the (awaited)
2059/// re-parse ran. Without this check, a `did_change` notification landing while that reparse
2060/// is in flight gets silently reverted: this function would otherwise commit unconditionally,
2061/// overwriting the newer edit with the older, watched-config-triggered content (impl-critic
2062/// S1). [`handle_document_change`] passes [`CommitGuard::Unconditional`] — a real edit's own
2063/// version is authoritative, there is nothing to guard against.
2064fn commit_parsed_document(
2065    uri: &Uri,
2066    ecosystem: &dyn Ecosystem,
2067    content: String,
2068    parse_result: Option<Box<dyn deps_core::ParseResult>>,
2069    version: Option<i32>,
2070    state: &ServerState,
2071    options: CommitOptions<'_>,
2072) -> bool {
2073    let diff = options.diff;
2074    if let CommitGuard::ExpectVersion(expected) = options.guard {
2075        let current = state.with_document(uri, |doc| doc.version);
2076        if current != Some(expected) {
2077            tracing::debug!(
2078                ?uri,
2079                ?expected,
2080                ?current,
2081                "skipping stale reparse commit: document version changed since this reparse started"
2082            );
2083            return false;
2084        }
2085    }
2086
2087    let mut doc_state = if let Some(pr) = parse_result {
2088        DocumentState::new_from_parse_result(resolve_ecosystem_id(ecosystem), content, pr)
2089    } else {
2090        tracing::debug!("Failed to parse manifest, storing document without parse result");
2091        DocumentState::new_without_parse_result(resolve_ecosystem_id(ecosystem), content)
2092    };
2093    doc_state.set_version(version);
2094
2095    if let Some(old_doc) = state.get_document(uri) {
2096        preserve_cache(&mut doc_state, &old_doc);
2097    }
2098
2099    // Prune stale cache entries for removed dependencies. `vulnerabilities`
2100    // is keyed by the *normalized* name (unlike `cached_versions`/
2101    // `resolved_versions`, which are raw-`dep.name()`-keyed), so pruning it
2102    // with the raw name would silently no-op for Composer/Swift/NuGet-style
2103    // ecosystems where normalization changes the string (critique M4).
2104    let formatter = ecosystem.formatter();
2105    for removed_dep in &diff.removed {
2106        doc_state.cached_versions.remove(removed_dep);
2107        doc_state.resolved_versions.remove(removed_dep);
2108        doc_state
2109            .vulnerabilities
2110            .remove(&formatter.normalize_package_name(removed_dep));
2111        doc_state
2112            .outcomes
2113            .remove(&formatter.normalize_package_name(removed_dep));
2114    }
2115
2116    // A version-only edit (name unchanged, requirement changed) invalidates
2117    // any yanked finding recorded against the dependency's *old* version —
2118    // e.g. editing a yanked pin to a safe one must not leave a stale
2119    // diagnostic anchored on the new range (security F1 / impl-critic S1).
2120    // Drop rather than try to refresh in place; the registry re-fetch that
2121    // follows in the caller (`deps_to_fetch` includes `version_changed`)
2122    // repopulates the entry if the *new* version also turns out to be
2123    // yanked. Same for `fetch_failed` (#267): a stale fetch-error marker
2124    // must not survive an edit that gets re-fetched below.
2125    //
2126    // Deliberately NOT mirrored for `deprecations`: #205's finding is
2127    // package-level, derived from `latest`, not the dependency's declared
2128    // version — editing which version is pinned does not make the package
2129    // any less (or more) deprecated, so there is nothing stale to drop here.
2130    for changed_dep in &diff.version_changed {
2131        let normalized = formatter.normalize_package_name(changed_dep);
2132        doc_state.outcomes.clear_yanked(&normalized);
2133        doc_state.outcomes.clear_fetch_failure(&normalized);
2134    }
2135
2136    state.update_document(uri.clone(), doc_state);
2137    true
2138}
2139
2140/// Generic document change handler using ecosystem registry.
2141///
2142/// Re-parses manifest when document content changes and spawns a debounced
2143/// task to update diagnostics and request inlay hint refresh.
2144pub async fn handle_document_change(
2145    uri: Uri,
2146    content: String,
2147    version: Option<i32>,
2148    state: Arc<ServerState>,
2149    client: Client,
2150    config: Arc<RwLock<DepsConfig>>,
2151) -> Result<JoinHandle<()>> {
2152    let task = handle_document_change_guarded(
2153        uri,
2154        content,
2155        version,
2156        CommitGuard::Unconditional,
2157        RefetchPolicy::Diff,
2158        state,
2159        client,
2160        config,
2161    )
2162    .await?;
2163    Ok(task.expect("CommitGuard::Unconditional never skips the commit"))
2164}
2165
2166/// Like [`handle_document_change`], but skips committing the reparse — and spawning its
2167/// diagnostics-refresh background task — if `guard` is [`CommitGuard::ExpectVersion`] and the
2168/// document's version in `state` no longer matches by the time this reparse finishes; see
2169/// [`commit_parsed_document`]'s doc for why.
2170///
2171/// Returns `Ok(None)` on a skip, never a sentinel/no-op `JoinHandle` (impl-critic S3): the
2172/// caller ([`crate::server::Backend::handle_watched_config_change`]) feeds the returned handle
2173/// straight into [`ServerState::spawn_background_task`], which unconditionally **aborts** any
2174/// existing task registered for the URI before installing the new one. A sentinel handle for
2175/// a skipped, superseded reparse would therefore abort the concurrent edit's *real* background
2176/// task (registry fetch, OSV rescan, `publish_diagnostics`) that a matching-version commit
2177/// already installed — silently dropping that newer edit's diagnostics until the next
2178/// keystroke. The caller must treat `None` as "do not touch the task registry for this URI at
2179/// all", not as "install a no-op task".
2180///
2181/// [`handle_document_change`] passes [`CommitGuard::Unconditional`] and unwraps the `Some`
2182/// unconditionally (preserving its exact prior behavior and `Result<JoinHandle<()>>` return
2183/// type) — only a reparse triggered by something other than the document's own edit stream
2184/// needs a real guard, and therefore ever observes `None`.
2185#[allow(
2186    clippy::too_many_arguments,
2187    reason = "issue #592 added `refetch: RefetchPolicy` alongside the pre-existing `guard: \
2188              CommitGuard` — both are commit/fetch-behavior switches the caller must set \
2189              independently; bundling them into one struct would only relocate, not reduce, \
2190              the parameter list"
2191)]
2192pub(crate) async fn handle_document_change_guarded(
2193    uri: Uri,
2194    content: String,
2195    version: Option<i32>,
2196    guard: CommitGuard,
2197    refetch: RefetchPolicy,
2198    state: Arc<ServerState>,
2199    client: Client,
2200    config: Arc<RwLock<DepsConfig>>,
2201) -> Result<Option<JoinHandle<()>>> {
2202    // Find appropriate ecosystem for this URI
2203    let ecosystem = match state.ecosystem_registry.get_for_uri(&uri) {
2204        Some(e) => e,
2205        None => {
2206            tracing::debug!("No ecosystem handler for {:?}", uri);
2207            return Err(deps_core::error::DepsError::UnsupportedEcosystem(format!(
2208                "{uri:?}"
2209            )));
2210        }
2211    };
2212
2213    check_content_size(&content, &uri)?;
2214
2215    let (parse_result, diff) =
2216        parse_and_diff_manifest(&uri, &content, &state, ecosystem.as_ref()).await;
2217
2218    // Captured before `commit_parsed_document` consumes `parse_result` — only needed under
2219    // `RefetchPolicy::AllDependencies` (issue #592), where the fetch must cover every
2220    // dependency in the new manifest rather than only what `diff` calls for: a
2221    // routing-only config change leaves the manifest's dependency set unchanged, so `diff`
2222    // alone would see nothing to fetch.
2223    let all_dependency_names: Vec<PackageName> = match refetch {
2224        RefetchPolicy::Diff => Vec::new(),
2225        RefetchPolicy::AllDependencies => parse_result
2226            .as_deref()
2227            .map(|pr| dependency_version_map(pr).into_keys().collect())
2228            .unwrap_or_default(),
2229    };
2230
2231    if !commit_parsed_document(
2232        &uri,
2233        ecosystem.as_ref(),
2234        content,
2235        parse_result,
2236        version,
2237        &state,
2238        CommitOptions { diff: &diff, guard },
2239    ) {
2240        return Ok(None);
2241    }
2242
2243    // Clone cache, diagnostics, and freshness config before spawning background task
2244    // (all read here, before any OSV request is built — FR-011).
2245    let (cache_config, vulnerabilities_enabled, freshness_settings, diagnostic_severities, offline) = {
2246        let cfg = config.read().await;
2247        (
2248            cfg.cache.clone(),
2249            cfg.diagnostics.vulnerabilities_enabled,
2250            cfg.freshness.to_settings(),
2251            cfg.diagnostics.to_severities(),
2252            cfg.network.offline,
2253        )
2254    };
2255
2256    let needs_osv_rescan = diff.needs_osv_rescan();
2257    let deps_to_fetch = match refetch {
2258        RefetchPolicy::Diff => {
2259            // The yanked probe must also re-run for a version-only edit, not just a
2260            // newly added dependency — otherwise editing a dependency's pin from a
2261            // safe version to a yanked one would never be checked, since an empty
2262            // `deps_to_fetch` skips the entire registry fetch below (security F1).
2263            let mut v = diff.added;
2264            v.extend(diff.version_changed);
2265            v
2266        }
2267        RefetchPolicy::AllDependencies => all_dependency_names,
2268    };
2269
2270    // Spawn background task to update diagnostics
2271    let task = tokio::spawn(run_document_change_task(
2272        uri,
2273        state,
2274        ecosystem,
2275        client,
2276        ChangeTaskConfig {
2277            cache: cache_config,
2278            vulnerabilities_enabled,
2279            freshness: freshness_settings,
2280            diagnostic_severities,
2281            offline,
2282            refetch,
2283        },
2284        needs_osv_rescan,
2285        deps_to_fetch,
2286    ));
2287
2288    Ok(Some(task))
2289}
2290
2291/// Config values snapshotted from `DepsConfig` before spawning [`run_document_change_task`]
2292/// (FR-011: all read before any OSV request is built), so the task never needs to hold the
2293/// config lock itself. Bundled into one struct rather than five parameters since every field
2294/// is captured together by the same snapshot in [`handle_document_change`].
2295struct ChangeTaskConfig {
2296    cache: crate::config::CacheConfig,
2297    vulnerabilities_enabled: bool,
2298    freshness: deps_core::FreshnessSettings,
2299    diagnostic_severities: deps_core::DiagnosticSeverities,
2300    offline: bool,
2301    refetch: RefetchPolicy,
2302}
2303
2304/// Background task spawned by [`handle_document_change`] once the new document state has
2305/// been committed: reloads lock-file-resolved versions, then runs the OSV rescan
2306/// concurrently with any registry fetch the diff calls for, and finally publishes the
2307/// resulting diagnostics.
2308async fn run_document_change_task(
2309    uri: Uri,
2310    state: Arc<ServerState>,
2311    ecosystem: Arc<dyn Ecosystem>,
2312    client: Client,
2313    config: ChangeTaskConfig,
2314    needs_osv_rescan: bool,
2315    deps_to_fetch: Vec<PackageName>,
2316) {
2317    // Small debounce delay
2318    tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
2319
2320    // Load resolved versions from lock file first (instant, no network)
2321    let resolved_versions = load_resolved_versions(&uri, &state, ecosystem.as_ref()).await;
2322
2323    // Update document state with resolved versions only
2324    // Do NOT touch cached_versions - they contain latest registry versions
2325    if !resolved_versions.is_empty()
2326        && let Some(mut doc) = state.documents.get_mut(&uri)
2327    {
2328        doc.update_resolved_versions(resolved_versions.clone());
2329    }
2330
2331    // Phase A OSV scan (only when a dependency was added or an existing
2332    // one's version changed — critique S1), spawned so it runs
2333    // concurrently with the registry fetch below.
2334    let osv_task = (config.vulnerabilities_enabled && needs_osv_rescan).then(|| {
2335        tokio::spawn(run_osv_scan_phase_a(
2336            uri.clone(),
2337            Arc::clone(&state),
2338            Arc::clone(&ecosystem),
2339            config.cache.fetch_timeout_secs,
2340        ))
2341    });
2342
2343    // Skip registry fetch if nothing new was added and no existing
2344    // dependency's version changed.
2345    //
2346    // Known limitation (#424 N2): editing composer.json's `minimum-stability` field alone
2347    // adds no dependency and changes no requirement string, so `deps_to_fetch` stays empty
2348    // and this early-return skips the fetch — existing dependencies keep their
2349    // `cached_versions` computed under the *previous* stability floor until the document
2350    // is closed and reopened. Not fixed here: doing so would mean treating a
2351    // `minimum_stability` change as its own full-refetch trigger in the diff above, a
2352    // separate concern from #424's parse+thread scope.
2353    if deps_to_fetch.is_empty() {
2354        tracing::debug!("no added or version-changed dependencies, skipping registry fetch");
2355
2356        if let Some(mut doc) = state.documents.get_mut(&uri) {
2357            doc.set_loaded();
2358        }
2359
2360        // Detached, capability-gated, timeout-bounded (issue #493): see
2361        // `ServerState::spawn_refresh_requests` for rationale.
2362        state.spawn_refresh_requests(&client);
2363
2364        await_and_commit_osv_phase_b(
2365            osv_task,
2366            &uri,
2367            &state,
2368            ecosystem.as_ref(),
2369            config.cache.fetch_timeout_secs,
2370        )
2371        .await;
2372
2373        generate_and_publish_diagnostics(
2374            &state,
2375            &uri,
2376            &client,
2377            config.freshness,
2378            config.diagnostic_severities,
2379            config.offline,
2380        )
2381        .await;
2382        return;
2383    }
2384
2385    // Bounds total outbound fetch concurrency across every open/changed document
2386    // server-wide (issue #592 S2/S3). Held across the fetch and the merge below, released
2387    // before the failure toast / OSV phase B / diagnostics publish — none of those take a
2388    // permit themselves, and OSV phase A (spawned separately, above) never does either, so
2389    // there is no permit-holder-awaits-permit-taker deadlock shape here.
2390    let fetch_permit = state
2391        .fetch_permits
2392        .acquire()
2393        .await
2394        .expect("fetch_permits semaphore is never closed");
2395
2396    let (progress, fetch_result, attempted_names, collided_names) =
2397        fetch_registry_versions_for_change(
2398            &uri,
2399            &state,
2400            &client,
2401            ecosystem.as_ref(),
2402            &resolved_versions,
2403            deps_to_fetch,
2404            config.freshness,
2405            config.cache.fetch_timeout_secs,
2406            config.cache.max_concurrent_fetches,
2407            config.refetch,
2408        )
2409        .await;
2410
2411    let success = !fetch_result.versions.is_empty();
2412
2413    // Merge new versions into existing cache
2414    let (failed_count, first_error) = merge_registry_fetch_result(
2415        &state,
2416        &uri,
2417        ecosystem.formatter(),
2418        fetch_result,
2419        &attempted_names,
2420        collided_names,
2421        success,
2422    );
2423    drop(fetch_permit);
2424
2425    if let Some(progress) = progress {
2426        progress.end(success).await;
2427    }
2428
2429    // Notify user about failed packages — suppressed when offline, see
2430    // `fetch_failure_toast`'s docs. `fetch_result.first_error` is always populated
2431    // by `fetch_latest_versions_parallel` whenever `failed_count > 0` (#480: every
2432    // site that increments `failed_count` also sets either `priority_error` or
2433    // `first_error`, and the two are merged into this field before returning).
2434    match fetch_failure_toast(
2435        failed_count,
2436        first_error.as_deref(),
2437        state.cache.is_offline(),
2438    ) {
2439        Some(message) => {
2440            client.show_message(MessageType::WARNING, message).await;
2441        }
2442        None if failed_count > 0 => {
2443            tracing::debug!(failed_count, "suppressing fetch-failure toast: offline");
2444        }
2445        None => {}
2446    }
2447
2448    // Detached, capability-gated, timeout-bounded (issue #493): see
2449    // `ServerState::spawn_refresh_requests` for rationale.
2450    state.spawn_refresh_requests(&client);
2451
2452    await_and_commit_osv_phase_b(
2453        osv_task,
2454        &uri,
2455        &state,
2456        ecosystem.as_ref(),
2457        config.cache.fetch_timeout_secs,
2458    )
2459    .await;
2460
2461    generate_and_publish_diagnostics(
2462        &state,
2463        &uri,
2464        &client,
2465        config.freshness,
2466        config.diagnostic_severities,
2467        config.offline,
2468    )
2469    .await;
2470}
2471
2472/// Awaits the concurrently-spawned OSV phase-A scan, if one was started, and — when it
2473/// produced a result — runs phase B against the now-resolved registry versions and commits
2474/// the outcome. Shared by both branches of [`run_document_change_task`] (nothing to fetch
2475/// vs. a full registry fetch), which otherwise diverge before OSV handling but must treat
2476/// it identically.
2477async fn await_and_commit_osv_phase_b(
2478    osv_task: Option<JoinHandle<Option<OsvScanResult>>>,
2479    uri: &Uri,
2480    state: &Arc<ServerState>,
2481    ecosystem: &dyn Ecosystem,
2482    fetch_timeout_secs: u64,
2483) {
2484    let Some(osv_task) = osv_task else {
2485        return;
2486    };
2487    match osv_task.await {
2488        Ok(Some(phase_a_result)) => {
2489            let ecosystem_id = resolve_ecosystem_id(ecosystem);
2490            run_osv_phase_b_and_commit(
2491                uri,
2492                state,
2493                ecosystem_id,
2494                ecosystem.formatter(),
2495                fetch_timeout_secs,
2496                phase_a_result,
2497            )
2498            .await;
2499        }
2500        Ok(None) => {}
2501        Err(e) => tracing::warn!("OSV scan task failed: {e}"),
2502    }
2503}
2504
2505/// Generates diagnostics from the current document/cache state and publishes them to the
2506/// client. Shared by both branches of [`run_document_change_task`], each of which must end
2507/// with an up-to-date publish regardless of whether a registry fetch actually ran.
2508async fn generate_and_publish_diagnostics(
2509    state: &Arc<ServerState>,
2510    uri: &Uri,
2511    client: &Client,
2512    freshness_settings: deps_core::FreshnessSettings,
2513    diagnostic_severities: deps_core::DiagnosticSeverities,
2514    offline: bool,
2515) {
2516    let diags = diagnostics::generate_diagnostics_internal(
2517        Arc::clone(state),
2518        uri,
2519        freshness_settings,
2520        diagnostic_severities,
2521        offline,
2522    )
2523    .await;
2524    client.publish_diagnostics(uri.clone(), diags, None).await;
2525}
2526
2527/// Fans the registry fetch out for the added/version-changed dependencies determined by the
2528/// caller's diff: marks the document loading, opens an LSP progress notification when the
2529/// client supports it, resolves each dependency occurrence to a fetchable source (deduping
2530/// same-name collisions across different resolved sources), and fetches latest versions in
2531/// parallel. Returns everything the caller needs to merge the result and end the progress
2532/// notification, without exposing the intermediate `in_use`/`dep_sources` bookkeeping.
2533#[allow(clippy::too_many_arguments)]
2534async fn fetch_registry_versions_for_change(
2535    uri: &Uri,
2536    state: &ServerState,
2537    client: &Client,
2538    ecosystem: &dyn Ecosystem,
2539    resolved_versions: &HashMap<PackageName, ConcreteVersion>,
2540    deps_to_fetch: Vec<PackageName>,
2541    freshness_settings: deps_core::FreshnessSettings,
2542    fetch_timeout_secs: u64,
2543    max_concurrent_fetches: usize,
2544    refetch: RefetchPolicy,
2545) -> (
2546    Option<RegistryProgress>,
2547    FetchResult,
2548    Vec<PackageName>,
2549    HashSet<PackageName>,
2550) {
2551    tracing::info!(
2552        count = deps_to_fetch.len(),
2553        "fetching versions for added/version-changed dependencies"
2554    );
2555
2556    // Mark as loading and start progress. Under `RefetchPolicy::AllDependencies` the
2557    // routing itself changed (issue #592), not just the manifest, so the cache built under
2558    // the *old* routing can no longer be vouched for — drop it under the same lock that
2559    // sets `Loading`, so no reader ever observes a half-updated state (critic M2: this
2560    // replaces a separate drop-then-set_loading sequence, which would leave a window
2561    // between two independent `get_mut` acquisitions).
2562    if let Some(mut doc) = state.documents.get_mut(uri) {
2563        if refetch == RefetchPolicy::AllDependencies {
2564            drop_cache_for_forced_refetch(&mut doc, &deps_to_fetch, ecosystem.formatter());
2565        }
2566        doc.set_loading();
2567    }
2568
2569    let (progress, progress_sender) = if state.supports_progress() {
2570        match tokio::time::timeout(
2571            std::time::Duration::from_secs(2),
2572            RegistryProgress::start(client.clone(), uri.as_str(), deps_to_fetch.len()),
2573        )
2574        .await
2575        {
2576            Ok(Ok((p, s))) => (Some(p), Some(s)),
2577            _ => (None, None),
2578        }
2579    } else {
2580        (None, None)
2581    };
2582
2583    // Build the in-use-version map (§4.6) and the added/changed dependencies' resolved
2584    // sources (spec FR-001/FR-011) from the freshly-committed parse result and the
2585    // resolved versions just loaded above.
2586    let (in_use, minimum_stability, dep_sources, collided_names): (
2587        HashMap<PackageName, Vec<String>>,
2588        Option<String>,
2589        DepSources,
2590        HashSet<PackageName>,
2591    ) = match state.get_document(uri) {
2592        Some(doc) => match doc.parse_result() {
2593            Some(pr) => {
2594                let (sources, collided_names) =
2595                    dedup_dependencies_by_source(pr, ecosystem.formatter());
2596                let dep_sources = deps_to_fetch
2597                    .iter()
2598                    .filter_map(|name| sources.get(name).map(|s| (name.clone(), s.clone())))
2599                    .collect();
2600                (
2601                    collect_in_use_versions(
2602                        pr,
2603                        resolved_versions,
2604                        ecosystem.formatter(),
2605                        resolve_ecosystem_id(ecosystem),
2606                    ),
2607                    composer_minimum_stability(pr),
2608                    dep_sources,
2609                    collided_names,
2610                )
2611            }
2612            None => (HashMap::new(), None, Vec::new(), HashSet::new()),
2613        },
2614        None => (HashMap::new(), None, Vec::new(), HashSet::new()),
2615    };
2616
2617    // Fetch latest versions only for NEW dependencies
2618    //
2619    // Captured before `dep_sources` is moved into the call below: every raw name a
2620    // fetch was actually attempted for this round, used by the #550
2621    // no-comparable-versions merge further down to distinguish "attempted and
2622    // resolved fine this round" (clear any stale marker) from "not attempted this
2623    // round" (leave any existing marker untouched) — unlike `fetched_names` in the
2624    // merge step, this can't be derived from `fetch_result.versions`'s keys, since a
2625    // no-comparable-versions package is by definition never one of them.
2626    let attempted_names: Vec<PackageName> =
2627        dep_sources.iter().map(|(name, _)| name.clone()).collect();
2628    let registry = ecosystem.registry();
2629    let fetch_result = fetch_latest_versions_parallel(
2630        registry,
2631        dep_sources,
2632        &in_use,
2633        progress_sender,
2634        freshness_settings,
2635        fetch_timeout_secs,
2636        max_concurrent_fetches,
2637        minimum_stability.as_deref(),
2638    )
2639    .await;
2640
2641    (progress, fetch_result, attempted_names, collided_names)
2642}
2643
2644/// Merges a completed registry fetch into the document's cache and outcome maps —
2645/// newly fetched versions, yanked/fetch-failure markers (re-keyed raw -> normalized),
2646/// collided names recorded as not-attempted, and deprecation / no-comparable-versions
2647/// bookkeeping — then marks the document loaded or failed depending on `success`. Returns
2648/// `(failed_count, first_error)`, the two `FetchResult` fields this function does not
2649/// consume, so the caller can still raise the fetch-failure toast after `fetch_result`
2650/// itself has been moved in here.
2651fn merge_registry_fetch_result(
2652    state: &ServerState,
2653    uri: &Uri,
2654    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
2655    fetch_result: FetchResult,
2656    attempted_names: &[PackageName],
2657    collided_names: HashSet<PackageName>,
2658    success: bool,
2659) -> (usize, Option<String>) {
2660    if let Some(mut doc) = state.documents.get_mut(uri) {
2661        // Captured before `fetch_result.versions` is consumed below: every name
2662        // successfully fetched this round, used by the S1 deprecation-clearing
2663        // loop further down to distinguish "fetched and clean" from "not fetched
2664        // this round" — only the former may clear a stale finding.
2665        let fetched_names: Vec<PackageName> = fetch_result.versions.keys().cloned().collect();
2666        for (name, version) in fetch_result.versions {
2667            doc.cached_versions.insert(name, version);
2668        }
2669        // Re-key raw -> normalized (§3.1), same as the didOpen path.
2670        for (name, version) in fetch_result.yanked_versions {
2671            doc.outcomes
2672                .set_yanked(formatter.normalize_package_name(&name), version);
2673        }
2674        for (name, failure) in fetch_result.fetch_failed {
2675            doc.outcomes
2676                .set_fetch_failure(formatter.normalize_package_name(&name), failure);
2677        }
2678        // `set_fetch_failure_if_absent` (not `set_fetch_failure`): a collided name
2679        // normalizing to the same key as a genuine failure just recorded above must
2680        // not clobber it (impl-critic M2).
2681        for name in collided_names {
2682            doc.outcomes.set_fetch_failure_if_absent(
2683                formatter.normalize_package_name(&name),
2684                FetchFailure::NotAttempted,
2685            );
2686        }
2687        merge_deprecations_after_fetch(
2688            &mut doc,
2689            &fetched_names,
2690            fetch_result.deprecations,
2691            formatter,
2692        );
2693        merge_no_comparable_versions_after_fetch(
2694            &mut doc,
2695            attempted_names,
2696            fetch_result.no_comparable_versions,
2697            formatter,
2698        );
2699        if success {
2700            doc.set_loaded();
2701        } else {
2702            doc.set_failed();
2703        }
2704    }
2705
2706    (fetch_result.failed_count, fetch_result.first_error)
2707}
2708
2709/// Builds a `cached_versions` map from lock-file-resolved versions, ahead of any registry
2710/// fetch.
2711///
2712/// `available` is deliberately left empty (`PackageVersions::latest_without_list`, not a
2713/// plausible-looking one-element list) — this runs before any registry fetch, and
2714/// `requirement_is_unsatisfiable` treats an empty `available` as "still loading, skip"
2715/// (FR-004). Using `latest_only` here instead would populate a bogus single-entry list and
2716/// let the unsatisfiable-requirement check compute a false verdict on every document open,
2717/// before the fetch that's supposed to suppress it has a chance to run.
2718fn cached_versions_from_lockfile(
2719    resolved: &HashMap<PackageName, ConcreteVersion>,
2720) -> HashMap<PackageName, PackageVersions> {
2721    resolved
2722        .iter()
2723        .map(|(name, version)| {
2724            (
2725                name.clone(),
2726                PackageVersions::latest_without_list(version.clone()),
2727            )
2728        })
2729        .collect()
2730}
2731
2732/// Loads resolved versions from lock file for a given manifest URI.
2733///
2734/// Uses the ecosystem's lockfile provider to parse the lock file.
2735/// Returns a HashMap mapping package names to their resolved versions.
2736/// Returns an empty HashMap if no lock file is found or parsing fails.
2737async fn load_resolved_versions(
2738    uri: &Uri,
2739    state: &ServerState,
2740    ecosystem: &dyn Ecosystem,
2741) -> HashMap<PackageName, ConcreteVersion> {
2742    let lock_provider = match ecosystem.lockfile_provider() {
2743        Some(p) => p,
2744        None => {
2745            tracing::debug!("No lock file provider for ecosystem {}", ecosystem.id());
2746            return HashMap::new();
2747        }
2748    };
2749
2750    let lockfile_path = match lock_provider.locate_lockfile(uri) {
2751        Some(path) => path,
2752        None => {
2753            tracing::debug!("No lock file found for {:?}", uri);
2754            return HashMap::new();
2755        }
2756    };
2757
2758    match state
2759        .lockfile_cache
2760        .get_or_parse(lock_provider.as_ref(), &lockfile_path)
2761        .await
2762    {
2763        Ok(resolved) => {
2764            tracing::info!(
2765                "Loaded {} resolved versions from {}",
2766                resolved.len(),
2767                lockfile_path.display()
2768            );
2769            resolved
2770                .iter()
2771                .map(|(name, pkg)| (PackageName::new(name.as_str()), pkg.version.clone().into()))
2772                .collect()
2773        }
2774        Err(e) => {
2775            tracing::warn!("Failed to parse lock file: {}", e);
2776            HashMap::new()
2777        }
2778    }
2779}
2780
2781/// Ensures a document is loaded in state.
2782///
2783/// If the document is not already in state, loads it from disk,
2784/// parses it, and spawns a background task to fetch version information.
2785///
2786/// This function is idempotent - calling it multiple times with the
2787/// same URI is safe and will only load once.
2788///
2789/// # Arguments
2790///
2791/// * `uri` - Document URI
2792/// * `state` - Server state
2793/// * `client` - LSP client for notifications
2794/// * `config` - Server configuration
2795///
2796/// # Returns
2797///
2798/// * `true` - Document is now loaded (either already existed or was just loaded)
2799/// * `false` - Document could not be loaded (unsupported file type, read error, etc.)
2800///
2801/// # Behavior
2802///
2803/// - If document exists in state → Return true immediately (no-op)
2804/// - If document doesn't exist → Load from disk, parse, update state, spawn bg task
2805/// - If load fails → Log warning and return false (graceful degradation)
2806///
2807/// # Examples
2808///
2809/// ```no_run
2810/// use deps_lsp::document::ensure_document_loaded;
2811/// use deps_lsp::document::ServerState;
2812/// use tower_lsp_server::ls_types::Uri;
2813/// use std::sync::Arc;
2814///
2815/// # async fn example(
2816/// #     uri: &Uri,
2817/// #     state: Arc<ServerState>,
2818/// #     client: tower_lsp_server::Client,
2819/// #     config: Arc<tokio::sync::RwLock<deps_lsp::config::DepsConfig>>,
2820/// # ) {
2821/// let loaded = ensure_document_loaded(uri, state, client, config).await;
2822/// if loaded {
2823///     println!("Document is available for processing");
2824/// }
2825/// # }
2826/// ```
2827pub async fn ensure_document_loaded(
2828    uri: &Uri,
2829    state: Arc<ServerState>,
2830    client: Client,
2831    config: Arc<RwLock<DepsConfig>>,
2832) -> bool {
2833    // Fast path: document already loaded
2834    if state.get_document(uri).is_some() {
2835        tracing::debug!("Document already loaded: {:?}", uri);
2836        return true;
2837    }
2838
2839    // Clone cold start config before async operations to release lock
2840    let cold_start_config = { config.read().await.cold_start.clone() };
2841
2842    // Check if cold start is enabled
2843    if !cold_start_config.enabled {
2844        tracing::debug!("Cold start disabled via configuration");
2845        return false;
2846    }
2847
2848    // Rate limiting check
2849    if !state.cold_start_limiter.allow_cold_start(uri) {
2850        tracing::warn!("Cold start rate limited: {:?}", uri);
2851        return false;
2852    }
2853
2854    // Check if we support this file type
2855    if state.ecosystem_registry.get_for_uri(uri).is_none() {
2856        tracing::debug!("Unsupported file type: {:?}", uri);
2857        return false;
2858    }
2859
2860    // Load from disk
2861    tracing::info!("Loading document from disk (cold start): {:?}", uri);
2862    let content = match load_document_from_disk(uri).await {
2863        Ok(c) => c,
2864        Err(e) => {
2865            tracing::warn!("Failed to load document {:?}: {}", uri, e);
2866            client
2867                .log_message(MessageType::WARNING, format!("Could not load file: {e}"))
2868                .await;
2869            return false;
2870        }
2871    };
2872
2873    // Reuse existing handle_document_open logic. `version: None` — content came from
2874    // disk, not an LSP didOpen, so there is no client-tracked version to record (see
2875    // `DocumentState::version` and the cold-start refusal in `handlers::code_lens`).
2876    match handle_document_open(
2877        uri.clone(),
2878        content,
2879        None,
2880        Arc::clone(&state),
2881        client.clone(),
2882        Arc::clone(&config),
2883    )
2884    .await
2885    {
2886        Ok(task) => {
2887            state.spawn_background_task(uri.clone(), task).await;
2888            tracing::info!("Document loaded successfully from disk: {:?}", uri);
2889            true
2890        }
2891        Err(e) => {
2892            tracing::warn!("Failed to process loaded document {:?}: {}", uri, e);
2893            false
2894        }
2895    }
2896}
2897
2898#[cfg(test)]
2899mod tests {
2900    use super::*;
2901    use deps_core::parser::DependencySource;
2902    use std::assert_matches;
2903
2904    /// Pairs every name with the plain `Registry` source — the shape every pre-existing
2905    /// `fetch_latest_versions_parallel` test used before that function became source-aware
2906    /// (spec FR-001). Production call sites build real `(name, source)` pairs from a
2907    /// parsed manifest via `dedup_dependencies_by_source` instead.
2908    fn with_registry_source(names: Vec<PackageName>) -> Vec<(PackageName, DependencySource)> {
2909        names
2910            .into_iter()
2911            .map(|name| (name, DependencySource::Registry))
2912            .collect()
2913    }
2914
2915    /// Issue #483: `fetch_failure_toast` is the pure decision both `handle_document_open`
2916    /// and `handle_document_change` delegate to, factored out specifically so the
2917    /// suppress-while-offline policy is unit-testable without an LSP transport to capture
2918    /// `show_message` calls over.
2919    mod fetch_failure_toast_tests {
2920        use super::*;
2921
2922        #[test]
2923        fn test_no_failures_produces_no_toast_regardless_of_offline() {
2924            assert_eq!(fetch_failure_toast(0, None, false), None);
2925            assert_eq!(fetch_failure_toast(0, Some("ignored"), true), None);
2926        }
2927
2928        #[test]
2929        fn test_offline_suppresses_toast_even_with_failures() {
2930            assert_eq!(
2931                fetch_failure_toast(3, Some("offline: request to https://x was blocked"), true),
2932                None,
2933                "every fetch fails by design while offline; toasting would make it unusable"
2934            );
2935        }
2936
2937        #[test]
2938        fn test_online_failure_with_first_error_uses_it_verbatim() {
2939            assert_eq!(
2940                fetch_failure_toast(1, Some("HTTP 503 for https://example.com"), false),
2941                Some(
2942                    "deps-lsp: 1 package(s) could not be resolved: HTTP 503 for https://example.com"
2943                        .to_string()
2944                )
2945            );
2946        }
2947
2948        #[test]
2949        fn test_online_failure_with_no_first_error_uses_count_fallback() {
2950            assert_eq!(
2951                fetch_failure_toast(5, None, false),
2952                Some(
2953                    "deps-lsp: 5 package(s) could not be resolved: timeout or network error"
2954                        .to_string()
2955                )
2956            );
2957        }
2958    }
2959
2960    /// Issue #592: `RefetchPolicy::AllDependencies`'s cache-drop mechanism, and the
2961    /// residual risk it accepts (forced-refetch-then-total-failure must render "Registry
2962    /// lookup failed", never "Unknown package").
2963    mod refetch_policy_tests {
2964        use super::*;
2965        use deps_core::PackageVersions;
2966
2967        /// A no-op formatter with identity name normalization — no ecosystem-specific
2968        /// behavior is under test here, just `drop_cache_for_forced_refetch`'s own
2969        /// bookkeeping. Mirrors `test_utils::blocking_ecosystem::NoopFormatter`.
2970        struct IdentityFormatter;
2971        impl deps_core::lsp_helpers::PackageNaming for IdentityFormatter {}
2972        impl deps_core::lsp_helpers::PackageRendering for IdentityFormatter {
2973            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
2974                version.to_string()
2975            }
2976            fn package_url(&self, name: &PackageName) -> String {
2977                name.to_string()
2978            }
2979        }
2980        impl deps_core::lsp_helpers::RequirementResolution for IdentityFormatter {}
2981        impl deps_core::lsp_helpers::DiagnosticMessages for IdentityFormatter {}
2982        impl deps_core::lsp_helpers::DiagnosticPolicy for IdentityFormatter {}
2983        impl deps_core::lsp_helpers::SourcePolicy for IdentityFormatter {}
2984        impl deps_core::lsp_helpers::OsvNaming for IdentityFormatter {}
2985
2986        /// Critic S1 fix: a dependency about to be refetched is marked
2987        /// `FetchFailure::NotAttempted` (a placeholder, not left absent) — see
2988        /// `drop_cache_for_forced_refetch`'s doc for why an absent entry is unsafe.
2989        #[test]
2990        fn test_drop_cache_for_forced_refetch_marks_pending_deps_not_attempted() {
2991            let mut doc =
2992                DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
2993            doc.update_cached_versions(HashMap::from([(
2994                PackageName::new("serde"),
2995                PackageVersions::latest_only("1.0.0"),
2996            )]));
2997            doc.update_resolved_versions(HashMap::from([(
2998                PackageName::new("serde"),
2999                ConcreteVersion::new("1.0.0"),
3000            )]));
3001            doc.replace_outcomes(
3002                DependencyOutcomes::new()
3003                    .with_fetch_failure("serde", FetchFailure::Transient)
3004                    .with_yanked(
3005                        "other",
3006                        (ConcreteVersion::new("2.0.0"), RemovalStatus::Yanked),
3007                    ),
3008            );
3009
3010            drop_cache_for_forced_refetch(
3011                &mut doc,
3012                &[PackageName::new("serde")],
3013                &IdentityFormatter,
3014            );
3015
3016            assert!(
3017                doc.cached_versions.is_empty(),
3018                "cached_versions must be dropped"
3019            );
3020            assert_eq!(
3021                doc.outcomes.fetch_failure("serde"),
3022                Some(&FetchFailure::NotAttempted),
3023                "the stale fetch-failure finding must be replaced with a NotAttempted \
3024                 placeholder, not left absent (S1: an absent entry surviving into a \
3025                 concurrent empty-diff commit renders as the misleading 'Unknown package')"
3026            );
3027            assert!(
3028                doc.outcomes.yanked("other").is_some(),
3029                "a yanked finding on a different package must survive untouched"
3030            );
3031            assert_eq!(
3032                doc.resolved_versions.len(),
3033                1,
3034                "resolved_versions (lockfile-derived, registry-independent) must survive"
3035            );
3036        }
3037
3038        /// A dependency NOT in `deps_to_fetch` (e.g. one the diff-based path wouldn't have
3039        /// touched) must not gain a placeholder it was never asked to carry.
3040        #[test]
3041        fn test_drop_cache_for_forced_refetch_does_not_mark_deps_outside_the_fetch_list() {
3042            let mut doc =
3043                DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
3044            doc.update_cached_versions(HashMap::from([(
3045                PackageName::new("serde"),
3046                PackageVersions::latest_only("1.0.0"),
3047            )]));
3048
3049            drop_cache_for_forced_refetch(&mut doc, &[], &IdentityFormatter);
3050
3051            assert!(doc.cached_versions.is_empty());
3052            assert!(
3053                doc.outcomes.fetch_failure("serde").is_none(),
3054                "a dependency outside deps_to_fetch must not be given a placeholder"
3055            );
3056        }
3057
3058        /// Residual risk 4 (accepted, per the #592 design review): after a forced
3059        /// refetch drops the document's cache, a *total* fetch failure must render
3060        /// "Registry lookup failed" for the affected dependency, never "Unknown
3061        /// package" — an empty cache must not be conflated with "genuinely not found".
3062        #[cfg(feature = "cargo")]
3063        #[tokio::test]
3064        async fn test_forced_refetch_total_failure_renders_lookup_failed_not_unknown_package() {
3065            let state = Arc::new(ServerState::new());
3066            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
3067            let content = "[dependencies]\nserde = \"1.0\"\n".to_string();
3068
3069            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
3070            let parse_result = ecosystem.parse_manifest(&content, &uri).await.unwrap();
3071            let mut doc_state =
3072                DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
3073            doc_state.set_version(Some(1));
3074            // Stale cache from before the forced refetch — must not survive to be
3075            // conflated with fresh data, and must not leak into the "not found" path
3076            // either once dropped.
3077            doc_state.update_cached_versions(HashMap::from([(
3078                PackageName::new("serde"),
3079                PackageVersions::latest_only("1.0.999"),
3080            )]));
3081            state.update_document(uri.clone(), doc_state);
3082
3083            // Simulate `fetch_registry_versions_for_change`'s `AllDependencies` drop.
3084            if let Some(mut doc) = state.documents.get_mut(&uri) {
3085                drop_cache_for_forced_refetch(
3086                    &mut doc,
3087                    &[PackageName::new("serde")],
3088                    ecosystem.formatter(),
3089                );
3090            }
3091
3092            // Simulate a total registry outage: the one dependency in the manifest failed,
3093            // nothing was fetched — exactly what `ErrorRegistry` produces in
3094            // `fetch_latest_versions_parallel`'s own tests.
3095            let fetch_result = FetchResult {
3096                versions: HashMap::new(),
3097                yanked_versions: HashMap::new(),
3098                fetch_failed: HashMap::from([(PackageName::new("serde"), FetchFailure::Transient)]),
3099                deprecations: HashMap::new(),
3100                no_comparable_versions: HashSet::new(),
3101                failed_count: 1,
3102                first_error: Some("network down".to_string()),
3103            };
3104
3105            let (failed_count, _) = merge_registry_fetch_result(
3106                &state,
3107                &uri,
3108                ecosystem.formatter(),
3109                fetch_result,
3110                &[PackageName::new("serde")],
3111                HashSet::new(),
3112                false,
3113            );
3114            assert_eq!(failed_count, 1);
3115
3116            let diags = diagnostics::generate_diagnostics_internal(
3117                Arc::clone(&state),
3118                &uri,
3119                deps_core::FreshnessSettings::default(),
3120                deps_core::DiagnosticSeverities::default(),
3121                false,
3122            )
3123            .await;
3124
3125            assert!(
3126                diags
3127                    .iter()
3128                    .any(|d| d.message.contains("Registry lookup failed")),
3129                "expected a 'Registry lookup failed' diagnostic, got: {diags:?}"
3130            );
3131            assert!(
3132                diags.iter().all(|d| !d.message.contains("Unknown package")),
3133                "must never render 'Unknown package' when the cache was dropped by a \
3134                 forced refetch, got: {diags:?}"
3135            );
3136        }
3137
3138        /// Critic S1 (blocking): the gap this fix actually closes, not just the drop
3139        /// mechanism in isolation. After `RefetchPolicy::AllDependencies` drops the cache
3140        /// (real fetch not yet complete — it's behind a debounce plus network latency), a
3141        /// concurrent or subsequent plain edit with *unchanged* content
3142        /// (`RefetchPolicy::Diff`) can commit before that fetch ever merges real results.
3143        /// Its diff is empty (nothing textually changed), so `deps_to_fetch` stays empty and
3144        /// `run_document_change_task`'s early-return path never touches the outcome map —
3145        /// `preserve_cache` alone decides what survives into the new `DocumentState`. Without
3146        /// the S1 placeholder, that would carry forward an outcomes map with no entry for the
3147        /// dropped dependency, indistinguishable from "checked, nothing found", and render
3148        /// the misleading "Unknown package" indefinitely (until the *original* forced
3149        /// refetch's own task eventually completes and overwrites it — an unbounded window
3150        /// for a document with no lockfile, since the `in_lockfile` guard doesn't apply).
3151        #[cfg(feature = "cargo")]
3152        #[tokio::test]
3153        async fn test_concurrent_diff_edit_after_forced_refetch_drop_does_not_render_unknown_package()
3154         {
3155            let state = Arc::new(ServerState::new());
3156            // No lock file for this manifest path — the `in_lockfile` guard in
3157            // `handlers::diagnostics` must not be what's saving this test; it's specifically
3158            // exercising the case that guard cannot help with (NuGet `.csproj`, a fresh
3159            // Cargo checkout without `Cargo.lock`, a lock-less `package.json`/`pyproject.toml`).
3160            let uri = deps_core::test_util::test_uri("/test/no-lockfile/Cargo.toml");
3161            let content = "[dependencies]\nserde = \"1.0\"\n".to_string();
3162
3163            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
3164            let parse_result = ecosystem.parse_manifest(&content, &uri).await.unwrap();
3165            let mut doc_state = DocumentState::new_from_parse_result(
3166                EcosystemId::Cargo,
3167                content.clone(),
3168                parse_result,
3169            );
3170            doc_state.set_version(Some(1));
3171            doc_state.update_cached_versions(HashMap::from([(
3172                PackageName::new("serde"),
3173                PackageVersions::latest_only("1.0.999"),
3174            )]));
3175            state.update_document(uri.clone(), doc_state);
3176
3177            // Step 1: the forced refetch's drop has run, but (in this test) its own fetch
3178            // never gets a chance to complete before step 2 lands — the exact race S1
3179            // describes.
3180            if let Some(mut doc) = state.documents.get_mut(&uri) {
3181                drop_cache_for_forced_refetch(
3182                    &mut doc,
3183                    &[PackageName::new("serde")],
3184                    ecosystem.formatter(),
3185                );
3186            }
3187
3188            // Step 2: a plain edit with unchanged content commits — empty diff, so
3189            // `RefetchPolicy::Diff`'s `deps_to_fetch` stays empty and the early-return path
3190            // runs, never touching `outcomes` itself; only `preserve_cache` decides what
3191            // carries forward.
3192            let (client, config) = crate::test_utils::test_helpers::create_test_client_and_config();
3193            let task = handle_document_change_guarded(
3194                uri.clone(),
3195                content,
3196                Some(2),
3197                CommitGuard::Unconditional,
3198                RefetchPolicy::Diff,
3199                Arc::clone(&state),
3200                client,
3201                config,
3202            )
3203            .await
3204            .unwrap()
3205            .expect("CommitGuard::Unconditional never skips the commit");
3206            task.await.unwrap();
3207
3208            let diags = diagnostics::generate_diagnostics_internal(
3209                Arc::clone(&state),
3210                &uri,
3211                deps_core::FreshnessSettings::default(),
3212                deps_core::DiagnosticSeverities::default(),
3213                false,
3214            )
3215            .await;
3216
3217            assert!(
3218                diags.iter().all(|d| !d.message.contains("Unknown package")),
3219                "S1 regression: a dropped-cache entry surviving preserve_cache into an \
3220                 empty-diff commit must never render as 'Unknown package', got: {diags:?}"
3221            );
3222        }
3223    }
3224
3225    /// Issue #592 critic S2/M1: proves `fetch_permits` actually bounds concurrency when
3226    /// driven through the real `handle_document_open` entry point (not just the isolated
3227    /// semaphore primitive tested in `document::state`), and that a cold-start burst past
3228    /// the permit limit never flips a queued document to `Loading` before its permit
3229    /// arrives (the exact defect M1 fixed by moving the permit acquisition ahead of
3230    /// `set_loading()`/`RegistryProgress::start`).
3231    mod open_path_semaphore_e2e_tests {
3232        use super::*;
3233        use deps_core::ecosystem::BoxFuture;
3234        use deps_core::ecosystem::private::Sealed;
3235        use deps_core::{
3236            Dependency, DiagnosticSeverities, EcosystemConfig, EcosystemFormatter,
3237            FreshnessSettings, Metadata, OsvNaming, PackageNaming, PackageRendering,
3238            RequirementResolution, SourcePolicy, Version, VersionData, completion::Completions,
3239        };
3240        use std::any::Any;
3241        use std::path::Path;
3242        use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
3243        use tokio::sync::Barrier;
3244        use tower_lsp_server::ls_types::{CodeLens, Diagnostic, InlayHint, Position, Range};
3245
3246        struct NoopFormatter;
3247        impl PackageNaming for NoopFormatter {}
3248        impl PackageRendering for NoopFormatter {
3249            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
3250                version.to_string()
3251            }
3252            fn package_url(&self, name: &PackageName) -> String {
3253                format!("https://example.com/{name}")
3254            }
3255        }
3256        impl RequirementResolution for NoopFormatter {}
3257        impl deps_core::lsp_helpers::DiagnosticMessages for NoopFormatter {}
3258        impl deps_core::lsp_helpers::DiagnosticPolicy for NoopFormatter {}
3259        impl SourcePolicy for NoopFormatter {}
3260        impl OsvNaming for NoopFormatter {}
3261
3262        struct FakeDependency {
3263            name: PackageName,
3264            version_requirement: VersionReq,
3265        }
3266        impl Dependency for FakeDependency {
3267            fn name(&self) -> &PackageName {
3268                &self.name
3269            }
3270            fn name_range(&self) -> Range {
3271                Range::new(Position::new(0, 0), Position::new(0, 1))
3272            }
3273            fn version_requirement(&self) -> Option<&VersionReq> {
3274                Some(&self.version_requirement)
3275            }
3276            fn version_range(&self) -> Option<Range> {
3277                None
3278            }
3279            fn source(&self) -> deps_core::parser::DependencySource {
3280                deps_core::parser::DependencySource::Registry
3281            }
3282            fn as_any(&self) -> &dyn Any {
3283                self
3284            }
3285        }
3286
3287        struct FakeParseResult {
3288            uri: Uri,
3289            dep: FakeDependency,
3290        }
3291        impl deps_core::ParseResult for FakeParseResult {
3292            fn dependencies(&self) -> Vec<&dyn Dependency> {
3293                vec![&self.dep]
3294            }
3295            fn workspace_root(&self) -> Option<&Path> {
3296                None
3297            }
3298            fn uri(&self) -> &Uri {
3299                &self.uri
3300            }
3301            fn as_any(&self) -> &dyn Any {
3302                self
3303            }
3304        }
3305
3306        /// Tracks concurrent holders and sleeps `delay` per call, standing in for a slow
3307        /// (but never-failing) registry — mirrors `ConcurrencyTrackingRegistry` above, but
3308        /// wired through a full `Ecosystem` so the real `run_document_open_background_task`
3309        /// entry point (permit acquisition included) is what's under test, not just
3310        /// `fetch_latest_versions_parallel` in isolation.
3311        struct SlowRegistry {
3312            current: Arc<AtomicUsize>,
3313            max_seen: Arc<AtomicUsize>,
3314            delay: Duration,
3315        }
3316        impl Registry for SlowRegistry {
3317            fn get_versions<'a>(
3318                &'a self,
3319                _name: &'a PackageName,
3320            ) -> BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>> {
3321                Box::pin(async move {
3322                    let now = self.current.fetch_add(1, Ordering::SeqCst) + 1;
3323                    self.max_seen.fetch_max(now, Ordering::SeqCst);
3324                    tokio::time::sleep(self.delay).await;
3325                    self.current.fetch_sub(1, Ordering::SeqCst);
3326                    Ok(vec![])
3327                })
3328            }
3329            fn get_latest_matching<'a>(
3330                &'a self,
3331                _name: &'a PackageName,
3332                _req: &'a VersionReq,
3333            ) -> BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>> {
3334                Box::pin(async move {
3335                    let now = self.current.fetch_add(1, Ordering::SeqCst) + 1;
3336                    self.max_seen.fetch_max(now, Ordering::SeqCst);
3337                    tokio::time::sleep(self.delay).await;
3338                    self.current.fetch_sub(1, Ordering::SeqCst);
3339                    Ok(None)
3340                })
3341            }
3342            fn search<'a>(
3343                &'a self,
3344                _query: &'a str,
3345                _limit: usize,
3346            ) -> BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>> {
3347                Box::pin(async move { Ok(vec![]) })
3348            }
3349            fn as_any(&self) -> &dyn Any {
3350                self
3351            }
3352        }
3353
3354        struct SlowFetchEcosystem {
3355            registry: Arc<SlowRegistry>,
3356        }
3357        impl Sealed for SlowFetchEcosystem {}
3358        impl Ecosystem for SlowFetchEcosystem {
3359            fn id(&self) -> &'static str {
3360                "cargo"
3361            }
3362            fn display_name(&self) -> &'static str {
3363                "cargo"
3364            }
3365            fn manifest_filenames(&self) -> &[&'static str] {
3366                &["Cargo.toml"]
3367            }
3368            fn parse_manifest<'a>(
3369                &'a self,
3370                _content: &'a str,
3371                uri: &'a Uri,
3372            ) -> BoxFuture<'a, deps_core::Result<Box<dyn deps_core::ParseResult>>> {
3373                let uri = uri.clone();
3374                Box::pin(async move {
3375                    let parse_result: Box<dyn deps_core::ParseResult> = Box::new(FakeParseResult {
3376                        uri: uri.clone(),
3377                        dep: FakeDependency {
3378                            name: PackageName::new("pkg"),
3379                            version_requirement: VersionReq::new("*"),
3380                        },
3381                    });
3382                    Ok(parse_result)
3383                })
3384            }
3385            fn registry(&self) -> Arc<dyn Registry> {
3386                Arc::clone(&self.registry) as Arc<dyn Registry>
3387            }
3388            fn formatter(&self) -> &dyn EcosystemFormatter {
3389                &NoopFormatter
3390            }
3391            fn generate_inlay_hints<'a>(
3392                &'a self,
3393                _parse_result: &'a dyn deps_core::ParseResult,
3394                _versions: VersionData<'a>,
3395                _loading_state: deps_core::LoadingState,
3396                _config: &'a EcosystemConfig,
3397            ) -> BoxFuture<'a, Vec<InlayHint>> {
3398                Box::pin(async move { vec![] })
3399            }
3400            fn generate_diagnostics<'a>(
3401                &'a self,
3402                _parse_result: &'a dyn deps_core::ParseResult,
3403                _versions: VersionData<'a>,
3404                _uri: &'a Uri,
3405                _freshness: FreshnessSettings,
3406                _severities: DiagnosticSeverities,
3407            ) -> BoxFuture<'a, Vec<Diagnostic>> {
3408                Box::pin(async move { vec![] })
3409            }
3410            fn generate_code_lenses<'a>(
3411                &'a self,
3412                _parse_result: &'a dyn deps_core::ParseResult,
3413                _content: &'a str,
3414                _versions: VersionData<'a>,
3415                _uri: &'a Uri,
3416                _command_id: &'a str,
3417            ) -> BoxFuture<'a, Vec<CodeLens>> {
3418                Box::pin(async move { vec![] })
3419            }
3420            fn generate_completions<'a>(
3421                &'a self,
3422                _parse_result: &'a dyn deps_core::ParseResult,
3423                _position: Position,
3424                _content: &'a str,
3425                _freshness: FreshnessSettings,
3426            ) -> BoxFuture<'a, Completions> {
3427                Box::pin(async move { Completions::default() })
3428            }
3429            fn as_any(&self) -> &dyn Any {
3430                self
3431            }
3432        }
3433
3434        #[cfg(feature = "cargo")]
3435        #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
3436        #[allow(
3437            clippy::async_yields_async,
3438            reason = "each spawned task deliberately returns handle_document_open's own \
3439                      JoinHandle so the test can await the outer spawn (proving the burst was \
3440                      actually launched) and the inner background task (its real fetch) \
3441                      separately, in two passes below"
3442        )]
3443        async fn test_fetch_permits_bound_holds_through_open_entry_point_with_no_premature_loading()
3444        {
3445            const N: usize = 8;
3446            const FETCH_PERMITS: usize = 4; // mirrors document::state's private FETCH_PERMITS
3447
3448            let state = Arc::new(ServerState::new());
3449            let current = Arc::new(AtomicUsize::new(0));
3450            let max_seen = Arc::new(AtomicUsize::new(0));
3451            let registry = Arc::new(SlowRegistry {
3452                current: Arc::clone(&current),
3453                max_seen: Arc::clone(&max_seen),
3454                delay: Duration::from_millis(80),
3455            });
3456            // Overrides the real "cargo" registration (same id) with one whose registry is
3457            // slow-but-observable, so the burst below actually contends on `fetch_permits`
3458            // instead of resolving before any two calls could overlap.
3459            state
3460                .ecosystem_registry
3461                .register(Arc::new(SlowFetchEcosystem { registry }));
3462
3463            let (client, config) = crate::test_utils::test_helpers::create_test_client_and_config();
3464
3465            let uris: Vec<Uri> = (0..N)
3466                .map(|i| deps_core::test_util::test_uri(&format!("/test/pkg{i}/Cargo.toml")))
3467                .collect();
3468
3469            // Polls `state.documents` for as long as fetches are in flight, tracking the
3470            // peak number simultaneously `Loading`. A violation of M1 (permit acquired only
3471            // around the fetch, not before `set_loading`) would flip every one of the N
3472            // documents to `Loading` immediately, well before any permit is granted.
3473            let max_loading = Arc::new(AtomicUsize::new(0));
3474            let stop = Arc::new(AtomicBool::new(false));
3475            let poller = tokio::spawn({
3476                let state = Arc::clone(&state);
3477                let uris = uris.clone();
3478                let max_loading = Arc::clone(&max_loading);
3479                let stop = Arc::clone(&stop);
3480                async move {
3481                    while !stop.load(Ordering::SeqCst) {
3482                        let loading_now = uris
3483                            .iter()
3484                            .filter(|uri| {
3485                                state.get_document(uri).is_some_and(|d| {
3486                                    d.loading_state == deps_core::LoadingState::Loading
3487                                })
3488                            })
3489                            .count();
3490                        max_loading.fetch_max(loading_now, Ordering::SeqCst);
3491                        tokio::time::sleep(Duration::from_millis(2)).await;
3492                    }
3493                }
3494            });
3495
3496            let barrier = Arc::new(Barrier::new(N));
3497            let mut handles = Vec::new();
3498            for uri in &uris {
3499                let uri = uri.clone();
3500                let state = Arc::clone(&state);
3501                let client = client.clone();
3502                let config = Arc::clone(&config);
3503                let barrier = Arc::clone(&barrier);
3504                handles.push(tokio::spawn(async move {
3505                    barrier.wait().await;
3506                    handle_document_open(
3507                        uri,
3508                        "irrelevant-content".to_string(),
3509                        Some(1),
3510                        state,
3511                        client,
3512                        config,
3513                    )
3514                    .await
3515                    .unwrap()
3516                }));
3517            }
3518
3519            let mut bg_tasks = Vec::new();
3520            for handle in handles {
3521                bg_tasks.push(handle.await.unwrap());
3522            }
3523            for task in bg_tasks {
3524                task.await.unwrap();
3525            }
3526
3527            stop.store(true, Ordering::SeqCst);
3528            poller.await.unwrap();
3529
3530            assert_eq!(
3531                max_seen.load(Ordering::SeqCst),
3532                FETCH_PERMITS,
3533                "fetch_permits must bound real concurrent registry calls through \
3534                 run_document_open_background_task to exactly P=4, neither more (unbounded) \
3535                 nor less (under-contended, meaning this test isn't exercising the bound)"
3536            );
3537            assert!(
3538                max_loading.load(Ordering::SeqCst) <= FETCH_PERMITS,
3539                "M1 regression: at most FETCH_PERMITS documents may be Loading at once — a \
3540                 cold-start burst must not flip every queued document to Loading before its \
3541                 permit arrives (observed peak: {})",
3542                max_loading.load(Ordering::SeqCst)
3543            );
3544            for uri in &uris {
3545                let doc = state.get_document(uri).unwrap();
3546                assert_ne!(
3547                    doc.loading_state,
3548                    deps_core::LoadingState::Loading,
3549                    "no document may be left stuck in Loading once every fetch has completed"
3550                );
3551            }
3552        }
3553    }
3554
3555    /// FR-011's actual collision bail-out, exercised directly against
3556    /// `dedup_dependencies_by_source` (review finding #6): two occurrences of the same
3557    /// name resolving to two different, both-resolvable sources must be dropped from the
3558    /// result and recorded in the returned collision set — not silently picked, and not
3559    /// simply absent with no trace.
3560    mod dedup_by_source_collision_tests {
3561        use super::*;
3562        use deps_core::Dependency;
3563        use deps_core::lsp_helpers::{
3564            DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
3565            RequirementResolution, SourcePolicy,
3566        };
3567        use std::any::Any;
3568        use tower_lsp_server::ls_types::{Position, Range};
3569
3570        /// Unlike the real `CargoFormatter`, treats *both* `Registry` and
3571        /// `AlternateRegistry` as resolvable — needed so two distinct source values can
3572        /// both pass gate 1 (resolvability) and reach gate 2 (collision) in the same test.
3573        struct AlternateAwareFormatter;
3574        impl PackageNaming for AlternateAwareFormatter {}
3575
3576        impl PackageRendering for AlternateAwareFormatter {
3577            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
3578                version.to_string()
3579            }
3580
3581            fn package_url(&self, name: &PackageName) -> String {
3582                format!("https://example.com/{name}")
3583            }
3584        }
3585
3586        impl RequirementResolution for AlternateAwareFormatter {}
3587
3588        impl DiagnosticMessages for AlternateAwareFormatter {}
3589
3590        impl DiagnosticPolicy for AlternateAwareFormatter {}
3591
3592        impl SourcePolicy for AlternateAwareFormatter {
3593            fn can_resolve_source(&self, source: &DependencySource) -> bool {
3594                matches!(
3595                    source,
3596                    DependencySource::Registry | DependencySource::AlternateRegistry { .. }
3597                )
3598            }
3599        }
3600
3601        impl OsvNaming for AlternateAwareFormatter {}
3602
3603        struct MockDep {
3604            name: PackageName,
3605            source: DependencySource,
3606            addr_tag: u32,
3607        }
3608
3609        impl Dependency for MockDep {
3610            fn name(&self) -> &PackageName {
3611                &self.name
3612            }
3613            fn name_range(&self) -> Range {
3614                Range::new(
3615                    Position::new(0, self.addr_tag),
3616                    Position::new(0, self.addr_tag + 1),
3617                )
3618            }
3619            fn version_requirement(&self) -> Option<&VersionReq> {
3620                None
3621            }
3622            fn version_range(&self) -> Option<Range> {
3623                None
3624            }
3625            fn source(&self) -> DependencySource {
3626                self.source.clone()
3627            }
3628            fn as_any(&self) -> &dyn Any {
3629                self
3630            }
3631        }
3632
3633        struct MockParseResult {
3634            deps: Vec<MockDep>,
3635        }
3636
3637        impl deps_core::ParseResult for MockParseResult {
3638            fn dependencies(&self) -> Vec<&dyn Dependency> {
3639                self.deps.iter().map(|d| d as &dyn Dependency).collect()
3640            }
3641            fn workspace_root(&self) -> Option<&std::path::Path> {
3642                None
3643            }
3644            fn uri(&self) -> &Uri {
3645                static URI: std::sync::OnceLock<Uri> = std::sync::OnceLock::new();
3646                URI.get_or_init(|| deps_core::test_util::test_uri("/test/Cargo.toml"))
3647            }
3648            fn as_any(&self) -> &dyn Any {
3649                self
3650            }
3651        }
3652
3653        #[test]
3654        fn test_two_different_resolvable_sources_collide_and_are_dropped() {
3655            let parse_result = MockParseResult {
3656                deps: vec![
3657                    MockDep {
3658                        name: PackageName::new("shared-name"),
3659                        source: DependencySource::Registry,
3660                        addr_tag: 0,
3661                    },
3662                    MockDep {
3663                        name: PackageName::new("shared-name"),
3664                        source: DependencySource::AlternateRegistry {
3665                            index: "https://index.mycorp.dev".into(),
3666                            mirrors_crates_io: false,
3667                        },
3668                        addr_tag: 1,
3669                    },
3670                ],
3671            };
3672
3673            let (sources, collided) =
3674                dedup_dependencies_by_source(&parse_result, &AlternateAwareFormatter);
3675
3676            assert!(
3677                !sources.contains_key(&PackageName::new("shared-name")),
3678                "a colliding name must not be fetched under either source"
3679            );
3680            assert!(
3681                collided.contains(&PackageName::new("shared-name")),
3682                "the collision must be recorded so the caller can mark it fetch_failed"
3683            );
3684        }
3685
3686        #[test]
3687        fn test_identical_sources_do_not_collide() {
3688            let parse_result = MockParseResult {
3689                deps: vec![
3690                    MockDep {
3691                        name: PackageName::new("shared-name"),
3692                        source: DependencySource::Registry,
3693                        addr_tag: 0,
3694                    },
3695                    MockDep {
3696                        name: PackageName::new("shared-name"),
3697                        source: DependencySource::Registry,
3698                        addr_tag: 1,
3699                    },
3700                ],
3701            };
3702
3703            let (sources, collided) =
3704                dedup_dependencies_by_source(&parse_result, &AlternateAwareFormatter);
3705
3706            assert!(collided.is_empty());
3707            assert_eq!(
3708                sources.get(&PackageName::new("shared-name")),
3709                Some(&DependencySource::Registry)
3710            );
3711        }
3712
3713        /// Gate 1 (Critical review finding #1): a non-resolvable source is dropped
3714        /// entirely, never reaching the fetch — this is what prevents a Git/Path
3715        /// dependency's name from being looked up against the ecosystem's default
3716        /// registry via the background fetch's routing default arm.
3717        #[test]
3718        fn test_non_resolvable_source_is_dropped_not_fetched() {
3719            let parse_result = MockParseResult {
3720                deps: vec![MockDep {
3721                    name: PackageName::new("local-fork"),
3722                    source: DependencySource::Path {
3723                        path: "../local-fork".into(),
3724                    },
3725                    addr_tag: 0,
3726                }],
3727            };
3728
3729            let (sources, collided) =
3730                dedup_dependencies_by_source(&parse_result, &AlternateAwareFormatter);
3731
3732            assert!(sources.is_empty());
3733            assert!(collided.is_empty());
3734        }
3735    }
3736
3737    // Generic tests (no feature flag required)
3738
3739    #[test]
3740    fn test_ecosystem_registry_unknown_file() {
3741        let state = ServerState::new();
3742        let unknown_uri = deps_core::test_util::test_uri("/test/unknown.txt");
3743        assert!(state.ecosystem_registry.get_for_uri(&unknown_uri).is_none());
3744    }
3745
3746    #[test]
3747    fn test_check_content_size_accepts_content_within_limit() {
3748        let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
3749        let content = "a".repeat(MAX_FILE_SIZE as usize);
3750        assert!(check_content_size(&content, &uri).is_ok());
3751    }
3752
3753    #[test]
3754    fn test_check_content_size_rejects_content_over_limit() {
3755        let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
3756        let content = "a".repeat(MAX_FILE_SIZE as usize + 1);
3757        let result = check_content_size(&content, &uri);
3758        match result {
3759            Err(deps_core::error::DepsError::CacheError(msg)) => {
3760                assert!(msg.contains("too large"), "unexpected message: {msg}");
3761            }
3762            other => panic!("Expected CacheError, got {other:?}"),
3763        }
3764    }
3765
3766    /// N5 regression guard: the lock-file-population path must build every
3767    /// `PackageVersions` with an **empty** `available` list, never a populated one — an
3768    /// empty `available` is what makes `requirement_is_unsatisfiable`'s FR-004 guard
3769    /// suppress the check before any registry fetch has run. This is the exact function
3770    /// `handle_document_open`'s background task calls, so a regression here (e.g.
3771    /// swapping `latest_without_list` for `latest_only`) is caught directly, without
3772    /// racing the background task.
3773    #[test]
3774    fn test_cached_versions_from_lockfile_has_empty_available() {
3775        let mut resolved = HashMap::new();
3776        resolved.insert(PackageName::new("serde"), "1.0.195".into());
3777        resolved.insert(PackageName::new("tokio"), "1.35.0".into());
3778
3779        let cached = cached_versions_from_lockfile(&resolved);
3780
3781        assert_eq!(cached.len(), 2);
3782        let serde = cached.get(&PackageName::new("serde")).unwrap();
3783        assert_eq!(serde.latest, "1.0.195");
3784        assert!(
3785            serde.available.is_empty(),
3786            "lock-file-populated entries must have an empty available list, got: {:?}",
3787            serde.available
3788        );
3789        // Issue #227 C3: a locked/pinned version's age is not actionable, so this
3790        // instant-display path must never attach a stale `published_at` — there is no
3791        // second parallel map here that could drift out of sync with `latest`, since
3792        // both live on the same `PackageVersions` entry.
3793        assert_eq!(serde.published_at, None);
3794        let tokio = cached.get(&PackageName::new("tokio")).unwrap();
3795        assert_eq!(tokio.latest, "1.35.0");
3796        assert!(tokio.available.is_empty());
3797        assert_eq!(tokio.published_at, None);
3798    }
3799
3800    #[test]
3801    fn test_cached_versions_from_lockfile_empty_input_is_empty_output() {
3802        let resolved = HashMap::new();
3803        assert!(cached_versions_from_lockfile(&resolved).is_empty());
3804    }
3805
3806    #[tokio::test]
3807    async fn test_ensure_document_loaded_unsupported_file_check() {
3808        // Returns false for unknown file types (e.g., README.md)
3809        let state = Arc::new(ServerState::new());
3810        let uri = deps_core::test_util::test_uri("/test/README.md");
3811
3812        // Verify ecosystem registry correctly identifies unsupported files
3813        assert!(
3814            state.ecosystem_registry.get_for_uri(&uri).is_none(),
3815            "README.md should not have an ecosystem handler"
3816        );
3817
3818        // This would cause ensure_document_loaded to return false
3819        // We test the underlying condition without needing Client
3820    }
3821
3822    #[tokio::test]
3823    async fn test_ensure_document_loaded_file_not_found_check() {
3824        // Test that load_document_from_disk fails gracefully for missing files
3825        use super::load_document_from_disk;
3826
3827        let uri = deps_core::test_util::test_uri("/nonexistent/Cargo.toml");
3828        let result = load_document_from_disk(&uri).await;
3829
3830        assert!(result.is_err(), "Should fail for missing files");
3831
3832        // This error would cause ensure_document_loaded to return false
3833    }
3834
3835    #[tokio::test]
3836    async fn test_fetch_latest_versions_parallel_with_timeout() {
3837        use deps_core::{Metadata, Registry, Version};
3838        use std::any::Any;
3839        use std::time::Duration;
3840
3841        // Mock registry that always times out
3842        struct TimeoutRegistry;
3843
3844        impl Registry for TimeoutRegistry {
3845            fn get_versions<'a>(
3846                &'a self,
3847                _name: &'a deps_core::PackageName,
3848            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3849            {
3850                Box::pin(async move {
3851                    // Sleep longer than timeout (10s default)
3852                    tokio::time::sleep(Duration::from_secs(10)).await;
3853                    Ok(vec![])
3854                })
3855            }
3856
3857            fn get_latest_matching<'a>(
3858                &'a self,
3859                _name: &'a deps_core::PackageName,
3860                _req: &'a deps_core::VersionReq,
3861            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3862            {
3863                Box::pin(async move {
3864                    // Sleep longer than timeout
3865                    tokio::time::sleep(Duration::from_secs(10)).await;
3866                    Ok(None)
3867                })
3868            }
3869
3870            fn search<'a>(
3871                &'a self,
3872                _query: &'a str,
3873                _limit: usize,
3874            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3875            {
3876                Box::pin(async move { Ok(vec![]) })
3877            }
3878
3879            fn as_any(&self) -> &dyn Any {
3880                self
3881            }
3882        }
3883
3884        let registry: Arc<dyn Registry> = Arc::new(TimeoutRegistry);
3885        let packages = vec![PackageName::new("slow-package")];
3886
3887        // Use 1 second timeout for test speed
3888        let result = fetch_latest_versions_parallel(
3889            registry,
3890            with_registry_source(packages),
3891            &HashMap::new(),
3892            None,
3893            deps_core::freshness::FreshnessSettings::default(),
3894            1,
3895            10,
3896            None,
3897        )
3898        .await;
3899
3900        // Should return empty (timeout, not success)
3901        assert!(result.versions.is_empty(), "Slow package should timeout");
3902        assert_eq!(result.failed_count, 1, "Should track 1 failed package");
3903        // #267: a timeout is also a fetch failure, not a "not found" — must
3904        // be recorded the same way as a hard registry error.
3905        assert_eq!(
3906            result.fetch_failed,
3907            HashMap::from([(PackageName::new("slow-package"), FetchFailure::Transient)]),
3908            "timed-out package must be recorded in fetch_failed"
3909        );
3910    }
3911
3912    #[tokio::test]
3913    async fn test_fetch_latest_versions_parallel_fast_packages_not_blocked() {
3914        use deps_core::{Metadata, Registry, Version};
3915        use std::any::Any;
3916        use std::time::Duration;
3917
3918        // Mock registry with one slow, one fast package
3919        struct MixedRegistry;
3920
3921        impl Registry for MixedRegistry {
3922            fn get_versions<'a>(
3923                &'a self,
3924                name: &'a deps_core::PackageName,
3925            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3926            {
3927                Box::pin(async move {
3928                    if name == "slow-package" {
3929                        // Sleep longer than timeout
3930                        tokio::time::sleep(Duration::from_secs(10)).await;
3931                    }
3932                    // Fast package or unknown: return immediately
3933                    Ok(vec![])
3934                })
3935            }
3936
3937            fn get_latest_matching<'a>(
3938                &'a self,
3939                name: &'a deps_core::PackageName,
3940                _req: &'a deps_core::VersionReq,
3941            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3942            {
3943                Box::pin(async move {
3944                    if name == "slow-package" {
3945                        // Sleep longer than timeout
3946                        tokio::time::sleep(Duration::from_secs(10)).await;
3947                    }
3948                    // Fast package or unknown: return immediately (no versions)
3949                    Ok(None)
3950                })
3951            }
3952
3953            fn search<'a>(
3954                &'a self,
3955                _query: &'a str,
3956                _limit: usize,
3957            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3958            {
3959                Box::pin(async move { Ok(vec![]) })
3960            }
3961
3962            fn as_any(&self) -> &dyn Any {
3963                self
3964            }
3965        }
3966
3967        let registry: Arc<dyn Registry> = Arc::new(MixedRegistry);
3968        let packages = vec![
3969            PackageName::new("slow-package"),
3970            PackageName::new("fast-package"),
3971        ];
3972
3973        let start = std::time::Instant::now();
3974        let result = fetch_latest_versions_parallel(
3975            registry,
3976            with_registry_source(packages),
3977            &HashMap::new(),
3978            None,
3979            deps_core::freshness::FreshnessSettings::default(),
3980            1,
3981            10,
3982            None,
3983        )
3984        .await;
3985        let elapsed = start.elapsed();
3986
3987        // Should complete in ~1s (timeout), not 10s (slow package duration)
3988        assert!(
3989            elapsed < Duration::from_secs(3),
3990            "Should not wait for slow package: {:?}",
3991            elapsed
3992        );
3993
3994        // Fast package processed (no versions), slow package timed out
3995        assert!(
3996            result.versions.is_empty(),
3997            "No versions returned (test registry returns empty)"
3998        );
3999        assert_eq!(
4000            result.failed_count, 1,
4001            "Slow package should be marked as failed"
4002        );
4003    }
4004
4005    #[tokio::test]
4006    async fn test_fetch_latest_versions_parallel_concurrency_limit() {
4007        use deps_core::{Metadata, Registry, Version};
4008        use std::any::Any;
4009        use std::sync::atomic::{AtomicUsize, Ordering};
4010        use std::time::Duration;
4011
4012        // Mock registry that tracks concurrent requests
4013        struct ConcurrencyTrackingRegistry {
4014            current: Arc<AtomicUsize>,
4015            max_seen: Arc<AtomicUsize>,
4016        }
4017
4018        impl Registry for ConcurrencyTrackingRegistry {
4019            fn get_versions<'a>(
4020                &'a self,
4021                _name: &'a deps_core::PackageName,
4022            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4023            {
4024                Box::pin(async move {
4025                    // Increment concurrent counter
4026                    let current = self.current.fetch_add(1, Ordering::SeqCst) + 1;
4027
4028                    // Track max concurrent
4029                    self.max_seen.fetch_max(current, Ordering::SeqCst);
4030
4031                    // Simulate work
4032                    tokio::time::sleep(Duration::from_millis(50)).await;
4033
4034                    // Decrement counter
4035                    self.current.fetch_sub(1, Ordering::SeqCst);
4036
4037                    Ok(vec![])
4038                })
4039            }
4040
4041            fn get_latest_matching<'a>(
4042                &'a self,
4043                _name: &'a deps_core::PackageName,
4044                _req: &'a deps_core::VersionReq,
4045            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4046            {
4047                Box::pin(async move {
4048                    // Increment concurrent counter
4049                    let current = self.current.fetch_add(1, Ordering::SeqCst) + 1;
4050
4051                    // Track max concurrent
4052                    self.max_seen.fetch_max(current, Ordering::SeqCst);
4053
4054                    // Simulate work
4055                    tokio::time::sleep(Duration::from_millis(50)).await;
4056
4057                    // Decrement counter
4058                    self.current.fetch_sub(1, Ordering::SeqCst);
4059
4060                    Ok(None)
4061                })
4062            }
4063
4064            fn search<'a>(
4065                &'a self,
4066                _query: &'a str,
4067                _limit: usize,
4068            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4069            {
4070                Box::pin(async move { Ok(vec![]) })
4071            }
4072
4073            fn as_any(&self) -> &dyn Any {
4074                self
4075            }
4076        }
4077
4078        let current = Arc::new(AtomicUsize::new(0));
4079        let max_seen = Arc::new(AtomicUsize::new(0));
4080
4081        let registry: Arc<dyn Registry> = Arc::new(ConcurrencyTrackingRegistry {
4082            current: Arc::clone(&current),
4083            max_seen: Arc::clone(&max_seen),
4084        });
4085
4086        // Create 50 packages, limit concurrency to 20
4087        let packages: Vec<PackageName> = (0..50)
4088            .map(|i| PackageName::new(format!("package-{}", i)))
4089            .collect();
4090
4091        fetch_latest_versions_parallel(
4092            registry,
4093            with_registry_source(packages),
4094            &HashMap::new(),
4095            None,
4096            deps_core::freshness::FreshnessSettings::default(),
4097            5,
4098            20,
4099            None,
4100        )
4101        .await;
4102
4103        // Max concurrent should not exceed limit (allow small margin for timing)
4104        let max = max_seen.load(Ordering::SeqCst);
4105        assert!(
4106            max <= 22,
4107            "Concurrency limit violated: {} concurrent requests (limit: 20)",
4108            max
4109        );
4110    }
4111
4112    #[tokio::test]
4113    async fn test_fetch_partial_success_with_mixed_outcomes() {
4114        use deps_core::{Metadata, Registry, Version};
4115        use std::any::Any;
4116        use std::time::Duration;
4117
4118        // Mock version for successful fetches
4119        #[derive(Debug)]
4120        struct MockVersion {
4121            version: ConcreteVersion,
4122        }
4123
4124        impl Version for MockVersion {
4125            fn version_string(&self) -> &ConcreteVersion {
4126                &self.version
4127            }
4128
4129            fn is_prerelease(&self) -> bool {
4130                false
4131            }
4132
4133            fn as_any(&self) -> &dyn Any {
4134                self
4135            }
4136        }
4137
4138        // Mock registry with mixed outcomes:
4139        // - "package-fast" returns quickly with version
4140        // - "package-slow" times out
4141        // - "package-error" returns error
4142        struct MixedOutcomeRegistry;
4143
4144        impl Registry for MixedOutcomeRegistry {
4145            fn get_versions<'a>(
4146                &'a self,
4147                name: &'a deps_core::PackageName,
4148            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4149            {
4150                Box::pin(async move {
4151                    match name.as_str() {
4152                        "package-fast" => {
4153                            // Return immediately with a stable version
4154                            Ok(vec![Box::new(MockVersion {
4155                                version: "1.0.0".into(),
4156                            }) as Box<dyn Version>])
4157                        }
4158                        "package-slow" => {
4159                            // Sleep longer than timeout (test uses 1s timeout)
4160                            tokio::time::sleep(Duration::from_secs(10)).await;
4161                            Ok(vec![])
4162                        }
4163                        "package-error" => {
4164                            // Return cache error (simpler for testing)
4165                            Err(deps_core::error::DepsError::CacheError(
4166                                "Mock registry error".to_string(),
4167                            ))
4168                        }
4169                        _ => Ok(vec![]),
4170                    }
4171                })
4172            }
4173
4174            fn get_latest_matching<'a>(
4175                &'a self,
4176                name: &'a deps_core::PackageName,
4177                _req: &'a deps_core::VersionReq,
4178            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4179            {
4180                Box::pin(async move {
4181                    match name.as_str() {
4182                        "package-fast" => Ok(Some(Box::new(MockVersion {
4183                            version: "1.0.0".into(),
4184                        }) as Box<dyn Version>)),
4185                        "package-slow" => {
4186                            tokio::time::sleep(Duration::from_secs(10)).await;
4187                            Ok(None)
4188                        }
4189                        "package-error" => Err(deps_core::error::DepsError::CacheError(
4190                            "Mock registry error".to_string(),
4191                        )),
4192                        _ => Ok(None),
4193                    }
4194                })
4195            }
4196
4197            fn search<'a>(
4198                &'a self,
4199                _query: &'a str,
4200                _limit: usize,
4201            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4202            {
4203                Box::pin(async move { Ok(vec![]) })
4204            }
4205
4206            fn select_latest_matching(
4207                &self,
4208                versions: &[Box<dyn Version>],
4209                _req: &deps_core::VersionReq,
4210            ) -> Option<usize> {
4211                // The fetch loop no longer calls `get_latest_matching` — it derives
4212                // "latest" from `get_versions` via this method instead, so this mock
4213                // must implement it too (rather than relying on the `None` default) to
4214                // keep exercising "package-fast" as a successful fetch.
4215                if versions.is_empty() { None } else { Some(0) }
4216            }
4217
4218            fn as_any(&self) -> &dyn Any {
4219                self
4220            }
4221        }
4222
4223        let registry: Arc<dyn Registry> = Arc::new(MixedOutcomeRegistry);
4224        let packages = vec![
4225            PackageName::new("package-fast"),
4226            PackageName::new("package-slow"),
4227            PackageName::new("package-error"),
4228        ];
4229
4230        // Use 1 second timeout for test speed
4231        let result = fetch_latest_versions_parallel(
4232            registry,
4233            with_registry_source(packages),
4234            &HashMap::new(),
4235            None,
4236            deps_core::freshness::FreshnessSettings::default(),
4237            1,
4238            10,
4239            None,
4240        )
4241        .await;
4242
4243        // Only the fast package should be in results
4244        assert_eq!(
4245            result.versions.len(),
4246            1,
4247            "Should have exactly 1 successful package"
4248        );
4249        assert_eq!(
4250            result
4251                .versions
4252                .get("package-fast")
4253                .map(|v| v.latest.as_str()),
4254            Some("1.0.0"),
4255            "Fast package should have correct version"
4256        );
4257        assert!(
4258            !result.versions.contains_key("package-slow"),
4259            "Slow package should not be in results (timeout)"
4260        );
4261        assert!(
4262            !result.versions.contains_key("package-error"),
4263            "Error package should not be in results"
4264        );
4265    }
4266
4267    /// Issue #247: the per-version yanked flag from `get_versions` must survive into
4268    /// `PackageVersions.yanked`, not be discarded — this is what lets
4269    /// `generate_diagnostics_from_cache` (via `requirement_matches_only_yanked`) detect a
4270    /// requirement that is satisfiable only by a yanked version.
4271    #[tokio::test]
4272    async fn test_fetch_latest_versions_parallel_carries_yanked_flag_into_cache() {
4273        use deps_core::{Metadata, Registry, Version};
4274        use std::any::Any;
4275
4276        #[derive(Debug)]
4277        struct MockVersion {
4278            version: ConcreteVersion,
4279            yanked: bool,
4280        }
4281
4282        impl Version for MockVersion {
4283            fn version_string(&self) -> &ConcreteVersion {
4284                &self.version
4285            }
4286            fn removal_status(&self) -> deps_core::RemovalStatus {
4287                deps_core::RemovalStatus::from_yanked(self.yanked)
4288            }
4289            fn as_any(&self) -> &dyn Any {
4290                self
4291            }
4292        }
4293
4294        struct YankedRegistry;
4295
4296        impl Registry for YankedRegistry {
4297            fn get_versions<'a>(
4298                &'a self,
4299                _name: &'a deps_core::PackageName,
4300            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4301            {
4302                Box::pin(async move {
4303                    Ok(vec![
4304                        Box::new(MockVersion {
4305                            version: "1.0.214".into(),
4306                            yanked: false,
4307                        }) as Box<dyn Version>,
4308                        Box::new(MockVersion {
4309                            version: "1.0.213".into(),
4310                            yanked: true,
4311                        }) as Box<dyn Version>,
4312                    ])
4313                })
4314            }
4315
4316            fn get_latest_matching<'a>(
4317                &'a self,
4318                _name: &'a deps_core::PackageName,
4319                _req: &'a deps_core::VersionReq,
4320            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4321            {
4322                Box::pin(async move { Ok(None) })
4323            }
4324
4325            fn search<'a>(
4326                &'a self,
4327                _query: &'a str,
4328                _limit: usize,
4329            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4330            {
4331                Box::pin(async move { Ok(vec![]) })
4332            }
4333
4334            fn select_latest_matching(
4335                &self,
4336                versions: &[Box<dyn Version>],
4337                _req: &deps_core::VersionReq,
4338            ) -> Option<usize> {
4339                versions
4340                    .iter()
4341                    .position(|v| !v.removal_status().blocks_resolution())
4342            }
4343
4344            fn as_any(&self) -> &dyn Any {
4345                self
4346            }
4347        }
4348
4349        let registry: Arc<dyn Registry> = Arc::new(YankedRegistry);
4350        let packages = vec![PackageName::new("serde")];
4351
4352        let result = fetch_latest_versions_parallel(
4353            registry,
4354            with_registry_source(packages),
4355            &HashMap::new(),
4356            None,
4357            deps_core::freshness::FreshnessSettings::default(),
4358            10,
4359            10,
4360            None,
4361        )
4362        .await;
4363
4364        let serde = result
4365            .versions
4366            .get("serde")
4367            .expect("serde should be fetched");
4368        assert_eq!(serde.latest, "1.0.214", "latest must skip the yanked entry");
4369        assert_eq!(
4370            &*serde.available,
4371            &[
4372                ConcreteVersion::new("1.0.214"),
4373                ConcreteVersion::new("1.0.213")
4374            ],
4375            "available must remain unfiltered"
4376        );
4377        assert_eq!(
4378            &*serde.yanked,
4379            &[(
4380                ConcreteVersion::new("1.0.213"),
4381                deps_core::RemovalStatus::Yanked
4382            )],
4383            "yanked must carry only the entries reported as yanked, paired with their status"
4384        );
4385    }
4386
4387    /// Issue #227 C3: `PackageVersions.published_at` must be the publish time of
4388    /// `latest` specifically, not of some other entry in `available` — a risk the old
4389    /// two-parallel-map design (a separate `HashMap<String, PublishTime>` alongside the
4390    /// version map) could not structurally rule out. Bundling `published_at` onto the
4391    /// same struct as `latest`/`available`/`yanked` makes that desync impossible: both
4392    /// are set from the same `Box<dyn Version>` in the same match arm.
4393    #[tokio::test]
4394    async fn test_fetch_latest_versions_parallel_carries_published_at_for_latest_only() {
4395        use deps_core::freshness::PublishTime;
4396        use deps_core::{Metadata, Registry, Version};
4397        use std::any::Any;
4398
4399        #[derive(Debug)]
4400        struct MockVersion {
4401            version: ConcreteVersion,
4402            yanked: bool,
4403            published_at: Option<PublishTime>,
4404        }
4405
4406        impl Version for MockVersion {
4407            fn version_string(&self) -> &ConcreteVersion {
4408                &self.version
4409            }
4410            fn removal_status(&self) -> deps_core::RemovalStatus {
4411                deps_core::RemovalStatus::from_yanked(self.yanked)
4412            }
4413            fn published_at(&self) -> Option<PublishTime> {
4414                self.published_at
4415            }
4416            fn as_any(&self) -> &dyn Any {
4417                self
4418            }
4419        }
4420
4421        struct DatedRegistry;
4422
4423        impl Registry for DatedRegistry {
4424            fn get_versions<'a>(
4425                &'a self,
4426                _name: &'a deps_core::PackageName,
4427            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4428            {
4429                Box::pin(async move {
4430                    Ok(vec![
4431                        Box::new(MockVersion {
4432                            version: "1.0.214".into(),
4433                            yanked: false,
4434                            published_at: Some(PublishTime::from_unix_secs(2_000)),
4435                        }) as Box<dyn Version>,
4436                        Box::new(MockVersion {
4437                            version: "1.0.213".into(),
4438                            yanked: true,
4439                            // Deliberately a different timestamp — proves the fetch loop
4440                            // never accidentally attaches this entry's age to `latest`.
4441                            published_at: Some(PublishTime::from_unix_secs(1_000)),
4442                        }) as Box<dyn Version>,
4443                    ])
4444                })
4445            }
4446
4447            fn get_latest_matching<'a>(
4448                &'a self,
4449                _name: &'a deps_core::PackageName,
4450                _req: &'a deps_core::VersionReq,
4451            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4452            {
4453                Box::pin(async move { Ok(None) })
4454            }
4455
4456            fn search<'a>(
4457                &'a self,
4458                _query: &'a str,
4459                _limit: usize,
4460            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4461            {
4462                Box::pin(async move { Ok(vec![]) })
4463            }
4464
4465            fn select_latest_matching(
4466                &self,
4467                versions: &[Box<dyn Version>],
4468                _req: &deps_core::VersionReq,
4469            ) -> Option<usize> {
4470                versions
4471                    .iter()
4472                    .position(|v| !v.removal_status().blocks_resolution())
4473            }
4474
4475            fn as_any(&self) -> &dyn Any {
4476                self
4477            }
4478        }
4479
4480        let registry: Arc<dyn Registry> = Arc::new(DatedRegistry);
4481        let packages = vec![PackageName::new("serde")];
4482
4483        let result = fetch_latest_versions_parallel(
4484            registry,
4485            with_registry_source(packages),
4486            &HashMap::new(),
4487            None,
4488            deps_core::freshness::FreshnessSettings::default(),
4489            10,
4490            10,
4491            None,
4492        )
4493        .await;
4494
4495        let serde = result
4496            .versions
4497            .get("serde")
4498            .expect("serde should be fetched");
4499        assert_eq!(serde.latest, "1.0.214");
4500        assert_eq!(
4501            serde.published_at,
4502            Some(PublishTime::from_unix_secs(2_000)),
4503            "published_at must be 1.0.214's own timestamp, not the yanked 1.0.213 entry's"
4504        );
4505    }
4506
4507    /// #339 regression guard: the bulk diagnostics-cache-population pass must call the
4508    /// freshness-aware `Registry::get_versions_with`, not the freshness-blind `get_versions`,
4509    /// for a registry that implements the override — otherwise `published_at` (and the
4510    /// cooldown-context diagnostic message it drives) is silently always `None` in
4511    /// production even though hover's separate call path gets it right.
4512    #[tokio::test]
4513    async fn test_fetch_latest_versions_parallel_uses_get_versions_with_for_freshness() {
4514        use deps_core::freshness::{FreshnessSettings, PublishTime};
4515        use deps_core::{Metadata, Registry, Version};
4516        use std::any::Any;
4517
4518        #[derive(Debug)]
4519        struct MockVersion {
4520            version: ConcreteVersion,
4521            published_at: Option<PublishTime>,
4522        }
4523
4524        impl Version for MockVersion {
4525            fn version_string(&self) -> &ConcreteVersion {
4526                &self.version
4527            }
4528            fn published_at(&self) -> Option<PublishTime> {
4529                self.published_at
4530            }
4531            fn as_any(&self) -> &dyn Any {
4532                self
4533            }
4534        }
4535
4536        struct FreshnessAwareRegistry;
4537
4538        impl Registry for FreshnessAwareRegistry {
4539            fn get_versions<'a>(
4540                &'a self,
4541                _name: &'a deps_core::PackageName,
4542            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4543            {
4544                // Deliberately returns no `published_at` — if the fetch loop ever calls
4545                // this instead of `get_versions_with`, the assertion below catches it.
4546                Box::pin(async move {
4547                    Ok(vec![Box::new(MockVersion {
4548                        version: "1.0.0".into(),
4549                        published_at: None,
4550                    }) as Box<dyn Version>])
4551                })
4552            }
4553
4554            fn get_versions_with<'a>(
4555                &'a self,
4556                _name: &'a deps_core::PackageName,
4557                freshness: FreshnessSettings,
4558            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4559            {
4560                Box::pin(async move {
4561                    Ok(vec![Box::new(MockVersion {
4562                        version: "1.0.0".into(),
4563                        published_at: freshness
4564                            .enabled
4565                            .then(|| PublishTime::from_unix_secs(5_000)),
4566                    }) as Box<dyn Version>])
4567                })
4568            }
4569
4570            fn get_latest_matching<'a>(
4571                &'a self,
4572                _name: &'a deps_core::PackageName,
4573                _req: &'a deps_core::VersionReq,
4574            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4575            {
4576                Box::pin(async move { Ok(None) })
4577            }
4578
4579            fn search<'a>(
4580                &'a self,
4581                _query: &'a str,
4582                _limit: usize,
4583            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4584            {
4585                Box::pin(async move { Ok(vec![]) })
4586            }
4587
4588            fn select_latest_matching(
4589                &self,
4590                versions: &[Box<dyn Version>],
4591                _req: &deps_core::VersionReq,
4592            ) -> Option<usize> {
4593                if versions.is_empty() { None } else { Some(0) }
4594            }
4595
4596            fn as_any(&self) -> &dyn Any {
4597                self
4598            }
4599        }
4600
4601        let registry: Arc<dyn Registry> = Arc::new(FreshnessAwareRegistry);
4602        let packages = vec![PackageName::new("widget")];
4603
4604        let result = fetch_latest_versions_parallel(
4605            registry,
4606            with_registry_source(packages),
4607            &HashMap::new(),
4608            None,
4609            FreshnessSettings::default(),
4610            10,
4611            10,
4612            None,
4613        )
4614        .await;
4615
4616        let widget = result
4617            .versions
4618            .get("widget")
4619            .expect("widget should be fetched");
4620        assert_eq!(
4621            widget.published_at,
4622            Some(PublishTime::from_unix_secs(5_000)),
4623            "published_at must come from get_versions_with, not the freshness-blind \
4624             get_versions (#339)"
4625        );
4626    }
4627
4628    /// #424 S1: `fetch_latest_versions_parallel` must call `select_latest_matching_with_context`
4629    /// with the `minimum_stability` value it was given, not the plain `select_latest_matching`
4630    /// — otherwise a registry with manifest-level stability state (e.g. Composer's
4631    /// `minimum-stability`) never actually sees it, and #424's S1 fix stays unreachable dead
4632    /// code from the live LSP fetch path's perspective (critic S3/tester's reachability gap).
4633    #[tokio::test]
4634    async fn test_fetch_latest_versions_parallel_threads_minimum_stability_into_select_latest_matching_with_context()
4635     {
4636        use deps_core::{Metadata, Registry, Version};
4637        use std::any::Any;
4638        use std::sync::Mutex;
4639
4640        #[derive(Debug)]
4641        struct MockVersion {
4642            version: ConcreteVersion,
4643        }
4644
4645        impl Version for MockVersion {
4646            fn version_string(&self) -> &ConcreteVersion {
4647                &self.version
4648            }
4649            fn as_any(&self) -> &dyn Any {
4650                self
4651            }
4652        }
4653
4654        struct ContextAwareRegistry {
4655            // Records every `minimum_stability` value observed, in call order — an empty
4656            // `Vec` after the fetch means the `_with_context` method was never invoked.
4657            seen_minimum_stability: Mutex<Vec<Option<String>>>,
4658        }
4659
4660        impl Registry for ContextAwareRegistry {
4661            fn get_versions<'a>(
4662                &'a self,
4663                _name: &'a deps_core::PackageName,
4664            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4665            {
4666                Box::pin(async move {
4667                    Ok(vec![Box::new(MockVersion {
4668                        version: "1.0.0".into(),
4669                    }) as Box<dyn Version>])
4670                })
4671            }
4672
4673            fn get_latest_matching<'a>(
4674                &'a self,
4675                _name: &'a deps_core::PackageName,
4676                _req: &'a deps_core::VersionReq,
4677            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4678            {
4679                Box::pin(async move { Ok(None) })
4680            }
4681
4682            fn search<'a>(
4683                &'a self,
4684                _query: &'a str,
4685                _limit: usize,
4686            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4687            {
4688                Box::pin(async move { Ok(vec![]) })
4689            }
4690
4691            // Deliberately NOT overridden: if the fetch loop ever calls the plain
4692            // `select_latest_matching` instead of the `_with_context` variant, this default
4693            // (`None`) makes the pick fail, which the fallback below records as "not found" —
4694            // distinguishable from the success path this test asserts on.
4695            fn select_latest_matching_with_context(
4696                &self,
4697                versions: &[Box<dyn Version>],
4698                _req: &deps_core::VersionReq,
4699                minimum_stability: Option<&str>,
4700            ) -> Option<usize> {
4701                self.seen_minimum_stability
4702                    .lock()
4703                    .unwrap_or_else(|p| p.into_inner())
4704                    .push(minimum_stability.map(str::to_string));
4705                if versions.is_empty() { None } else { Some(0) }
4706            }
4707
4708            fn as_any(&self) -> &dyn Any {
4709                self
4710            }
4711        }
4712
4713        let registry = Arc::new(ContextAwareRegistry {
4714            seen_minimum_stability: Mutex::new(Vec::new()),
4715        });
4716        let packages = vec![PackageName::new("vendor/pkg")];
4717
4718        let result = fetch_latest_versions_parallel(
4719            Arc::clone(&registry) as Arc<dyn Registry>,
4720            with_registry_source(packages),
4721            &HashMap::new(),
4722            None,
4723            deps_core::freshness::FreshnessSettings::default(),
4724            10,
4725            10,
4726            Some("beta"),
4727        )
4728        .await;
4729
4730        assert_eq!(
4731            *registry
4732                .seen_minimum_stability
4733                .lock()
4734                .unwrap_or_else(|p| p.into_inner()),
4735            vec![Some("beta".to_string())],
4736            "select_latest_matching_with_context must receive the caller's minimum_stability"
4737        );
4738        assert!(
4739            result.versions.contains_key("vendor/pkg"),
4740            "the pick must still succeed via the _with_context path"
4741        );
4742    }
4743
4744    /// #424 S1: the `get_latest_matching` fallback path (used when the pure list-based pick
4745    /// finds nothing) must also thread `minimum_stability` through its own `_with_context`
4746    /// variant.
4747    #[tokio::test]
4748    async fn test_fetch_latest_versions_parallel_threads_minimum_stability_into_get_latest_matching_with_context()
4749     {
4750        use deps_core::{Metadata, Registry, Version};
4751        use std::any::Any;
4752        use std::sync::Mutex;
4753
4754        #[derive(Debug)]
4755        struct MockVersion {
4756            version: ConcreteVersion,
4757        }
4758
4759        impl Version for MockVersion {
4760            fn version_string(&self) -> &ConcreteVersion {
4761                &self.version
4762            }
4763            fn as_any(&self) -> &dyn Any {
4764                self
4765            }
4766        }
4767
4768        struct FallbackContextAwareRegistry {
4769            // Records every `minimum_stability` value observed, in call order — an empty
4770            // `Vec` after the fetch means the `_with_context` method was never invoked.
4771            seen_minimum_stability: Mutex<Vec<Option<String>>>,
4772        }
4773
4774        impl Registry for FallbackContextAwareRegistry {
4775            fn get_versions<'a>(
4776                &'a self,
4777                _name: &'a deps_core::PackageName,
4778            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4779            {
4780                // Empty list forces the fetch loop's `get_latest_matching_with_context`
4781                // fallback (the pure list-based pick over an empty list finds nothing).
4782                Box::pin(async move { Ok(vec![]) })
4783            }
4784
4785            fn get_latest_matching<'a>(
4786                &'a self,
4787                _name: &'a deps_core::PackageName,
4788                _req: &'a deps_core::VersionReq,
4789            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4790            {
4791                Box::pin(async move { Ok(None) })
4792            }
4793
4794            fn get_latest_matching_with_context<'a>(
4795                &'a self,
4796                _name: &'a deps_core::PackageName,
4797                _req: &'a deps_core::VersionReq,
4798                minimum_stability: Option<&'a str>,
4799            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4800            {
4801                self.seen_minimum_stability
4802                    .lock()
4803                    .unwrap_or_else(|p| p.into_inner())
4804                    .push(minimum_stability.map(str::to_string));
4805                Box::pin(async move {
4806                    Ok(Some(Box::new(MockVersion {
4807                        version: "2.0.0-beta1".into(),
4808                    }) as Box<dyn Version>))
4809                })
4810            }
4811
4812            fn search<'a>(
4813                &'a self,
4814                _query: &'a str,
4815                _limit: usize,
4816            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4817            {
4818                Box::pin(async move { Ok(vec![]) })
4819            }
4820
4821            fn as_any(&self) -> &dyn Any {
4822                self
4823            }
4824        }
4825
4826        let registry = Arc::new(FallbackContextAwareRegistry {
4827            seen_minimum_stability: Mutex::new(Vec::new()),
4828        });
4829        let packages = vec![PackageName::new("vendor/pkg")];
4830
4831        let result = fetch_latest_versions_parallel(
4832            Arc::clone(&registry) as Arc<dyn Registry>,
4833            with_registry_source(packages),
4834            &HashMap::new(),
4835            None,
4836            deps_core::freshness::FreshnessSettings::default(),
4837            10,
4838            10,
4839            Some("beta"),
4840        )
4841        .await;
4842
4843        assert_eq!(
4844            *registry
4845                .seen_minimum_stability
4846                .lock()
4847                .unwrap_or_else(|p| p.into_inner()),
4848            vec![Some("beta".to_string())],
4849            "get_latest_matching_with_context must receive the caller's minimum_stability"
4850        );
4851        let widget = result
4852            .versions
4853            .get("vendor/pkg")
4854            .expect("fallback pick should succeed");
4855        assert_eq!(widget.latest, "2.0.0-beta1");
4856    }
4857
4858    /// S3 regression: a registry whose `get_versions` list is incomplete (e.g. Go's
4859    /// `/@v/list`, which never enumerates pseudo-versions and can be entirely empty for an
4860    /// untagged module) must not render the package as "no version found" just because
4861    /// `select_latest_matching`'s pure list-based pick came up empty — the fetch loop must
4862    /// fall back to the registry's own `get_latest_matching`.
4863    #[tokio::test]
4864    async fn test_fetch_falls_back_to_get_latest_matching_when_list_based_pick_finds_nothing() {
4865        use deps_core::{Metadata, Registry, Version};
4866        use std::any::Any;
4867
4868        #[derive(Debug)]
4869        struct MockVersion {
4870            version: ConcreteVersion,
4871        }
4872
4873        impl Version for MockVersion {
4874            fn version_string(&self) -> &ConcreteVersion {
4875                &self.version
4876            }
4877            fn as_any(&self) -> &dyn Any {
4878                self
4879            }
4880        }
4881
4882        /// Mimics an untagged Go module: `get_versions` (the list endpoint) is empty, but
4883        /// `get_latest_matching` (a different, more complete endpoint) still resolves a
4884        /// pseudo-version. `select_latest_matching` deliberately relies on the trait
4885        /// default (`None`), matching a real registry whose list-based pick has nothing to
4886        /// work with.
4887        struct UntaggedModuleRegistry;
4888
4889        impl Registry for UntaggedModuleRegistry {
4890            fn get_versions<'a>(
4891                &'a self,
4892                _name: &'a deps_core::PackageName,
4893            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4894            {
4895                Box::pin(async move { Ok(vec![]) })
4896            }
4897
4898            fn get_latest_matching<'a>(
4899                &'a self,
4900                _name: &'a deps_core::PackageName,
4901                _req: &'a deps_core::VersionReq,
4902            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4903            {
4904                Box::pin(async move {
4905                    Ok(Some(Box::new(MockVersion {
4906                        version: "v0.0.0-20191109021931-daa7c04131f5".into(),
4907                    }) as Box<dyn Version>))
4908                })
4909            }
4910
4911            fn search<'a>(
4912                &'a self,
4913                _query: &'a str,
4914                _limit: usize,
4915            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4916            {
4917                Box::pin(async move { Ok(vec![]) })
4918            }
4919
4920            fn as_any(&self) -> &dyn Any {
4921                self
4922            }
4923        }
4924
4925        let registry: Arc<dyn Registry> = Arc::new(UntaggedModuleRegistry);
4926        let packages = vec![PackageName::new("golang.org/x/exp")];
4927
4928        let result = fetch_latest_versions_parallel(
4929            registry,
4930            with_registry_source(packages),
4931            &HashMap::new(),
4932            None,
4933            deps_core::freshness::FreshnessSettings::default(),
4934            5,
4935            10,
4936            None,
4937        )
4938        .await;
4939
4940        assert_eq!(
4941            result
4942                .versions
4943                .get("golang.org/x/exp")
4944                .map(|v| v.latest.as_str()),
4945            Some("v0.0.0-20191109021931-daa7c04131f5"),
4946            "must fall back to get_latest_matching instead of reporting no version found"
4947        );
4948    }
4949
4950    #[tokio::test]
4951    async fn test_fetch_registry_error_handled() {
4952        use deps_core::{Metadata, Registry, Version};
4953        use std::any::Any;
4954
4955        // Mock registry that returns errors for all packages
4956        struct ErrorRegistry;
4957
4958        impl Registry for ErrorRegistry {
4959            fn get_versions<'a>(
4960                &'a self,
4961                name: &'a deps_core::PackageName,
4962            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4963            {
4964                Box::pin(async move {
4965                    Err(deps_core::error::DepsError::CacheError(format!(
4966                        "Failed to fetch package: {}",
4967                        name
4968                    )))
4969                })
4970            }
4971
4972            fn get_latest_matching<'a>(
4973                &'a self,
4974                name: &'a deps_core::PackageName,
4975                _req: &'a deps_core::VersionReq,
4976            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4977            {
4978                Box::pin(async move {
4979                    Err(deps_core::error::DepsError::CacheError(format!(
4980                        "Failed to fetch package: {}",
4981                        name
4982                    )))
4983                })
4984            }
4985
4986            fn search<'a>(
4987                &'a self,
4988                _query: &'a str,
4989                _limit: usize,
4990            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4991            {
4992                Box::pin(async move { Ok(vec![]) })
4993            }
4994
4995            fn as_any(&self) -> &dyn Any {
4996                self
4997            }
4998        }
4999
5000        let registry: Arc<dyn Registry> = Arc::new(ErrorRegistry);
5001        let packages = vec![
5002            PackageName::new("package-1"),
5003            PackageName::new("package-2"),
5004            PackageName::new("package-3"),
5005        ];
5006
5007        // Should not panic, just return empty result
5008        let result = fetch_latest_versions_parallel(
5009            registry,
5010            with_registry_source(packages),
5011            &HashMap::new(),
5012            None,
5013            deps_core::freshness::FreshnessSettings::default(),
5014            5,
5015            10,
5016            None,
5017        )
5018        .await;
5019
5020        // All packages failed, result should be empty
5021        assert!(
5022            result.versions.is_empty(),
5023            "All packages with errors should be omitted from results"
5024        );
5025        assert_eq!(
5026            result.failed_count, 3,
5027            "All 3 packages should be marked as failed"
5028        );
5029        // #267: a fetch error must be recorded per-package, not just counted,
5030        // so diagnostic generation can tell "fetch failed" apart from
5031        // "genuinely not found" instead of reporting "Unknown package".
5032        assert_eq!(
5033            result.fetch_failed,
5034            HashMap::from([
5035                (PackageName::new("package-1"), FetchFailure::Transient),
5036                (PackageName::new("package-2"), FetchFailure::Transient),
5037                (PackageName::new("package-3"), FetchFailure::Transient),
5038            ]),
5039            "every errored package must be recorded in fetch_failed"
5040        );
5041    }
5042
5043    #[tokio::test]
5044    async fn test_fetch_not_found_is_not_recorded_as_fetch_failed() {
5045        // #267 C1: a genuine not-found (`DepsError::PackageNotFound`, the
5046        // variant npm/PyPI/Go/Swift map a 404 to) means the registry was
5047        // successfully asked and answered "no such package" — recording it
5048        // in `fetch_failed` would make `generate_diagnostics_from_cache`
5049        // report "Registry lookup failed" instead of "Unknown package" for
5050        // the common typo'd-dependency case, inverting the bug this field
5051        // exists to fix.
5052        use deps_core::{Metadata, Registry, Version};
5053        use std::any::Any;
5054
5055        struct NotFoundRegistry;
5056
5057        impl Registry for NotFoundRegistry {
5058            fn get_versions<'a>(
5059                &'a self,
5060                name: &'a deps_core::PackageName,
5061            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
5062            {
5063                Box::pin(async move {
5064                    Err(deps_core::error::DepsError::PackageNotFound {
5065                        package: name.to_string(),
5066                        registry: "mock",
5067                    })
5068                })
5069            }
5070
5071            fn get_latest_matching<'a>(
5072                &'a self,
5073                name: &'a deps_core::PackageName,
5074                _req: &'a deps_core::VersionReq,
5075            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
5076            {
5077                Box::pin(async move {
5078                    Err(deps_core::error::DepsError::PackageNotFound {
5079                        package: name.to_string(),
5080                        registry: "mock",
5081                    })
5082                })
5083            }
5084
5085            fn search<'a>(
5086                &'a self,
5087                _query: &'a str,
5088                _limit: usize,
5089            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
5090            {
5091                Box::pin(async move { Ok(vec![]) })
5092            }
5093
5094            fn as_any(&self) -> &dyn Any {
5095                self
5096            }
5097        }
5098
5099        let registry: Arc<dyn Registry> = Arc::new(NotFoundRegistry);
5100        let packages = vec![PackageName::new("typo-pkg")];
5101
5102        let result = fetch_latest_versions_parallel(
5103            registry,
5104            with_registry_source(packages),
5105            &HashMap::new(),
5106            None,
5107            deps_core::freshness::FreshnessSettings::default(),
5108            5,
5109            10,
5110            None,
5111        )
5112        .await;
5113
5114        assert!(result.versions.is_empty());
5115        assert!(
5116            result.fetch_failed.is_empty(),
5117            "a genuine not-found must not be recorded in fetch_failed, or \
5118             generate_diagnostics_from_cache would report it as a registry \
5119             error instead of Unknown package"
5120        );
5121    }
5122
5123    /// Regression for #550: a registry fetch that genuinely succeeds (no error at
5124    /// either the list-based pick or the `get_latest_matching` fallback) but resolves
5125    /// to zero versions must be recorded in `no_comparable_versions`, distinct from
5126    /// both a normal successful fetch (`versions`) and a real failure
5127    /// (`fetch_failed`). Mirrors `GithubActionsRegistry::get_versions("dtolnay/rust-toolchain")`,
5128    /// whose sole tag `v1` doesn't parse as full semver, so `tags_to_versions` filters
5129    /// it out and returns `Ok(vec![])`.
5130    #[tokio::test]
5131    async fn test_fetch_success_with_zero_versions_is_recorded_as_no_comparable_versions() {
5132        use deps_core::{Metadata, Registry, Version};
5133        use std::any::Any;
5134
5135        struct EmptyButRealRegistry;
5136
5137        impl Registry for EmptyButRealRegistry {
5138            fn get_versions<'a>(
5139                &'a self,
5140                _name: &'a deps_core::PackageName,
5141            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
5142            {
5143                Box::pin(async move { Ok(vec![]) })
5144            }
5145
5146            fn get_latest_matching<'a>(
5147                &'a self,
5148                _name: &'a deps_core::PackageName,
5149                _req: &'a deps_core::VersionReq,
5150            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
5151            {
5152                Box::pin(async move { Ok(None) })
5153            }
5154
5155            fn search<'a>(
5156                &'a self,
5157                _query: &'a str,
5158                _limit: usize,
5159            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
5160            {
5161                Box::pin(async move { Ok(vec![]) })
5162            }
5163
5164            fn as_any(&self) -> &dyn Any {
5165                self
5166            }
5167        }
5168
5169        let registry: Arc<dyn Registry> = Arc::new(EmptyButRealRegistry);
5170        let packages = vec![PackageName::new("dtolnay/rust-toolchain")];
5171
5172        let result = fetch_latest_versions_parallel(
5173            registry,
5174            with_registry_source(packages),
5175            &HashMap::new(),
5176            None,
5177            deps_core::freshness::FreshnessSettings::default(),
5178            5,
5179            10,
5180            None,
5181        )
5182        .await;
5183
5184        assert!(result.versions.is_empty());
5185        assert!(
5186            result.fetch_failed.is_empty(),
5187            "a genuine empty-but-successful fetch must not be recorded as a fetch \
5188             failure, or generate_diagnostics_from_cache would report a registry \
5189             error instead of nothing"
5190        );
5191        assert!(
5192            result
5193                .no_comparable_versions
5194                .contains(&PackageName::new("dtolnay/rust-toolchain")),
5195            "a package whose fetch succeeded with zero comparable versions must be \
5196             recorded in no_comparable_versions, or R5 would misreport it as Unknown \
5197             package; got: {:?}",
5198            result.no_comparable_versions
5199        );
5200    }
5201
5202    #[tokio::test]
5203    async fn test_fetch_http_404_is_not_recorded_as_fetch_failed() {
5204        // Same as `test_fetch_not_found_is_not_recorded_as_fetch_failed`, for
5205        // the ecosystems (Cargo, Maven, Gradle, Bundler, Dart, Composer,
5206        // NuGet) that propagate a raw `DepsError::HttpStatus { status: 404 }`
5207        // instead of mapping it to `PackageNotFound`.
5208        use deps_core::{Metadata, Registry, Version};
5209        use std::any::Any;
5210
5211        struct Http404Registry;
5212
5213        impl Registry for Http404Registry {
5214            fn get_versions<'a>(
5215                &'a self,
5216                name: &'a deps_core::PackageName,
5217            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
5218            {
5219                Box::pin(async move {
5220                    Err(deps_core::error::DepsError::HttpStatus {
5221                        url: format!("https://example.com/{name}"),
5222                        status: 404,
5223                    })
5224                })
5225            }
5226
5227            fn get_latest_matching<'a>(
5228                &'a self,
5229                name: &'a deps_core::PackageName,
5230                _req: &'a deps_core::VersionReq,
5231            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
5232            {
5233                Box::pin(async move {
5234                    Err(deps_core::error::DepsError::HttpStatus {
5235                        url: format!("https://example.com/{name}"),
5236                        status: 404,
5237                    })
5238                })
5239            }
5240
5241            fn search<'a>(
5242                &'a self,
5243                _query: &'a str,
5244                _limit: usize,
5245            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
5246            {
5247                Box::pin(async move { Ok(vec![]) })
5248            }
5249
5250            fn as_any(&self) -> &dyn Any {
5251                self
5252            }
5253        }
5254
5255        let registry: Arc<dyn Registry> = Arc::new(Http404Registry);
5256        let packages = vec![PackageName::new("typo-pkg")];
5257
5258        let result = fetch_latest_versions_parallel(
5259            registry,
5260            with_registry_source(packages),
5261            &HashMap::new(),
5262            None,
5263            deps_core::freshness::FreshnessSettings::default(),
5264            5,
5265            10,
5266            None,
5267        )
5268        .await;
5269
5270        assert!(result.versions.is_empty());
5271        assert!(
5272            result.fetch_failed.is_empty(),
5273            "a bare HTTP 404 must not be recorded in fetch_failed either"
5274        );
5275    }
5276
5277    #[tokio::test]
5278    async fn test_fetch_fallback_error_recorded_as_fetch_failed_unless_not_found() {
5279        // Go-shaped path: `get_versions` returns an empty list (nothing for
5280        // `select_latest_matching` to pick), so `fetch_latest_versions_parallel`
5281        // falls back to `get_latest_matching`. Exercises the fallback's own
5282        // error/timeout arms (previously zero test coverage — tester gap),
5283        // and confirms the same not-found-vs-failure gating (#267 C1) applies
5284        // there too, per-package via the `not_found` name.
5285        use deps_core::{Metadata, Registry, Version};
5286        use std::any::Any;
5287
5288        struct FallbackErrorRegistry;
5289
5290        impl Registry for FallbackErrorRegistry {
5291            fn get_versions<'a>(
5292                &'a self,
5293                _name: &'a deps_core::PackageName,
5294            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
5295            {
5296                Box::pin(async move { Ok(vec![]) })
5297            }
5298
5299            fn get_latest_matching<'a>(
5300                &'a self,
5301                name: &'a deps_core::PackageName,
5302                _req: &'a deps_core::VersionReq,
5303            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
5304            {
5305                let name = name.clone();
5306                Box::pin(async move {
5307                    if name.as_str() == "not-found" {
5308                        Err(deps_core::error::DepsError::PackageNotFound {
5309                            package: name.to_string(),
5310                            registry: "mock",
5311                        })
5312                    } else {
5313                        Err(deps_core::error::DepsError::CacheError(
5314                            "mock fallback failure".to_string(),
5315                        ))
5316                    }
5317                })
5318            }
5319
5320            fn search<'a>(
5321                &'a self,
5322                _query: &'a str,
5323                _limit: usize,
5324            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
5325            {
5326                Box::pin(async move { Ok(vec![]) })
5327            }
5328
5329            fn as_any(&self) -> &dyn Any {
5330                self
5331            }
5332        }
5333
5334        let registry: Arc<dyn Registry> = Arc::new(FallbackErrorRegistry);
5335        let packages = vec![PackageName::new("flaky"), PackageName::new("not-found")];
5336
5337        let result = fetch_latest_versions_parallel(
5338            registry,
5339            with_registry_source(packages),
5340            &HashMap::new(),
5341            None,
5342            deps_core::freshness::FreshnessSettings::default(),
5343            5,
5344            10,
5345            None,
5346        )
5347        .await;
5348
5349        assert!(result.versions.is_empty());
5350        assert_eq!(
5351            result.fetch_failed,
5352            HashMap::from([(PackageName::new("flaky"), FetchFailure::Transient)]),
5353            "the fallback's own non-not-found error must be recorded in fetch_failed, \
5354             but its not-found error must not"
5355        );
5356        assert_eq!(
5357            result.failed_count, 2,
5358            "both fallback failures count toward failed_count regardless of cause (S2)"
5359        );
5360    }
5361
5362    /// #490: this is the real-world shape of the toast-overcount bug — a mixed batch of
5363    /// one not-found package and one genuinely-failed package leaves `failed_count` (2)
5364    /// exceeding `fetch_failed.len()` (1), since the not-found package never gets a
5365    /// "Registry lookup failed" diagnostic. The toast must still report the full count
5366    /// without wording itself as if both packages failed a fetch. Uses the same fixture
5367    /// shape as `test_fetch_fallback_error_recorded_as_fetch_failed_unless_not_found`
5368    /// (kept separate, and that test kept byte-identical, so the #276 S2 contract stays
5369    /// pinned independently of this wording assertion).
5370    #[tokio::test]
5371    async fn test_fetch_failure_toast_wording_for_mixed_not_found_and_genuine_failure_batch() {
5372        use deps_core::{Metadata, Registry, Version};
5373        use std::any::Any;
5374
5375        struct FallbackErrorRegistry;
5376
5377        impl Registry for FallbackErrorRegistry {
5378            fn get_versions<'a>(
5379                &'a self,
5380                _name: &'a deps_core::PackageName,
5381            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
5382            {
5383                Box::pin(async move { Ok(vec![]) })
5384            }
5385
5386            fn get_latest_matching<'a>(
5387                &'a self,
5388                name: &'a deps_core::PackageName,
5389                _req: &'a deps_core::VersionReq,
5390            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
5391            {
5392                let name = name.clone();
5393                Box::pin(async move {
5394                    if name.as_str() == "not-found" {
5395                        Err(deps_core::error::DepsError::PackageNotFound {
5396                            package: name.to_string(),
5397                            registry: "mock",
5398                        })
5399                    } else {
5400                        Err(deps_core::error::DepsError::CacheError(
5401                            "mock fallback failure".to_string(),
5402                        ))
5403                    }
5404                })
5405            }
5406
5407            fn search<'a>(
5408                &'a self,
5409                _query: &'a str,
5410                _limit: usize,
5411            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
5412            {
5413                Box::pin(async move { Ok(vec![]) })
5414            }
5415
5416            fn as_any(&self) -> &dyn Any {
5417                self
5418            }
5419        }
5420
5421        let registry: Arc<dyn Registry> = Arc::new(FallbackErrorRegistry);
5422        let packages = vec![PackageName::new("flaky"), PackageName::new("not-found")];
5423
5424        let result = fetch_latest_versions_parallel(
5425            registry,
5426            with_registry_source(packages),
5427            &HashMap::new(),
5428            None,
5429            deps_core::freshness::FreshnessSettings::default(),
5430            5,
5431            10,
5432            None,
5433        )
5434        .await;
5435
5436        assert_eq!(result.failed_count, 2);
5437        assert_eq!(
5438            result.fetch_failed.len(),
5439            1,
5440            "not-found must not be in fetch_failed"
5441        );
5442
5443        let toast = fetch_failure_toast(result.failed_count, result.first_error.as_deref(), false)
5444            .expect("failed_count > 0 and not offline, so a toast must be produced");
5445        assert!(
5446            toast.starts_with("deps-lsp: 2 package(s) could not be resolved:"),
5447            "got: {toast}"
5448        );
5449        assert!(
5450            !toast.contains("lookup failed"),
5451            "must not reuse the 'Registry lookup failed' diagnostic wording, which \
5452             excludes not-found and would misrepresent this mixed batch: {toast}"
5453        );
5454    }
5455
5456    #[tokio::test]
5457    async fn test_fetch_fallback_timeout_recorded_as_fetch_failed() {
5458        // Timeout coverage for the `get_latest_matching` fallback path — a
5459        // timeout is never a "not found", so it must always land in
5460        // `fetch_failed` (and count toward `failed_count`, S2).
5461        use deps_core::{Metadata, Registry, Version};
5462        use std::any::Any;
5463        use std::time::Duration;
5464
5465        struct FallbackTimeoutRegistry;
5466
5467        impl Registry for FallbackTimeoutRegistry {
5468            fn get_versions<'a>(
5469                &'a self,
5470                _name: &'a deps_core::PackageName,
5471            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
5472            {
5473                Box::pin(async move { Ok(vec![]) })
5474            }
5475
5476            fn get_latest_matching<'a>(
5477                &'a self,
5478                _name: &'a deps_core::PackageName,
5479                _req: &'a deps_core::VersionReq,
5480            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
5481            {
5482                Box::pin(async move {
5483                    tokio::time::sleep(Duration::from_secs(10)).await;
5484                    Ok(None)
5485                })
5486            }
5487
5488            fn search<'a>(
5489                &'a self,
5490                _query: &'a str,
5491                _limit: usize,
5492            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
5493            {
5494                Box::pin(async move { Ok(vec![]) })
5495            }
5496
5497            fn as_any(&self) -> &dyn Any {
5498                self
5499            }
5500        }
5501
5502        let registry: Arc<dyn Registry> = Arc::new(FallbackTimeoutRegistry);
5503        let packages = vec![PackageName::new("slow-fallback")];
5504
5505        // 1s timeout for test speed.
5506        let result = fetch_latest_versions_parallel(
5507            registry,
5508            with_registry_source(packages),
5509            &HashMap::new(),
5510            None,
5511            deps_core::freshness::FreshnessSettings::default(),
5512            1,
5513            10,
5514            None,
5515        )
5516        .await;
5517
5518        assert!(result.versions.is_empty());
5519        assert_eq!(
5520            result.fetch_failed,
5521            HashMap::from([(PackageName::new("slow-fallback"), FetchFailure::Transient)])
5522        );
5523        assert_eq!(result.failed_count, 1);
5524    }
5525
5526    #[tokio::test]
5527    async fn test_first_error_prefers_actionable_error_over_not_found_regardless_of_race_order() {
5528        // #480: `first_error` is the batch's one-shot toast message. Before this fix it
5529        // was simply whichever concurrent fetch finished first — so a fast not-found
5530        // ("Unknown package") could outrank a slower but far more actionable error
5531        // (e.g. a rate limit hit by every other package in the batch). Here the
5532        // not-found resolves immediately while the actionable error resolves after a
5533        // short delay, so it wins the finishing race; `priority_error` must still make
5534        // the actionable error win the reported `first_error`.
5535        use deps_core::{Metadata, Registry, Version};
5536        use std::any::Any;
5537        use std::time::Duration;
5538
5539        struct MixedErrorRegistry;
5540
5541        impl Registry for MixedErrorRegistry {
5542            fn get_versions<'a>(
5543                &'a self,
5544                name: &'a deps_core::PackageName,
5545            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
5546            {
5547                Box::pin(async move {
5548                    if name.as_str() == "typo-pkg" {
5549                        Err(deps_core::error::DepsError::PackageNotFound {
5550                            package: name.to_string(),
5551                            registry: "mock",
5552                        })
5553                    } else {
5554                        tokio::time::sleep(Duration::from_millis(50)).await;
5555                        Err(deps_core::error::DepsError::CacheError(
5556                            "rate limit exceeded".to_string(),
5557                        ))
5558                    }
5559                })
5560            }
5561
5562            fn get_latest_matching<'a>(
5563                &'a self,
5564                _name: &'a deps_core::PackageName,
5565                _req: &'a deps_core::VersionReq,
5566            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
5567            {
5568                Box::pin(async move { Ok(None) })
5569            }
5570
5571            fn search<'a>(
5572                &'a self,
5573                _query: &'a str,
5574                _limit: usize,
5575            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
5576            {
5577                Box::pin(async move { Ok(vec![]) })
5578            }
5579
5580            fn as_any(&self) -> &dyn Any {
5581                self
5582            }
5583        }
5584
5585        let registry: Arc<dyn Registry> = Arc::new(MixedErrorRegistry);
5586        let packages = vec![
5587            PackageName::new("typo-pkg"),
5588            PackageName::new("rate-limited"),
5589        ];
5590
5591        let result = fetch_latest_versions_parallel(
5592            registry,
5593            with_registry_source(packages),
5594            &HashMap::new(),
5595            None,
5596            deps_core::freshness::FreshnessSettings::default(),
5597            5,
5598            10,
5599            None,
5600        )
5601        .await;
5602
5603        let err = result
5604            .first_error
5605            .expect("an actionable failure occurred and must be reported");
5606        assert!(
5607            err.contains("rate limit exceeded"),
5608            "the actionable error must win the toast over the faster-finishing not-found, \
5609             got: {err}"
5610        );
5611        assert!(
5612            !err.contains("not found"),
5613            "a not-found error must never outrank an actionable error, got: {err}"
5614        );
5615    }
5616
5617    #[tokio::test]
5618    async fn test_first_error_falls_back_to_not_found_when_no_actionable_error_occurred() {
5619        // #480 fallback path: `priority_error` is only populated by errors that also
5620        // count toward `fetch_failed` (non-not-found). A batch whose only failures are
5621        // not-found ones must still surface one via `first_error` instead of silently
5622        // reporting nothing.
5623        use deps_core::{Metadata, Registry, Version};
5624        use std::any::Any;
5625
5626        struct AllNotFoundRegistry;
5627
5628        impl Registry for AllNotFoundRegistry {
5629            fn get_versions<'a>(
5630                &'a self,
5631                name: &'a deps_core::PackageName,
5632            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
5633            {
5634                Box::pin(async move {
5635                    Err(deps_core::error::DepsError::PackageNotFound {
5636                        package: name.to_string(),
5637                        registry: "mock",
5638                    })
5639                })
5640            }
5641
5642            fn get_latest_matching<'a>(
5643                &'a self,
5644                _name: &'a deps_core::PackageName,
5645                _req: &'a deps_core::VersionReq,
5646            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
5647            {
5648                Box::pin(async move { Ok(None) })
5649            }
5650
5651            fn search<'a>(
5652                &'a self,
5653                _query: &'a str,
5654                _limit: usize,
5655            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
5656            {
5657                Box::pin(async move { Ok(vec![]) })
5658            }
5659
5660            fn as_any(&self) -> &dyn Any {
5661                self
5662            }
5663        }
5664
5665        let registry: Arc<dyn Registry> = Arc::new(AllNotFoundRegistry);
5666        let packages = vec![
5667            PackageName::new("typo-pkg-1"),
5668            PackageName::new("typo-pkg-2"),
5669        ];
5670
5671        let result = fetch_latest_versions_parallel(
5672            registry,
5673            with_registry_source(packages),
5674            &HashMap::new(),
5675            None,
5676            deps_core::freshness::FreshnessSettings::default(),
5677            5,
5678            10,
5679            None,
5680        )
5681        .await;
5682
5683        assert!(
5684            result.fetch_failed.is_empty(),
5685            "not-found errors must never be recorded in fetch_failed"
5686        );
5687        let err = result
5688            .first_error
5689            .expect("a not-found-only batch must still fall back to reporting one via first_error");
5690        assert!(err.contains("not found"), "got: {err}");
5691    }
5692
5693    #[tokio::test]
5694    async fn test_timeout_only_batch_reports_first_error_alongside_failed_count() {
5695        // #480 S1: the toast used to special-case a populated `first_error` as
5696        // `format!("deps-lsp: {err}")`, entirely dropping `failed_count` from the
5697        // message whenever `first_error` was `Some` — so a multi-package timeout batch
5698        // (which always populates `first_error` via `priority_error`, unlike the
5699        // not-found-only case) silently lost its count. The toast is now built
5700        // unconditionally from both fields (`"{failed_count} package(s) could not be
5701        // resolved: {first_error}"`, see #490), so this asserts the `FetchResult` data
5702        // that feeds it: a batch where every package times out must report `failed_count`
5703        // equal to the batch size *and* a populated, actionable `first_error` — both
5704        // fields together, not one masking the other.
5705        use deps_core::{Metadata, Registry, Version};
5706        use std::any::Any;
5707        use std::time::Duration;
5708
5709        struct AlwaysTimesOutRegistry;
5710
5711        impl Registry for AlwaysTimesOutRegistry {
5712            fn get_versions<'a>(
5713                &'a self,
5714                _name: &'a deps_core::PackageName,
5715            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
5716            {
5717                Box::pin(async move {
5718                    tokio::time::sleep(Duration::from_secs(10)).await;
5719                    Ok(vec![])
5720                })
5721            }
5722
5723            fn get_latest_matching<'a>(
5724                &'a self,
5725                _name: &'a deps_core::PackageName,
5726                _req: &'a deps_core::VersionReq,
5727            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
5728            {
5729                Box::pin(async move {
5730                    tokio::time::sleep(Duration::from_secs(10)).await;
5731                    Ok(None)
5732                })
5733            }
5734
5735            fn search<'a>(
5736                &'a self,
5737                _query: &'a str,
5738                _limit: usize,
5739            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
5740            {
5741                Box::pin(async move { Ok(vec![]) })
5742            }
5743
5744            fn as_any(&self) -> &dyn Any {
5745                self
5746            }
5747        }
5748
5749        let registry: Arc<dyn Registry> = Arc::new(AlwaysTimesOutRegistry);
5750        let packages = vec![
5751            PackageName::new("slow-1"),
5752            PackageName::new("slow-2"),
5753            PackageName::new("slow-3"),
5754        ];
5755
5756        let result = fetch_latest_versions_parallel(
5757            registry,
5758            with_registry_source(packages),
5759            &HashMap::new(),
5760            None,
5761            deps_core::freshness::FreshnessSettings::default(),
5762            1,
5763            10,
5764            None,
5765        )
5766        .await;
5767
5768        assert_eq!(
5769            result.failed_count, 3,
5770            "all 3 packages must count toward failed_count"
5771        );
5772        let err = result
5773            .first_error
5774            .expect("a timeout is actionable and must populate first_error, not just failed_count");
5775        assert!(
5776            err.contains("timed out"),
5777            "first_error must be the actionable timeout message, got: {err}"
5778        );
5779    }
5780
5781    // Composer-specific tests
5782    #[cfg(feature = "composer")]
5783    mod composer_tests {
5784        use super::*;
5785
5786        /// #424 S1: `composer_minimum_stability` must extract the manifest's
5787        /// `minimum-stability` field via the real `deps_composer::parser::parse_composer_json`
5788        /// → `ComposerParseResult` → `deps_core::ParseResult` downcast path, not just a
5789        /// hand-built fixture — this is the actual production call path from the fetch task.
5790        #[tokio::test]
5791        async fn test_composer_minimum_stability_extracts_from_real_parse_result() {
5792            let json = r#"{
5793  "minimum-stability": "beta",
5794  "require": {
5795    "symfony/console": "^6.0"
5796  }
5797}"#;
5798            let uri = deps_core::test_util::test_uri("/test/composer.json");
5799            let parse_result = crate::parse_composer_json(json, &uri).unwrap();
5800
5801            assert_eq!(
5802                composer_minimum_stability(&parse_result as &dyn deps_core::ParseResult),
5803                Some("beta".to_string())
5804            );
5805        }
5806
5807        /// #424 S1: a `composer.json` with no `minimum-stability` field extracts to `None`,
5808        /// not a fabricated `"stable"`.
5809        #[tokio::test]
5810        async fn test_composer_minimum_stability_none_when_absent() {
5811            let json = r#"{"require": {"symfony/console": "^6.0"}}"#;
5812            let uri = deps_core::test_util::test_uri("/test/composer.json");
5813            let parse_result = crate::parse_composer_json(json, &uri).unwrap();
5814
5815            assert_eq!(
5816                composer_minimum_stability(&parse_result as &dyn deps_core::ParseResult),
5817                None
5818            );
5819        }
5820
5821        /// #424 S1: a non-Composer `ParseResult` (the downcast target type mismatches) must
5822        /// extract to `None` rather than panicking — this is what every other ecosystem's
5823        /// document hits on every fetch cycle.
5824        #[test]
5825        fn test_composer_minimum_stability_none_for_non_composer_parse_result() {
5826            struct OtherParseResult;
5827            impl deps_core::ParseResult for OtherParseResult {
5828                fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
5829                    vec![]
5830                }
5831                fn workspace_root(&self) -> Option<&std::path::Path> {
5832                    None
5833                }
5834                fn uri(&self) -> &Uri {
5835                    unimplemented!("not exercised by this test")
5836                }
5837                fn as_any(&self) -> &dyn std::any::Any {
5838                    self
5839                }
5840            }
5841
5842            assert_eq!(
5843                composer_minimum_stability(&OtherParseResult as &dyn deps_core::ParseResult),
5844                None
5845            );
5846        }
5847    }
5848
5849    // Cargo-specific tests
5850    #[cfg(feature = "cargo")]
5851    mod cargo_tests {
5852        use super::*;
5853
5854        #[test]
5855        fn test_ecosystem_registry_lookup() {
5856            let state = ServerState::new();
5857            let cargo_uri = deps_core::test_util::test_uri("/test/Cargo.toml");
5858            assert!(state.ecosystem_registry.get_for_uri(&cargo_uri).is_some());
5859        }
5860
5861        #[tokio::test]
5862        async fn test_document_parsing() {
5863            let state = Arc::new(ServerState::new());
5864            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
5865            let content = r#"[dependencies]
5866serde = "1.0"
5867"#;
5868
5869            let ecosystem = state
5870                .ecosystem_registry
5871                .get_for_uri(&uri)
5872                .expect("Cargo ecosystem not found");
5873
5874            let parse_result = ecosystem.parse_manifest(content, &uri).await;
5875            assert!(parse_result.is_ok());
5876
5877            let doc_state = DocumentState::new_from_parse_result(
5878                EcosystemId::Cargo,
5879                content.to_string(),
5880                parse_result.unwrap(),
5881            );
5882            state.update_document(uri.clone(), doc_state);
5883
5884            assert_eq!(state.document_count(), 1);
5885            let doc = state.get_document(&uri).unwrap();
5886            assert_eq!(doc.ecosystem_id(), "cargo");
5887        }
5888
5889        #[tokio::test]
5890        async fn test_document_stored_even_when_parsing_fails() {
5891            let state = Arc::new(ServerState::new());
5892            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
5893            // Invalid TOML that will fail parsing
5894            let content = r#"[dependencies
5895serde = "1.0"
5896"#;
5897
5898            let ecosystem = state
5899                .ecosystem_registry
5900                .get_for_uri(&uri)
5901                .expect("Cargo ecosystem not found");
5902
5903            // Try to parse (will fail)
5904            let parse_result = ecosystem.parse_manifest(content, &uri).await.ok();
5905            assert!(
5906                parse_result.is_none(),
5907                "Parsing should fail for invalid TOML"
5908            );
5909
5910            // Create document state without parse result
5911            let doc_state = if let Some(pr) = parse_result {
5912                DocumentState::new_from_parse_result(EcosystemId::Cargo, content.to_string(), pr)
5913            } else {
5914                DocumentState::new_without_parse_result(EcosystemId::Cargo, content.to_string())
5915            };
5916
5917            state.update_document(uri.clone(), doc_state);
5918
5919            // Document should be stored despite parse failure
5920            let doc = state.get_document(&uri);
5921            assert!(
5922                doc.is_some(),
5923                "Document should be stored even when parsing fails"
5924            );
5925
5926            let doc = doc.unwrap();
5927            assert_eq!(doc.ecosystem_id(), "cargo");
5928            assert_eq!(doc.content, content);
5929            assert!(
5930                doc.parse_result().is_none(),
5931                "Parse result should be None for failed parse"
5932            );
5933        }
5934
5935        #[tokio::test]
5936        async fn test_ensure_document_loaded_fast_path() {
5937            // Fast path: document already loaded, should return true without loading
5938            let state = Arc::new(ServerState::new());
5939            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
5940            let content = r#"[dependencies]
5941serde = "1.0""#;
5942
5943            // Pre-populate state with document
5944            let ecosystem = state
5945                .ecosystem_registry
5946                .get_for_uri(&uri)
5947                .expect("Cargo ecosystem");
5948            let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
5949            let doc_state = DocumentState::new_from_parse_result(
5950                EcosystemId::Cargo,
5951                content.to_string(),
5952                parse_result,
5953            );
5954            state.update_document(uri.clone(), doc_state);
5955
5956            // Fast path check: document exists
5957            assert!(
5958                state.get_document(&uri).is_some(),
5959                "Document should exist in state"
5960            );
5961            assert_eq!(state.document_count(), 1, "Document count should be 1");
5962
5963            // The fast path in ensure_document_loaded would return true here without
5964            // requiring a Client. We test the condition directly since creating a test
5965            // Client requires complex tower-lsp-server internals (ServerState, ClientSocket).
5966        }
5967
5968        #[tokio::test]
5969        async fn test_ensure_document_loaded_successful_disk_load() {
5970            // Test successful load from filesystem with temp file
5971            use super::super::load_document_from_disk;
5972            use std::fs;
5973            use tempfile::TempDir;
5974
5975            // Create a temporary directory with a Cargo.toml file
5976            let temp_dir = TempDir::new().unwrap();
5977            let cargo_toml_path = temp_dir.path().join("Cargo.toml");
5978            let content = r#"[package]
5979name = "test"
5980version = "0.1.0"
5981
5982[dependencies]
5983serde = "1.0"
5984"#;
5985            fs::write(&cargo_toml_path, content).unwrap();
5986
5987            let uri = Uri::from_file_path(&cargo_toml_path).unwrap();
5988
5989            // Test that load_document_from_disk succeeds
5990            let loaded_content = load_document_from_disk(&uri).await.unwrap();
5991            assert_eq!(loaded_content, content);
5992
5993            // Test that parsing succeeds
5994            let state = Arc::new(ServerState::new());
5995            let ecosystem = state
5996                .ecosystem_registry
5997                .get_for_uri(&uri)
5998                .expect("Cargo ecosystem");
5999            let parse_result = ecosystem.parse_manifest(&loaded_content, &uri).await;
6000            assert!(parse_result.is_ok(), "Should parse successfully");
6001
6002            // These successful operations are the building blocks of ensure_document_loaded
6003        }
6004
6005        #[tokio::test]
6006        async fn test_ensure_document_loaded_idempotent_check() {
6007            // Test that repeated loads are idempotent at the state level
6008            let state = Arc::new(ServerState::new());
6009            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
6010            let content = r#"[dependencies]
6011serde = "1.0""#;
6012
6013            let ecosystem = state
6014                .ecosystem_registry
6015                .get_for_uri(&uri)
6016                .expect("Cargo ecosystem");
6017
6018            // Parse twice to simulate idempotent loads
6019            let parse_result1 = ecosystem.parse_manifest(content, &uri).await.unwrap();
6020            let parse_result2 = ecosystem.parse_manifest(content, &uri).await.unwrap();
6021
6022            // First update
6023            let doc_state1 = DocumentState::new_from_parse_result(
6024                EcosystemId::Cargo,
6025                content.to_string(),
6026                parse_result1,
6027            );
6028            state.update_document(uri.clone(), doc_state1);
6029            assert_eq!(state.document_count(), 1);
6030
6031            // Second update (idempotent)
6032            let doc_state2 = DocumentState::new_from_parse_result(
6033                EcosystemId::Cargo,
6034                content.to_string(),
6035                parse_result2,
6036            );
6037            state.update_document(uri.clone(), doc_state2);
6038            assert_eq!(
6039                state.document_count(),
6040                1,
6041                "Should still have only 1 document"
6042            );
6043        }
6044
6045        #[tokio::test]
6046        async fn test_handle_document_open_rejects_oversized_content() {
6047            use crate::test_utils::test_helpers::create_test_client_and_config;
6048
6049            let state = Arc::new(ServerState::new());
6050            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
6051            let oversized_content = "a".repeat(MAX_FILE_SIZE as usize + 1);
6052            let (client, config) = create_test_client_and_config();
6053
6054            let result = handle_document_open(
6055                uri.clone(),
6056                oversized_content,
6057                Some(1),
6058                state.clone(),
6059                client,
6060                config,
6061            )
6062            .await;
6063
6064            assert!(result.is_err(), "Oversized content should be rejected");
6065            match result {
6066                Err(deps_core::error::DepsError::CacheError(msg)) => {
6067                    assert!(
6068                        msg.contains("too large"),
6069                        "Error message should indicate size issue: {msg}"
6070                    );
6071                }
6072                other => panic!("Expected CacheError for oversized content, got {other:?}"),
6073            }
6074            assert_eq!(
6075                state.document_count(),
6076                0,
6077                "Oversized content must not be stored/parsed"
6078            );
6079        }
6080
6081        #[tokio::test]
6082        async fn test_handle_document_open_accepts_normal_sized_content() {
6083            use crate::test_utils::test_helpers::create_test_client_and_config;
6084
6085            let state = Arc::new(ServerState::new());
6086            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
6087            let content = r#"[dependencies]
6088serde = "1.0"
6089"#
6090            .to_string();
6091            let (client, config) = create_test_client_and_config();
6092
6093            let result =
6094                handle_document_open(uri.clone(), content, Some(1), state.clone(), client, config)
6095                    .await;
6096
6097            assert!(result.is_ok(), "Normal-sized content should be accepted");
6098            assert_eq!(state.document_count(), 1);
6099        }
6100
6101        #[tokio::test]
6102        async fn test_handle_document_change_rejects_oversized_content() {
6103            use crate::test_utils::test_helpers::create_test_client_and_config;
6104
6105            let state = Arc::new(ServerState::new());
6106            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
6107            let oversized_content = "a".repeat(MAX_FILE_SIZE as usize + 1);
6108            let (client, config) = create_test_client_and_config();
6109
6110            let result = handle_document_change(
6111                uri.clone(),
6112                oversized_content,
6113                Some(2),
6114                state.clone(),
6115                client,
6116                config,
6117            )
6118            .await;
6119
6120            assert!(result.is_err(), "Oversized content should be rejected");
6121            match result {
6122                Err(deps_core::error::DepsError::CacheError(msg)) => {
6123                    assert!(
6124                        msg.contains("too large"),
6125                        "Error message should indicate size issue: {msg}"
6126                    );
6127                }
6128                other => panic!("Expected CacheError for oversized content, got {other:?}"),
6129            }
6130            assert_eq!(
6131                state.document_count(),
6132                0,
6133                "Oversized content must not be stored/parsed"
6134            );
6135        }
6136
6137        #[tokio::test]
6138        async fn test_handle_document_change_rejects_oversized_content_preserves_existing_document()
6139        {
6140            use crate::test_utils::test_helpers::create_test_client_and_config;
6141
6142            let state = Arc::new(ServerState::new());
6143            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
6144            let original_content = r#"[dependencies]
6145serde = "1.0"
6146"#
6147            .to_string();
6148
6149            // Open a valid document first (mirrors an already-open editor buffer).
6150            let (client, config) = create_test_client_and_config();
6151            handle_document_open(
6152                uri.clone(),
6153                original_content.clone(),
6154                Some(1),
6155                state.clone(),
6156                client,
6157                config,
6158            )
6159            .await
6160            .expect("initial open should succeed");
6161            assert_eq!(state.document_count(), 1);
6162
6163            // An oversized didChange must be rejected without touching the stored document.
6164            let oversized_content = "a".repeat(MAX_FILE_SIZE as usize + 1);
6165            let (client, config) = create_test_client_and_config();
6166            let result = handle_document_change(
6167                uri.clone(),
6168                oversized_content,
6169                Some(2),
6170                state.clone(),
6171                client,
6172                config,
6173            )
6174            .await;
6175
6176            assert!(result.is_err(), "Oversized change should be rejected");
6177            assert_eq!(
6178                state.document_count(),
6179                1,
6180                "The previously stored document must survive a rejected change"
6181            );
6182            let doc = state
6183                .get_document(&uri)
6184                .expect("original document should still be present");
6185            assert_eq!(
6186                doc.content, original_content,
6187                "Document content must be unchanged by the rejected change"
6188            );
6189        }
6190
6191        /// Issue #493 regression: before the fix, `inlay_hint_refresh`/`code_lens_refresh`
6192        /// were awaited inline in the spawned background task, ahead of the OSV
6193        /// vulnerability commit and diagnostics publish. A client that declares refresh
6194        /// support (`ServerState`'s cached flag is `true`) but whose request never
6195        /// resolves must not be able to stall that commit — the calls are fire-and-forget
6196        /// now, so the background task must still reach a terminal loading state and
6197        /// return within a bounded time regardless of what the refresh call does.
6198        #[tokio::test]
6199        async fn test_handle_document_open_completes_promptly_with_refresh_support_enabled() {
6200            use crate::test_utils::test_helpers::create_test_client_and_config;
6201
6202            let state = Arc::new(ServerState::new());
6203            state.set_inlay_hint_refresh_supported(true);
6204            state.set_code_lens_refresh_supported(true);
6205
6206            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
6207            let content = r#"[dependencies]
6208serde = "1.0"
6209"#
6210            .to_string();
6211            let (client, config) = create_test_client_and_config();
6212
6213            let task =
6214                handle_document_open(uri.clone(), content, Some(1), state.clone(), client, config)
6215                    .await
6216                    .expect("normal-sized content should be accepted");
6217
6218            tokio::time::timeout(Duration::from_secs(10), task)
6219                .await
6220                .expect(
6221                    "background task must complete promptly even with refresh support \
6222                     enabled (issue #493 regression: an inline refresh await could hang here)",
6223                )
6224                .expect("background task must not panic");
6225
6226            let doc = state.get_document(&uri).expect("document should be stored");
6227            assert!(
6228                matches!(
6229                    doc.loading_state,
6230                    deps_core::LoadingState::Loaded | deps_core::LoadingState::Failed
6231                ),
6232                "document loading must reach a terminal state, proving the pipeline ran \
6233                 past the refresh call sites to commit OSV results and diagnostics: {:?}",
6234                doc.loading_state
6235            );
6236        }
6237
6238        #[tokio::test]
6239        async fn test_handle_document_change_completes_promptly_with_refresh_support_enabled() {
6240            use crate::test_utils::test_helpers::create_test_client_and_config;
6241
6242            let state = Arc::new(ServerState::new());
6243            state.set_inlay_hint_refresh_supported(true);
6244            state.set_code_lens_refresh_supported(true);
6245
6246            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
6247            let original_content = r#"[dependencies]
6248serde = "1.0"
6249"#
6250            .to_string();
6251            let (client, config) = create_test_client_and_config();
6252            handle_document_open(
6253                uri.clone(),
6254                original_content,
6255                Some(1),
6256                state.clone(),
6257                client,
6258                config,
6259            )
6260            .await
6261            .expect("initial open should succeed")
6262            .await
6263            .expect("initial open's background task must not panic");
6264
6265            let changed_content = r#"[dependencies]
6266serde = "1.0"
6267tokio = "1.0"
6268"#
6269            .to_string();
6270            let (client, config) = create_test_client_and_config();
6271            let task = handle_document_change(
6272                uri.clone(),
6273                changed_content,
6274                Some(2),
6275                state.clone(),
6276                client,
6277                config,
6278            )
6279            .await
6280            .expect("normal-sized change should be accepted");
6281
6282            tokio::time::timeout(Duration::from_secs(10), task)
6283                .await
6284                .expect(
6285                    "background task must complete promptly even with refresh support \
6286                     enabled (issue #493 regression: an inline refresh await could hang here)",
6287                )
6288                .expect("background task must not panic");
6289
6290            let doc = state.get_document(&uri).expect("document should be stored");
6291            assert!(
6292                matches!(
6293                    doc.loading_state,
6294                    deps_core::LoadingState::Loaded | deps_core::LoadingState::Failed
6295                ),
6296                "document loading must reach a terminal state, proving the pipeline ran \
6297                 past the refresh call sites to commit OSV results and diagnostics: {:?}",
6298                doc.loading_state
6299            );
6300        }
6301    }
6302
6303    // npm-specific tests
6304    #[cfg(feature = "npm")]
6305    mod npm_tests {
6306        use super::*;
6307
6308        #[test]
6309        fn test_ecosystem_registry_lookup() {
6310            let state = ServerState::new();
6311            let npm_uri = deps_core::test_util::test_uri("/test/package.json");
6312            assert!(state.ecosystem_registry.get_for_uri(&npm_uri).is_some());
6313        }
6314
6315        #[tokio::test]
6316        async fn test_document_parsing() {
6317            let state = Arc::new(ServerState::new());
6318            let uri = deps_core::test_util::test_uri("/test/package.json");
6319            let content = r#"{"dependencies": {"express": "^4.18.0"}}"#;
6320
6321            let ecosystem = state
6322                .ecosystem_registry
6323                .get_for_uri(&uri)
6324                .expect("npm ecosystem not found");
6325
6326            let parse_result = ecosystem.parse_manifest(content, &uri).await;
6327            assert!(parse_result.is_ok());
6328
6329            let doc_state = DocumentState::new_from_parse_result(
6330                EcosystemId::Npm,
6331                content.to_string(),
6332                parse_result.unwrap(),
6333            );
6334            state.update_document(uri.clone(), doc_state);
6335
6336            let doc = state.get_document(&uri).unwrap();
6337            assert_eq!(doc.ecosystem_id(), "npm");
6338        }
6339
6340        /// Impl-critic S1 regression: a version-guarded reparse whose `expected_version` no
6341        /// longer matches the document's *current* version (a concurrent `did_change` already
6342        /// landed) must not commit — the older, guarded reparse would otherwise silently
6343        /// revert the newer edit.
6344        #[tokio::test]
6345        async fn test_handle_document_change_guarded_skips_commit_when_version_changed() {
6346            use crate::test_utils::test_helpers::create_test_client_and_config;
6347
6348            let state = Arc::new(ServerState::new());
6349            let uri = deps_core::test_util::test_uri("/test/package.json");
6350            let original_content = r#"{"dependencies": {"express": "^4.18.0"}}"#.to_string();
6351            let (client, config) = create_test_client_and_config();
6352
6353            handle_document_open(
6354                uri.clone(),
6355                original_content,
6356                Some(1),
6357                Arc::clone(&state),
6358                client.clone(),
6359                Arc::clone(&config),
6360            )
6361            .await
6362            .unwrap();
6363
6364            // A real, concurrent `did_change` lands and commits version 2 — simulating this
6365            // landing while a watched-config-triggered reparse (still snapshotted at version
6366            // 1) is in flight.
6367            let concurrent_content = r#"{"dependencies": {"express": "^4.19.0"}}"#.to_string();
6368            handle_document_change(
6369                uri.clone(),
6370                concurrent_content.clone(),
6371                Some(2),
6372                Arc::clone(&state),
6373                client.clone(),
6374                Arc::clone(&config),
6375            )
6376            .await
6377            .unwrap();
6378
6379            // The watched-config-triggered reparse now runs, still expecting version 1 (its
6380            // stale pre-race snapshot) and carrying content from before the concurrent edit.
6381            let stale_content =
6382                r#"{"dependencies": {"express": "^4.18.0", "lodash": "^4.0.0"}}"#.to_string();
6383            let task = handle_document_change_guarded(
6384                uri.clone(),
6385                stale_content,
6386                Some(3),
6387                CommitGuard::ExpectVersion(Some(1)),
6388                RefetchPolicy::Diff,
6389                Arc::clone(&state),
6390                client,
6391                config,
6392            )
6393            .await
6394            .unwrap();
6395            assert!(
6396                task.is_none(),
6397                "a version mismatch must skip the commit and return None, never a sentinel \
6398                 task (impl-critic S3)"
6399            );
6400
6401            let doc = state.get_document(&uri).unwrap();
6402            assert_eq!(
6403                doc.content, concurrent_content,
6404                "a stale guarded reparse must not overwrite content committed after its snapshot"
6405            );
6406            assert_eq!(doc.version, Some(2));
6407        }
6408
6409        /// The mirror case: `expected_version` still matches the document's current version,
6410        /// so the guarded reparse must commit normally.
6411        #[tokio::test]
6412        async fn test_handle_document_change_guarded_commits_when_version_matches() {
6413            use crate::test_utils::test_helpers::create_test_client_and_config;
6414
6415            let state = Arc::new(ServerState::new());
6416            let uri = deps_core::test_util::test_uri("/test/package.json");
6417            let original_content = r#"{"dependencies": {"express": "^4.18.0"}}"#.to_string();
6418            let (client, config) = create_test_client_and_config();
6419
6420            handle_document_open(
6421                uri.clone(),
6422                original_content,
6423                Some(1),
6424                Arc::clone(&state),
6425                client.clone(),
6426                Arc::clone(&config),
6427            )
6428            .await
6429            .unwrap();
6430
6431            let new_content = r#"{"dependencies": {"express": "^4.19.0"}}"#.to_string();
6432            let task = handle_document_change_guarded(
6433                uri.clone(),
6434                new_content.clone(),
6435                Some(1),
6436                CommitGuard::ExpectVersion(Some(1)),
6437                RefetchPolicy::Diff,
6438                Arc::clone(&state),
6439                client,
6440                config,
6441            )
6442            .await
6443            .unwrap();
6444            task.expect("a matching version must commit and spawn a real task")
6445                .await
6446                .unwrap();
6447
6448            let doc = state.get_document(&uri).unwrap();
6449            assert_eq!(doc.content, new_content);
6450        }
6451
6452        /// Impl-critic S3 regression: a skipped guarded reparse must never register a
6453        /// sentinel task via `spawn_background_task` — doing so would abort whatever real
6454        /// background task (e.g. a concurrent edit's own registry fetch + diagnostics
6455        /// publish) is already registered for that URI. Unlike the two tests above, this one
6456        /// exercises the actual task registry (`ServerState::spawn_background_task`), not
6457        /// just the returned handle directly.
6458        #[tokio::test]
6459        async fn test_guarded_reparse_skip_does_not_abort_pre_existing_background_task() {
6460            use crate::test_utils::test_helpers::create_test_client_and_config;
6461            use std::sync::atomic::{AtomicBool, Ordering};
6462
6463            let state = Arc::new(ServerState::new());
6464            let uri = deps_core::test_util::test_uri("/test/package.json");
6465            let original_content = r#"{"dependencies": {"express": "^4.18.0"}}"#.to_string();
6466            let (client, config) = create_test_client_and_config();
6467
6468            handle_document_open(
6469                uri.clone(),
6470                original_content,
6471                Some(1),
6472                Arc::clone(&state),
6473                client.clone(),
6474                Arc::clone(&config),
6475            )
6476            .await
6477            .unwrap();
6478
6479            // Stands in for the real background task a concurrent `did_change` would already
6480            // have installed by the time a stale, guarded reparse runs.
6481            let ran_to_completion = Arc::new(AtomicBool::new(false));
6482            let flag = Arc::clone(&ran_to_completion);
6483            let pre_existing_task = tokio::spawn(async move {
6484                tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6485                flag.store(true, Ordering::SeqCst);
6486            });
6487            state
6488                .spawn_background_task(uri.clone(), pre_existing_task)
6489                .await;
6490
6491            // A guarded reparse whose expected version no longer matches — must skip. Mirrors
6492            // `Backend::handle_watched_config_change`'s exact branching: `spawn_background_task`
6493            // is called only on `Some`, never on a skip.
6494            let stale_content = r#"{"dependencies": {"express": "^4.19.0"}}"#.to_string();
6495            let result = handle_document_change_guarded(
6496                uri.clone(),
6497                stale_content,
6498                Some(2),
6499                CommitGuard::ExpectVersion(Some(999)),
6500                RefetchPolicy::Diff,
6501                Arc::clone(&state),
6502                client,
6503                config,
6504            )
6505            .await
6506            .unwrap();
6507            assert!(result.is_none(), "a version mismatch must skip the commit");
6508            if let Some(task) = result {
6509                state.spawn_background_task(uri.clone(), task).await;
6510            }
6511
6512            // If the pre-existing task had instead been aborted, it would never reach the
6513            // `store(true, ...)` line above; give it well past its 50ms sleep to prove it ran
6514            // to completion undisturbed.
6515            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
6516            assert!(
6517                ran_to_completion.load(Ordering::SeqCst),
6518                "the pre-existing background task must not be aborted by a skipped guarded reparse"
6519            );
6520        }
6521    }
6522
6523    // PyPI-specific tests
6524    #[cfg(feature = "pypi")]
6525    mod pypi_tests {
6526        use super::*;
6527
6528        #[test]
6529        fn test_ecosystem_registry_lookup() {
6530            let state = ServerState::new();
6531            let pypi_uri = deps_core::test_util::test_uri("/test/pyproject.toml");
6532            assert!(state.ecosystem_registry.get_for_uri(&pypi_uri).is_some());
6533        }
6534
6535        #[tokio::test]
6536        async fn test_document_parsing() {
6537            let state = Arc::new(ServerState::new());
6538            let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
6539            let content = r#"[project]
6540dependencies = ["requests>=2.0.0"]
6541"#;
6542
6543            let ecosystem = state
6544                .ecosystem_registry
6545                .get_for_uri(&uri)
6546                .expect("pypi ecosystem not found");
6547
6548            let parse_result = ecosystem.parse_manifest(content, &uri).await;
6549            assert!(parse_result.is_ok());
6550
6551            let doc_state = DocumentState::new_from_parse_result(
6552                EcosystemId::Pypi,
6553                content.to_string(),
6554                parse_result.unwrap(),
6555            );
6556            state.update_document(uri.clone(), doc_state);
6557
6558            let doc = state.get_document(&uri).unwrap();
6559            assert_eq!(doc.ecosystem_id(), "pypi");
6560        }
6561    }
6562
6563    /// End-to-end PyPI key guard (critic S4): drives the *real* pypi parser
6564    /// and formatter (not a hand-rolled mock) through the full
6565    /// fetch -> re-key -> store -> diagnostic pipeline for an `==`-pinned
6566    /// dependency with no lock file, declared as a Poetry
6567    /// `[tool.poetry.dependencies]` table key. Poetry's table-key path keeps
6568    /// `Dependency::name()` exactly as written in the manifest (unlike a PEP
6569    /// 508 requirement *string* — `pyproject.toml`'s PEP 621 array or
6570    /// `requirements.txt` — where `pep508_rs::PackageName` already
6571    /// PEP 503-normalizes at construction, so raw and normalized already
6572    /// coincide there and could not exercise this guard); the Poetry path is
6573    /// therefore the one place a manifest-declared underscore/dotted name
6574    /// genuinely reaches `FetchResult::yanked_versions` unnormalized (§3.1).
6575    /// Asserts BOTH that `DocumentState::yanked_versions` ends up keyed by
6576    /// the *normalized* name and that the diagnostic actually reaches
6577    /// `generate_diagnostics_from_cache`'s output — either alone would miss
6578    /// a regression the other half could hide (a normalized key with a
6579    /// diagnostic-generation bug that never reads it, or a working
6580    /// diagnostic built by accident on a raw key that happens to already be
6581    /// normalized).
6582    #[cfg(feature = "pypi")]
6583    mod pypi_yanked_key_guard_tests {
6584        use super::*;
6585        use deps_core::{DiagnosticSeverities, Metadata, Version, VersionData};
6586        use std::any::Any;
6587
6588        #[derive(Debug, Clone)]
6589        struct MockYankVersion {
6590            version: ConcreteVersion,
6591            yanked: bool,
6592        }
6593
6594        impl Version for MockYankVersion {
6595            fn version_string(&self) -> &ConcreteVersion {
6596                &self.version
6597            }
6598            fn removal_status(&self) -> deps_core::RemovalStatus {
6599                deps_core::RemovalStatus::from_yanked(self.yanked)
6600            }
6601            fn as_any(&self) -> &dyn Any {
6602                self
6603            }
6604        }
6605
6606        /// Reports `pinned_version` as yanked and a different, non-yanked
6607        /// `"9.9.9"` as latest, for every package name it's asked about —
6608        /// good enough for a single-dependency guard case.
6609        struct MockYankedRegistry {
6610            pinned_version: &'static str,
6611        }
6612
6613        impl Registry for MockYankedRegistry {
6614            fn get_versions<'a>(
6615                &'a self,
6616                _name: &'a PackageName,
6617            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
6618            {
6619                let versions = vec![
6620                    Box::new(MockYankVersion {
6621                        version: "9.9.9".into(),
6622                        yanked: false,
6623                    }) as Box<dyn Version>,
6624                    Box::new(MockYankVersion {
6625                        version: self.pinned_version.into(),
6626                        yanked: true,
6627                    }) as Box<dyn Version>,
6628                ];
6629                Box::pin(async move { Ok(versions) })
6630            }
6631
6632            fn get_latest_matching<'a>(
6633                &'a self,
6634                _name: &'a PackageName,
6635                _req: &'a VersionReq,
6636            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
6637            {
6638                let latest = Box::new(MockYankVersion {
6639                    version: "9.9.9".into(),
6640                    yanked: false,
6641                }) as Box<dyn Version>;
6642                Box::pin(async move { Ok(Some(latest)) })
6643            }
6644
6645            fn search<'a>(
6646                &'a self,
6647                _query: &'a str,
6648                _limit: usize,
6649            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
6650            {
6651                Box::pin(async move { Ok(vec![]) })
6652            }
6653
6654            fn as_any(&self) -> &dyn Any {
6655                self
6656            }
6657        }
6658
6659        /// Runs the full pipeline for one Poetry `[tool.poetry.dependencies]`
6660        /// table-key dependency, declared with an `==pinned_version` pin and
6661        /// no lock file, and returns the generated diagnostics plus the
6662        /// stored (normalized-keyed) yanked map.
6663        async fn run_pipeline(
6664            raw_name: &str,
6665            pinned_version: &'static str,
6666        ) -> (
6667            Vec<tower_lsp_server::ls_types::Diagnostic>,
6668            HashMap<String, (ConcreteVersion, RemovalStatus)>,
6669        ) {
6670            let state = Arc::new(ServerState::new());
6671            let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
6672            // The TOML key is quoted so a dotted name (e.g. `zope.interface`)
6673            // is a literal key rather than TOML's dotted-key table-nesting
6674            // syntax.
6675            let content =
6676                format!("[tool.poetry.dependencies]\n\"{raw_name}\" = \"=={pinned_version}\"\n");
6677
6678            let ecosystem = state
6679                .ecosystem_registry
6680                .get_for_uri(&uri)
6681                .expect("pypi ecosystem not found");
6682            let formatter = ecosystem.formatter();
6683
6684            let parse_result = ecosystem
6685                .parse_manifest(&content, &uri)
6686                .await
6687                .expect("a single Poetry table-key dependency must parse");
6688            assert_eq!(
6689                parse_result
6690                    .dependencies()
6691                    .iter()
6692                    .map(|d| d.name().to_string())
6693                    .collect::<Vec<_>>(),
6694                vec![raw_name.to_string()],
6695                "Poetry table-key parsing must keep the manifest-declared name as-is"
6696            );
6697
6698            let resolved_versions = HashMap::new();
6699            let dep_names: Vec<PackageName> = parse_result
6700                .dependencies()
6701                .into_iter()
6702                .map(|d| d.name().clone())
6703                .collect();
6704            let in_use = collect_in_use_versions(
6705                parse_result.as_ref(),
6706                &resolved_versions,
6707                formatter,
6708                EcosystemId::Pypi,
6709            );
6710            // Sanity check on the fix this guard exists for: the pep440
6711            // `==` comparator must already be stripped here.
6712            assert_eq!(
6713                in_use.get(&PackageName::new(raw_name)),
6714                Some(&vec![pinned_version.to_string()])
6715            );
6716
6717            let registry: Arc<dyn Registry> = Arc::new(MockYankedRegistry { pinned_version });
6718            let fetch_result = fetch_latest_versions_parallel(
6719                registry,
6720                with_registry_source(dep_names),
6721                &in_use,
6722                None,
6723                deps_core::freshness::FreshnessSettings::default(),
6724                5,
6725                10,
6726                None,
6727            )
6728            .await;
6729
6730            let yanked_versions: HashMap<String, (ConcreteVersion, RemovalStatus)> = fetch_result
6731                .yanked_versions
6732                .into_iter()
6733                .map(|(name, v)| (formatter.normalize_package_name(&name), v))
6734                .collect();
6735
6736            let mut doc_state = DocumentState::new_from_parse_result(
6737                EcosystemId::Pypi,
6738                content.clone(),
6739                parse_result,
6740            );
6741            doc_state.update_cached_versions(fetch_result.versions);
6742            let mut outcomes = DependencyOutcomes::new();
6743            for (name, v) in yanked_versions.clone() {
6744                outcomes.set_yanked(name, v);
6745            }
6746            doc_state.replace_outcomes(outcomes);
6747            state.update_document(uri.clone(), doc_state);
6748
6749            let doc = state.get_document(&uri).unwrap();
6750            let diagnostics = deps_core::lsp_helpers::generate_diagnostics_from_cache(
6751                doc.parse_result().unwrap(),
6752                VersionData::new(&doc.cached_versions, &doc.resolved_versions)
6753                    .with_outcomes(&doc.outcomes),
6754                formatter,
6755                &uri,
6756                deps_core::freshness::FreshnessSettings::default(),
6757                DiagnosticSeverities::default(),
6758                deps_core::PublishTime::now(),
6759            );
6760
6761            (diagnostics, yanked_versions)
6762        }
6763
6764        #[tokio::test]
6765        async fn typing_extensions_underscore_name_resolves_via_normalized_key() {
6766            let (diagnostics, yanked_versions) = run_pipeline("typing_extensions", "4.9.0").await;
6767
6768            assert_eq!(
6769                yanked_versions.get("typing-extensions"),
6770                Some(&(ConcreteVersion::new("4.9.0"), RemovalStatus::Yanked)),
6771                "must be keyed by the normalized (dash) name, not the raw manifest name"
6772            );
6773            assert!(
6774                diagnostics.iter().any(|d| d.message.contains("4.9.0")),
6775                "yanked diagnostic must reach the generated output: {diagnostics:?}"
6776            );
6777        }
6778
6779        #[tokio::test]
6780        async fn zope_interface_dotted_name_resolves_via_normalized_key() {
6781            let (diagnostics, yanked_versions) = run_pipeline("zope.interface", "5.0.0").await;
6782
6783            assert_eq!(
6784                yanked_versions.get("zope-interface"),
6785                Some(&(ConcreteVersion::new("5.0.0"), RemovalStatus::Yanked)),
6786                "must be keyed by the normalized (dotted -> dash) name"
6787            );
6788            assert!(
6789                diagnostics.iter().any(|d| d.message.contains("5.0.0")),
6790                "yanked diagnostic must reach the generated output: {diagnostics:?}"
6791            );
6792        }
6793    }
6794
6795    // Go-specific tests
6796    #[cfg(feature = "go")]
6797    mod go_tests {
6798        use super::*;
6799
6800        #[test]
6801        fn test_ecosystem_registry_lookup() {
6802            let state = ServerState::new();
6803            let go_uri = deps_core::test_util::test_uri("/test/go.mod");
6804            assert!(state.ecosystem_registry.get_for_uri(&go_uri).is_some());
6805        }
6806
6807        #[tokio::test]
6808        async fn test_document_parsing() {
6809            let state = Arc::new(ServerState::new());
6810            let uri = deps_core::test_util::test_uri("/test/go.mod");
6811            let content = r"module example.com/mymodule
6812
6813go 1.21
6814
6815require github.com/gorilla/mux v1.8.0
6816";
6817
6818            let ecosystem = state
6819                .ecosystem_registry
6820                .get_for_uri(&uri)
6821                .expect("go ecosystem not found");
6822
6823            let parse_result = ecosystem.parse_manifest(content, &uri).await;
6824            assert!(parse_result.is_ok());
6825
6826            let doc_state = DocumentState::new_from_parse_result(
6827                EcosystemId::Go,
6828                content.to_string(),
6829                parse_result.unwrap(),
6830            );
6831            state.update_document(uri.clone(), doc_state);
6832
6833            let doc = state.get_document(&uri).unwrap();
6834            assert_eq!(doc.ecosystem_id(), "go");
6835        }
6836
6837        /// Regression test for critique S1 (`.local/handoff/2026-08-23T20-55-32-critic.md`):
6838        /// go.mod's `require` line is the exact MVS-selected version, but go.sum only ever
6839        /// gets appended to, so a stale higher version left over from a downgrade can still
6840        /// be recorded there and win last-occurrence-wins parsing (#235). The instant-cache
6841        /// seed in `handle_document_open` must not copy that stale value into
6842        /// `cached_versions` (the "latest" comparison operand) for such a dependency, or it
6843        /// would desync against the go.mod-accurate `resolved_versions` value during the
6844        /// cold-open window before the registry fetch completes.
6845        #[tokio::test]
6846        async fn test_handle_document_open_go_instant_cache_excludes_stale_require_version() {
6847            use crate::test_utils::test_helpers::create_test_client_and_config;
6848            use std::fs;
6849            use tempfile::TempDir;
6850            use tokio::time::{Duration, sleep};
6851
6852            let temp_dir = TempDir::new().unwrap();
6853            let go_mod_path = temp_dir.path().join("go.mod");
6854            let go_sum_path = temp_dir.path().join("go.sum");
6855
6856            // go.mod was downgraded back to v1.8.0 after having briefly required v1.8.1.
6857            let go_mod_content = r"module example.com/mymodule
6858
6859go 1.21
6860
6861require github.com/gorilla/mux v1.8.0
6862";
6863            fs::write(&go_mod_path, go_mod_content).unwrap();
6864
6865            // go.sum is a checksum ledger, not pruned on downgrade: it still carries the
6866            // higher v1.8.1 entry appended before the downgrade, which sorts last and wins
6867            // naive last-occurrence-wins parsing.
6868            let go_sum_content = r"github.com/gorilla/mux v1.8.0 h1:hash1=
6869github.com/gorilla/mux v1.8.1 h1:hash2=
6870";
6871            fs::write(&go_sum_path, go_sum_content).unwrap();
6872
6873            let uri = Uri::from_file_path(&go_mod_path).unwrap();
6874            let state = Arc::new(ServerState::new());
6875            let (client, config) = create_test_client_and_config();
6876
6877            handle_document_open(
6878                uri.clone(),
6879                go_mod_content.to_string(),
6880                Some(1),
6881                state.clone(),
6882                client,
6883                config,
6884            )
6885            .await
6886            .expect("go.mod should open successfully");
6887
6888            let dep_name = PackageName::new("github.com/gorilla/mux");
6889
6890            // The instant-cache seed is disk-only (go.sum read) and runs before any
6891            // registry network call, but it happens in a spawned background task — poll
6892            // briefly instead of assuming a fixed delay.
6893            let mut resolved_seen = false;
6894            for _ in 0..200 {
6895                if state
6896                    .get_document(&uri)
6897                    .is_some_and(|doc| doc.resolved_versions.contains_key(&dep_name))
6898                {
6899                    resolved_seen = true;
6900                    break;
6901                }
6902                sleep(Duration::from_millis(5)).await;
6903            }
6904            assert!(
6905                resolved_seen,
6906                "resolved_versions should be seeded from go.sum shortly after open"
6907            );
6908
6909            let doc = state.get_document(&uri).unwrap();
6910            assert_eq!(
6911                doc.resolved_versions.get(&dep_name),
6912                Some(&ConcreteVersion::new("v1.8.1")),
6913                "sanity check: go.sum's last-occurrence-wins parsing does surface the stale version"
6914            );
6915            assert!(
6916                !doc.cached_versions.contains_key(&dep_name),
6917                "S1: a Go `require` dependency's stale go.sum version must not be seeded into \
6918                 cached_versions (the 'latest' comparison operand) during the cold-open window — \
6919                 doing so would desync it against the go.mod-accurate resolved value and produce \
6920                 a false 'outdated, update to the version you downgraded away from' signal"
6921            );
6922        }
6923    }
6924
6925    // Phase 1: Cache Preservation Tests
6926    #[cfg(feature = "cargo")]
6927    mod incremental_fetch_tests {
6928        use super::*;
6929
6930        #[tokio::test]
6931        async fn test_preserve_cached_versions_on_change() {
6932            let state = Arc::new(ServerState::new());
6933            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
6934
6935            // Initial document with 2 dependencies
6936            let content1 = r#"[dependencies]
6937serde = "1.0"
6938tokio = "1.0"
6939"#;
6940
6941            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
6942            let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
6943            let doc_state1 = DocumentState::new_from_parse_result(
6944                EcosystemId::Cargo,
6945                content1.to_string(),
6946                parse_result1,
6947            );
6948            state.update_document(uri.clone(), doc_state1);
6949
6950            // Manually populate cache (simulating background fetch)
6951            {
6952                let mut doc = state.documents.get_mut(&uri).unwrap();
6953                doc.cached_versions
6954                    .insert("serde".into(), PackageVersions::latest_only("1.0.210"));
6955                doc.cached_versions
6956                    .insert("tokio".into(), PackageVersions::latest_only("1.40.0"));
6957                doc.resolved_versions
6958                    .insert("serde".into(), "1.0.195".into());
6959                doc.resolved_versions
6960                    .insert("tokio".into(), "1.35.0".into());
6961            }
6962
6963            // Verify cache populated
6964            {
6965                let doc = state.get_document(&uri).unwrap();
6966                assert_eq!(doc.cached_versions.len(), 2);
6967                assert_eq!(doc.resolved_versions.len(), 2);
6968            }
6969
6970            // Change document (modify serde version)
6971            let content2 = r#"[dependencies]
6972serde = "1.0.210"
6973tokio = "1.0"
6974"#;
6975
6976            let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.unwrap();
6977            let mut doc_state2 = DocumentState::new_from_parse_result(
6978                EcosystemId::Cargo,
6979                content2.to_string(),
6980                parse_result2,
6981            );
6982
6983            if let Some(old_doc) = state.get_document(&uri) {
6984                preserve_cache(&mut doc_state2, &old_doc);
6985            }
6986
6987            state.update_document(uri.clone(), doc_state2);
6988
6989            // Verify cache preserved after update
6990            {
6991                let doc = state.get_document(&uri).unwrap();
6992                assert_eq!(
6993                    doc.cached_versions.len(),
6994                    2,
6995                    "Cached versions should be preserved"
6996                );
6997                assert_eq!(
6998                    doc.cached_versions.get("serde").map(|v| v.latest.as_str()),
6999                    Some("1.0.210"),
7000                    "serde cache preserved"
7001                );
7002                assert_eq!(
7003                    doc.cached_versions.get("tokio").map(|v| v.latest.as_str()),
7004                    Some("1.40.0"),
7005                    "tokio cache preserved"
7006                );
7007                assert_eq!(
7008                    doc.resolved_versions.len(),
7009                    2,
7010                    "Resolved versions should be preserved"
7011                );
7012            }
7013        }
7014
7015        #[tokio::test]
7016        async fn test_preserve_cache_carries_vulnerabilities_across_edit() {
7017            use deps_core::osv::{ScanOutcome, VulnerabilityMap};
7018
7019            let state = Arc::new(ServerState::new());
7020            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7021
7022            let content1 = r#"[dependencies]
7023time = "0.1.43"
7024"#;
7025            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7026            let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
7027            let doc_state1 = DocumentState::new_from_parse_result(
7028                EcosystemId::Cargo,
7029                content1.to_string(),
7030                parse_result1,
7031            );
7032            state.update_document(uri.clone(), doc_state1);
7033
7034            let mut vulns = VulnerabilityMap::new();
7035            vulns.insert("time".to_string(), ScanOutcome::Clean);
7036            {
7037                let mut doc = state.documents.get_mut(&uri).unwrap();
7038                doc.update_vulnerabilities(vulns);
7039            }
7040
7041            // A whitespace-only edit: DocumentState is rebuilt from scratch,
7042            // which would silently wipe `vulnerabilities` on every keystroke
7043            // without preserve_cache carrying it through (§4).
7044            let content2 = r#"[dependencies]
7045time = "0.1.43"
7046
7047"#;
7048            let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.unwrap();
7049            let mut doc_state2 = DocumentState::new_from_parse_result(
7050                EcosystemId::Cargo,
7051                content2.to_string(),
7052                parse_result2,
7053            );
7054
7055            if let Some(old_doc) = state.get_document(&uri) {
7056                preserve_cache(&mut doc_state2, &old_doc);
7057            }
7058            state.update_document(uri.clone(), doc_state2);
7059
7060            let doc = state.get_document(&uri).unwrap();
7061            assert_matches!(doc.vulnerabilities.get("time"), Some(ScanOutcome::Clean));
7062        }
7063
7064        #[tokio::test]
7065        async fn test_preserve_cache_carries_yanked_versions_across_edit() {
7066            let state = Arc::new(ServerState::new());
7067            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7068
7069            let content1 = r#"[dependencies]
7070time = "0.1.43"
7071"#;
7072            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7073            let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
7074            let doc_state1 = DocumentState::new_from_parse_result(
7075                EcosystemId::Cargo,
7076                content1.to_string(),
7077                parse_result1,
7078            );
7079            state.update_document(uri.clone(), doc_state1);
7080
7081            {
7082                let mut doc = state.documents.get_mut(&uri).unwrap();
7083                doc.replace_outcomes(DependencyOutcomes::new().with_yanked(
7084                    "time",
7085                    (ConcreteVersion::new("0.1.43"), RemovalStatus::Yanked),
7086                ));
7087            }
7088
7089            // A whitespace-only edit: DocumentState is rebuilt from scratch,
7090            // which would silently flicker the yanked diagnostic off on
7091            // every keystroke without preserve_cache carrying it through.
7092            let content2 = r#"[dependencies]
7093time = "0.1.43"
7094
7095"#;
7096            let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.unwrap();
7097            let mut doc_state2 = DocumentState::new_from_parse_result(
7098                EcosystemId::Cargo,
7099                content2.to_string(),
7100                parse_result2,
7101            );
7102
7103            if let Some(old_doc) = state.get_document(&uri) {
7104                preserve_cache(&mut doc_state2, &old_doc);
7105            }
7106            state.update_document(uri.clone(), doc_state2);
7107
7108            let doc = state.get_document(&uri).unwrap();
7109            assert_eq!(
7110                doc.outcomes.yanked("time"),
7111                Some(&(ConcreteVersion::new("0.1.43"), RemovalStatus::Yanked))
7112            );
7113        }
7114
7115        #[tokio::test]
7116        async fn test_preserve_cache_carries_deprecations_across_edit() {
7117            let state = Arc::new(ServerState::new());
7118            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7119
7120            let content1 = r#"[dependencies]
7121time = "0.1.43"
7122"#;
7123            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7124            let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
7125            let doc_state1 = DocumentState::new_from_parse_result(
7126                EcosystemId::Cargo,
7127                content1.to_string(),
7128                parse_result1,
7129            );
7130            state.update_document(uri.clone(), doc_state1);
7131
7132            {
7133                let mut doc = state.documents.get_mut(&uri).unwrap();
7134                doc.replace_outcomes(DependencyOutcomes::new().with_deprecation(
7135                    "time",
7136                    Deprecation {
7137                        reason: Some("archived".to_string()),
7138                        replacement: None,
7139                    },
7140                ));
7141            }
7142
7143            // A whitespace-only edit: DocumentState is rebuilt from scratch, which
7144            // would silently flicker the deprecation diagnostic off on every keystroke
7145            // without preserve_cache carrying it through.
7146            let content2 = r#"[dependencies]
7147time = "0.1.43"
7148
7149"#;
7150            let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.unwrap();
7151            let mut doc_state2 = DocumentState::new_from_parse_result(
7152                EcosystemId::Cargo,
7153                content2.to_string(),
7154                parse_result2,
7155            );
7156
7157            if let Some(old_doc) = state.get_document(&uri) {
7158                preserve_cache(&mut doc_state2, &old_doc);
7159            }
7160            state.update_document(uri.clone(), doc_state2);
7161
7162            let doc = state.get_document(&uri).unwrap();
7163            assert_eq!(
7164                doc.outcomes.deprecation("time"),
7165                Some(&Deprecation {
7166                    reason: Some("archived".to_string()),
7167                    replacement: None,
7168                })
7169            );
7170        }
7171
7172        #[tokio::test]
7173        async fn test_deprecations_pruned_on_dependency_removal_by_normalized_name() {
7174            let state = Arc::new(ServerState::new());
7175            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7176
7177            let content1 = r#"[dependencies]
7178serde = "1.0"
7179time = "0.1.43"
7180"#;
7181            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7182            let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
7183            let doc_state1 = DocumentState::new_from_parse_result(
7184                EcosystemId::Cargo,
7185                content1.to_string(),
7186                parse_result1,
7187            );
7188            state.update_document(uri.clone(), doc_state1);
7189
7190            {
7191                let mut doc = state.documents.get_mut(&uri).unwrap();
7192                doc.replace_outcomes(DependencyOutcomes::new().with_deprecation(
7193                    "time",
7194                    Deprecation {
7195                        reason: Some("archived".to_string()),
7196                        replacement: None,
7197                    },
7198                ));
7199            }
7200
7201            let content2 = r#"[dependencies]
7202serde = "1.0"
7203"#;
7204            let old_deps: HashMap<PackageName, Vec<Option<VersionReq>>> =
7205                [("serde", None), ("time", None)]
7206                    .into_iter()
7207                    .map(|(n, r)| (PackageName::new(n), vec![r]))
7208                    .collect();
7209            let new_deps: HashMap<PackageName, Vec<Option<VersionReq>>> =
7210                std::iter::once((PackageName::new("serde"), vec![None])).collect();
7211            let diff = DependencyDiff::compute(&old_deps, &new_deps);
7212            assert_eq!(diff.removed, vec![PackageName::new("time")]);
7213
7214            let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.unwrap();
7215            let mut doc_state2 = DocumentState::new_from_parse_result(
7216                EcosystemId::Cargo,
7217                content2.to_string(),
7218                parse_result2,
7219            );
7220
7221            if let Some(old_doc) = state.get_document(&uri) {
7222                preserve_cache(&mut doc_state2, &old_doc);
7223            }
7224
7225            let formatter = ecosystem.formatter();
7226            for removed_dep in &diff.removed {
7227                doc_state2
7228                    .outcomes
7229                    .remove(&formatter.normalize_package_name(removed_dep));
7230            }
7231
7232            state.update_document(uri.clone(), doc_state2);
7233
7234            let doc = state.get_document(&uri).unwrap();
7235            assert!(
7236                doc.outcomes.deprecation("time").is_none(),
7237                "removed dependency's deprecation entry must be pruned"
7238            );
7239        }
7240
7241        /// D2 invariant: unlike `yanked_versions`, a #205 finding is package-level, not
7242        /// tied to the declared version — editing which version is pinned must NOT
7243        /// drop it, mirroring the deliberate absence of a
7244        /// `diff.version_changed`-triggered prune in `handle_document_change`.
7245        #[tokio::test]
7246        async fn test_deprecations_survive_version_change_unlike_yanked_versions() {
7247            let state = Arc::new(ServerState::new());
7248            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7249
7250            let content1 = r#"[dependencies]
7251time = "0.1.44"
7252"#;
7253            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7254            let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
7255            let doc_state1 = DocumentState::new_from_parse_result(
7256                EcosystemId::Cargo,
7257                content1.to_string(),
7258                parse_result1,
7259            );
7260            state.update_document(uri.clone(), doc_state1);
7261
7262            {
7263                let mut doc = state.documents.get_mut(&uri).unwrap();
7264                doc.replace_outcomes(
7265                    DependencyOutcomes::new()
7266                        .with_yanked("time", ("0.1.44".into(), RemovalStatus::Yanked))
7267                        .with_deprecation(
7268                            "time",
7269                            Deprecation {
7270                                reason: Some("archived".to_string()),
7271                                replacement: None,
7272                            },
7273                        ),
7274                );
7275            }
7276
7277            // Edit the pin from a yanked version to a safe one — the *version-level*
7278            // yanked finding is stale and must be dropped, but the *package-level*
7279            // deprecation finding is not tied to which version is pinned.
7280            let content2 = r#"[dependencies]
7281time = "0.1.43"
7282"#;
7283            let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.unwrap();
7284            let mut doc_state2 = DocumentState::new_from_parse_result(
7285                EcosystemId::Cargo,
7286                content2.to_string(),
7287                parse_result2,
7288            );
7289
7290            if let Some(old_doc) = state.get_document(&uri) {
7291                preserve_cache(&mut doc_state2, &old_doc);
7292            }
7293            doc_state2.outcomes.clear_yanked("time");
7294
7295            state.update_document(uri.clone(), doc_state2);
7296
7297            let doc = state.get_document(&uri).unwrap();
7298            assert!(
7299                doc.outcomes.yanked("time").is_none(),
7300                "the stale version-level yanked finding must be dropped"
7301            );
7302            assert_eq!(
7303                doc.outcomes.deprecation("time"),
7304                Some(&Deprecation {
7305                    reason: Some("archived".to_string()),
7306                    replacement: None,
7307                }),
7308                "the package-level deprecation finding must survive a version-only edit"
7309            );
7310        }
7311
7312        /// Mirrors `test_deprecations_survive_version_change_unlike_yanked_versions` for
7313        /// `fetch_failed` (#267): the `version_changed` loop in `handle_document_change`
7314        /// clears both `yanked` and `fetch_failed` together (lifecycle.rs, right above
7315        /// `deps_to_fetch.extend`), never `deprecation`. With all three channels set on
7316        /// the same normalized name, a version-only edit must clear the first two and
7317        /// leave the deprecation finding untouched.
7318        #[tokio::test]
7319        async fn test_fetch_failed_and_yanked_cleared_but_deprecation_survives_version_change() {
7320            let state = Arc::new(ServerState::new());
7321            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7322
7323            let content1 = r#"[dependencies]
7324time = "0.1.44"
7325"#;
7326            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7327            let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
7328            let doc_state1 = DocumentState::new_from_parse_result(
7329                EcosystemId::Cargo,
7330                content1.to_string(),
7331                parse_result1,
7332            );
7333            state.update_document(uri.clone(), doc_state1);
7334
7335            {
7336                let mut doc = state.documents.get_mut(&uri).unwrap();
7337                doc.replace_outcomes(
7338                    DependencyOutcomes::new()
7339                        .with_yanked("time", ("0.1.44".into(), RemovalStatus::Yanked))
7340                        .with_fetch_failure("time", FetchFailure::Transient)
7341                        .with_deprecation(
7342                            "time",
7343                            Deprecation {
7344                                reason: Some("archived".to_string()),
7345                                replacement: None,
7346                            },
7347                        ),
7348                );
7349            }
7350
7351            let content2 = r#"[dependencies]
7352time = "0.1.43"
7353"#;
7354            let old_deps = dependency_version_map(
7355                ecosystem
7356                    .parse_manifest(content1, &uri)
7357                    .await
7358                    .unwrap()
7359                    .as_ref(),
7360            );
7361            let new_deps = dependency_version_map(
7362                ecosystem
7363                    .parse_manifest(content2, &uri)
7364                    .await
7365                    .unwrap()
7366                    .as_ref(),
7367            );
7368            let diff = DependencyDiff::compute(&old_deps, &new_deps);
7369            assert_eq!(diff.version_changed, vec![PackageName::new("time")]);
7370
7371            let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.unwrap();
7372            let mut doc_state2 = DocumentState::new_from_parse_result(
7373                EcosystemId::Cargo,
7374                content2.to_string(),
7375                parse_result2,
7376            );
7377
7378            if let Some(old_doc) = state.get_document(&uri) {
7379                preserve_cache(&mut doc_state2, &old_doc);
7380            }
7381
7382            let formatter = ecosystem.formatter();
7383            for changed_dep in &diff.version_changed {
7384                let normalized = formatter.normalize_package_name(changed_dep);
7385                doc_state2.outcomes.clear_yanked(&normalized);
7386                doc_state2.outcomes.clear_fetch_failure(&normalized);
7387            }
7388
7389            state.update_document(uri.clone(), doc_state2);
7390
7391            let doc = state.get_document(&uri).unwrap();
7392            assert!(
7393                doc.outcomes.yanked("time").is_none(),
7394                "the stale version-level yanked finding must be dropped"
7395            );
7396            assert!(
7397                doc.outcomes.fetch_failure("time").is_none(),
7398                "the stale fetch-failure finding must be dropped"
7399            );
7400            assert_eq!(
7401                doc.outcomes.deprecation("time"),
7402                Some(&Deprecation {
7403                    reason: Some("archived".to_string()),
7404                    replacement: None,
7405                }),
7406                "the package-level deprecation finding must survive a version-only edit"
7407            );
7408        }
7409
7410        /// T4 (C3): a deprecation finding recorded on the full-fetch path must survive
7411        /// a partial didChange fetch that does not re-fetch that package.
7412        #[test]
7413        fn test_merge_deprecations_after_fetch_retains_finding_for_name_not_refetched() {
7414            let state = ServerState::new();
7415            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7416            let formatter = ecosystem.formatter();
7417            let mut doc =
7418                DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
7419            doc.outcomes.set_deprecation(
7420                "vendor/a".to_string(),
7421                Deprecation {
7422                    reason: None,
7423                    replacement: Some("vendor/a2".to_string()),
7424                },
7425            );
7426
7427            // Only "vendor/b" was fetched this round (e.g. a new dependency added by
7428            // the edit); "vendor/a" was untouched.
7429            let mut fetched = HashMap::new();
7430            fetched.insert(
7431                PackageName::new("vendor/b"),
7432                Deprecation {
7433                    reason: Some("abandoned".to_string()),
7434                    replacement: None,
7435                },
7436            );
7437            merge_deprecations_after_fetch(
7438                &mut doc,
7439                &[PackageName::new("vendor/b")],
7440                fetched,
7441                formatter,
7442            );
7443
7444            assert_eq!(
7445                doc.outcomes.deprecation("vendor/a"),
7446                Some(&Deprecation {
7447                    reason: None,
7448                    replacement: Some("vendor/a2".to_string()),
7449                }),
7450                "a finding for a name not in this round's fetch must survive untouched"
7451            );
7452            assert_eq!(
7453                doc.outcomes.deprecation("vendor/b"),
7454                Some(&Deprecation {
7455                    reason: Some("abandoned".to_string()),
7456                    replacement: None,
7457                })
7458            );
7459        }
7460
7461        /// T5 (S1): a package that stops being deprecated must have its finding
7462        /// cleared once re-fetched clean — distinct from a name simply not fetched
7463        /// this round (T4), which must be left untouched.
7464        #[test]
7465        fn test_merge_deprecations_after_fetch_clears_finding_when_refetched_clean() {
7466            let state = ServerState::new();
7467            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7468            let formatter = ecosystem.formatter();
7469            let mut doc =
7470                DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
7471            doc.outcomes.set_deprecation(
7472                "vendor/a".to_string(),
7473                Deprecation {
7474                    reason: None,
7475                    replacement: None,
7476                },
7477            );
7478
7479            // "vendor/a" was re-fetched this round and no longer reports a finding.
7480            merge_deprecations_after_fetch(
7481                &mut doc,
7482                &[PackageName::new("vendor/a")],
7483                HashMap::new(),
7484                formatter,
7485            );
7486
7487            assert!(
7488                doc.outcomes.deprecation("vendor/a").is_none(),
7489                "a name that was fetched and produced no finding must be cleared"
7490            );
7491        }
7492
7493        /// I2: two raw names that normalize to the same key (Composer's `normalize_package_name`
7494        /// lowercases, so `"Vendor/Package"` and `"vendor/package"` collide) must merge
7495        /// deterministically — a finding under either raw name must survive regardless of
7496        /// `fetched_names`' (unspecified `HashMap::keys()`) iteration order.
7497        #[test]
7498        fn test_merge_deprecations_after_fetch_is_order_independent_across_normalization_collision()
7499        {
7500            let state = ServerState::new();
7501            let ecosystem = state.ecosystem_registry.get("composer").unwrap();
7502            let formatter = ecosystem.formatter();
7503
7504            for names in [
7505                [
7506                    PackageName::new("vendor/package"),
7507                    PackageName::new("Vendor/Package"),
7508                ],
7509                [
7510                    PackageName::new("Vendor/Package"),
7511                    PackageName::new("vendor/package"),
7512                ],
7513            ] {
7514                let mut doc =
7515                    DocumentState::new_without_parse_result(EcosystemId::Composer, String::new());
7516
7517                let mut fetched = HashMap::new();
7518                fetched.insert(
7519                    PackageName::new("Vendor/Package"),
7520                    Deprecation {
7521                        reason: None,
7522                        replacement: Some("vendor/other".to_string()),
7523                    },
7524                );
7525                // "vendor/package" (lowercase) is fetched too and reports no finding.
7526
7527                merge_deprecations_after_fetch(&mut doc, &names, fetched, formatter);
7528
7529                assert_eq!(
7530                    doc.outcomes.deprecation("vendor/package"),
7531                    Some(&Deprecation {
7532                        reason: None,
7533                        replacement: Some("vendor/other".to_string()),
7534                    }),
7535                    "a finding under either raw name sharing a normalized key must survive, \
7536                     regardless of fetch order: {names:?}"
7537                );
7538            }
7539        }
7540
7541        /// Regression for critic finding C3 (#550): `merge_no_comparable_versions_after_fetch`'s
7542        /// `found == true` branch (`set_no_comparable_versions`) had zero coverage — mirrors
7543        /// `test_merge_deprecations_after_fetch_retains_finding_for_name_not_refetched`, but
7544        /// for the *first-time-set* case: a package attempted this round whose fetch
7545        /// genuinely found zero comparable versions must be recorded, and an unrelated
7546        /// package not attempted this round must be left untouched either way.
7547        #[test]
7548        fn test_merge_no_comparable_versions_after_fetch_sets_finding_for_newly_flagged_name() {
7549            let state = ServerState::new();
7550            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7551            let formatter = ecosystem.formatter();
7552            let mut doc =
7553                DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
7554
7555            // "vendor/b" was attempted this round (e.g. a new dependency added by the
7556            // edit) and its fetch genuinely succeeded with zero comparable versions;
7557            // "vendor/a" was not attempted at all.
7558            let mut fetched = HashSet::new();
7559            fetched.insert(PackageName::new("vendor/b"));
7560            merge_no_comparable_versions_after_fetch(
7561                &mut doc,
7562                &[PackageName::new("vendor/b")],
7563                fetched,
7564                formatter,
7565            );
7566
7567            assert!(
7568                doc.outcomes.no_comparable_versions("vendor/b"),
7569                "a package whose fetch was attempted and found zero comparable versions \
7570                 this round must be recorded"
7571            );
7572            assert!(
7573                !doc.outcomes.no_comparable_versions("vendor/a"),
7574                "a package never attempted this round must not be flagged"
7575            );
7576        }
7577
7578        /// Regression for critic finding C3 (#550): the literal "package no longer has
7579        /// zero-comparable-versions on a subsequent fetch" scenario — e.g.
7580        /// `dtolnay/rust-toolchain` eventually publishes a real `v1.2.3` tag. Mirrors
7581        /// `test_merge_deprecations_after_fetch_clears_finding_when_refetched_clean`.
7582        #[test]
7583        fn test_merge_no_comparable_versions_after_fetch_clears_finding_when_refetched_with_versions()
7584         {
7585            let state = ServerState::new();
7586            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7587            let formatter = ecosystem.formatter();
7588            let mut doc =
7589                DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
7590            doc.outcomes
7591                .set_no_comparable_versions("vendor/a".to_string());
7592
7593            // "vendor/a" was re-fetched this round and this time resolved a real
7594            // version, so it's absent from the fetched-flags set.
7595            merge_no_comparable_versions_after_fetch(
7596                &mut doc,
7597                &[PackageName::new("vendor/a")],
7598                HashSet::new(),
7599                formatter,
7600            );
7601
7602            assert!(
7603                !doc.outcomes.no_comparable_versions("vendor/a"),
7604                "a package that was attempted and this time resolved a real version must \
7605                 have its stale marker cleared, or R5e would keep suppressing Unknown \
7606                 package diagnostics for a name that could now legitimately need one"
7607            );
7608        }
7609
7610        /// A finding for a name not attempted this round (e.g. an unrelated dependency
7611        /// untouched by a partial didChange fetch) must survive untouched — distinct
7612        /// from the clear-on-refetch case above.
7613        #[test]
7614        fn test_merge_no_comparable_versions_after_fetch_retains_finding_for_name_not_attempted() {
7615            let state = ServerState::new();
7616            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7617            let formatter = ecosystem.formatter();
7618            let mut doc =
7619                DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
7620            doc.outcomes
7621                .set_no_comparable_versions("vendor/a".to_string());
7622
7623            // Only "vendor/b" was attempted this round; "vendor/a" was untouched.
7624            merge_no_comparable_versions_after_fetch(
7625                &mut doc,
7626                &[PackageName::new("vendor/b")],
7627                HashSet::new(),
7628                formatter,
7629            );
7630
7631            assert!(
7632                doc.outcomes.no_comparable_versions("vendor/a"),
7633                "a finding for a name not attempted this round must survive untouched"
7634            );
7635        }
7636
7637        #[tokio::test]
7638        async fn test_yanked_versions_pruned_on_dependency_removal_by_normalized_name() {
7639            let state = Arc::new(ServerState::new());
7640            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7641
7642            let content1 = r#"[dependencies]
7643serde = "1.0"
7644time = "0.1.43"
7645"#;
7646            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7647            let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
7648            let doc_state1 = DocumentState::new_from_parse_result(
7649                EcosystemId::Cargo,
7650                content1.to_string(),
7651                parse_result1,
7652            );
7653            state.update_document(uri.clone(), doc_state1);
7654
7655            {
7656                let mut doc = state.documents.get_mut(&uri).unwrap();
7657                doc.replace_outcomes(DependencyOutcomes::new().with_yanked(
7658                    "time",
7659                    (ConcreteVersion::new("0.1.43"), RemovalStatus::Yanked),
7660                ));
7661            }
7662
7663            let content2 = r#"[dependencies]
7664serde = "1.0"
7665"#;
7666            let old_deps: HashMap<PackageName, Vec<Option<VersionReq>>> =
7667                [("serde", None), ("time", None)]
7668                    .into_iter()
7669                    .map(|(n, r)| (PackageName::new(n), vec![r]))
7670                    .collect();
7671            let new_deps: HashMap<PackageName, Vec<Option<VersionReq>>> =
7672                std::iter::once((PackageName::new("serde"), vec![None])).collect();
7673            let diff = DependencyDiff::compute(&old_deps, &new_deps);
7674            assert_eq!(diff.removed, vec![PackageName::new("time")]);
7675
7676            let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.unwrap();
7677            let mut doc_state2 = DocumentState::new_from_parse_result(
7678                EcosystemId::Cargo,
7679                content2.to_string(),
7680                parse_result2,
7681            );
7682
7683            if let Some(old_doc) = state.get_document(&uri) {
7684                preserve_cache(&mut doc_state2, &old_doc);
7685            }
7686
7687            let formatter = ecosystem.formatter();
7688            for removed_dep in &diff.removed {
7689                doc_state2
7690                    .outcomes
7691                    .remove(&formatter.normalize_package_name(removed_dep));
7692            }
7693
7694            state.update_document(uri.clone(), doc_state2);
7695
7696            let doc = state.get_document(&uri).unwrap();
7697            assert!(
7698                doc.outcomes.yanked("time").is_none(),
7699                "removed dependency's yanked entry must be pruned"
7700            );
7701        }
7702
7703        #[tokio::test]
7704        async fn test_yanked_versions_pruned_on_version_change_by_normalized_name() {
7705            // Security F1 / impl-critic S1 (false positive direction):
7706            // editing a dependency from a yanked pin to a safe one, with no
7707            // lock file, must not leave the stale yanked diagnostic
7708            // anchored on the new version's range. Editing in place (not
7709            // add+remove) means the pruning loop for `diff.removed` alone
7710            // would miss this — the name never leaves `diff.removed`, it's
7711            // in `diff.version_changed` instead.
7712            let state = Arc::new(ServerState::new());
7713            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7714
7715            let content1 = r#"[dependencies]
7716time = "=0.1.43"
7717"#;
7718            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7719            let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
7720            let doc_state1 = DocumentState::new_from_parse_result(
7721                EcosystemId::Cargo,
7722                content1.to_string(),
7723                parse_result1,
7724            );
7725            state.update_document(uri.clone(), doc_state1);
7726
7727            // `time` was found yanked at its old pin, "=0.1.43".
7728            {
7729                let mut doc = state.documents.get_mut(&uri).unwrap();
7730                doc.replace_outcomes(DependencyOutcomes::new().with_yanked(
7731                    "time",
7732                    (ConcreteVersion::new("0.1.43"), RemovalStatus::Yanked),
7733                ));
7734            }
7735
7736            // Edited to a different, safe pin — same dependency, in place.
7737            let content2 = r#"[dependencies]
7738time = "=0.1.44"
7739"#;
7740            let old_deps = dependency_version_map(
7741                ecosystem
7742                    .parse_manifest(content1, &uri)
7743                    .await
7744                    .unwrap()
7745                    .as_ref(),
7746            );
7747            let new_deps = dependency_version_map(
7748                ecosystem
7749                    .parse_manifest(content2, &uri)
7750                    .await
7751                    .unwrap()
7752                    .as_ref(),
7753            );
7754            let diff = DependencyDiff::compute(&old_deps, &new_deps);
7755            assert!(diff.added.is_empty());
7756            assert!(diff.removed.is_empty());
7757            assert_eq!(diff.version_changed, vec![PackageName::new("time")]);
7758
7759            let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.unwrap();
7760            let mut doc_state2 = DocumentState::new_from_parse_result(
7761                EcosystemId::Cargo,
7762                content2.to_string(),
7763                parse_result2,
7764            );
7765
7766            if let Some(old_doc) = state.get_document(&uri) {
7767                preserve_cache(&mut doc_state2, &old_doc);
7768            }
7769
7770            let formatter = ecosystem.formatter();
7771            for changed_dep in &diff.version_changed {
7772                doc_state2
7773                    .outcomes
7774                    .clear_yanked(&formatter.normalize_package_name(changed_dep));
7775            }
7776
7777            state.update_document(uri.clone(), doc_state2);
7778
7779            let doc = state.get_document(&uri).unwrap();
7780            assert!(
7781                doc.outcomes.yanked("time").is_none(),
7782                "stale yanked entry against the OLD version must not survive an in-place edit"
7783            );
7784        }
7785
7786        #[tokio::test]
7787        async fn test_fetch_failed_pruned_on_dependency_removal_by_normalized_name() {
7788            // Mirrors `test_yanked_versions_pruned_on_dependency_removal_by_normalized_name`
7789            // for `fetch_failed` (#267): a stale fetch-error marker for a
7790            // dependency the user has since deleted must not linger.
7791            let state = Arc::new(ServerState::new());
7792            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7793
7794            let content1 = r#"[dependencies]
7795serde = "1.0"
7796time = "0.1.43"
7797"#;
7798            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7799            let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
7800            let doc_state1 = DocumentState::new_from_parse_result(
7801                EcosystemId::Cargo,
7802                content1.to_string(),
7803                parse_result1,
7804            );
7805            state.update_document(uri.clone(), doc_state1);
7806
7807            {
7808                let mut doc = state.documents.get_mut(&uri).unwrap();
7809                doc.replace_outcomes(
7810                    DependencyOutcomes::new().with_fetch_failure("time", FetchFailure::Transient),
7811                );
7812            }
7813
7814            let content2 = r#"[dependencies]
7815serde = "1.0"
7816"#;
7817            let old_deps: HashMap<PackageName, Vec<Option<VersionReq>>> =
7818                [("serde", None), ("time", None)]
7819                    .into_iter()
7820                    .map(|(n, r)| (PackageName::new(n), vec![r]))
7821                    .collect();
7822            let new_deps: HashMap<PackageName, Vec<Option<VersionReq>>> =
7823                std::iter::once((PackageName::new("serde"), vec![None])).collect();
7824            let diff = DependencyDiff::compute(&old_deps, &new_deps);
7825            assert_eq!(diff.removed, vec![PackageName::new("time")]);
7826
7827            let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.unwrap();
7828            let mut doc_state2 = DocumentState::new_from_parse_result(
7829                EcosystemId::Cargo,
7830                content2.to_string(),
7831                parse_result2,
7832            );
7833
7834            if let Some(old_doc) = state.get_document(&uri) {
7835                preserve_cache(&mut doc_state2, &old_doc);
7836            }
7837
7838            // Preserved before pruning: proves `preserve_cache` itself
7839            // carries `fetch_failed` across the edit, not just the final
7840            // (already-pruned) state below.
7841            assert!(
7842                doc_state2.outcomes.fetch_failure("time").is_some(),
7843                "preserve_cache must carry fetch_failed across an edit"
7844            );
7845
7846            let formatter = ecosystem.formatter();
7847            for removed_dep in &diff.removed {
7848                doc_state2
7849                    .outcomes
7850                    .remove(&formatter.normalize_package_name(removed_dep));
7851            }
7852
7853            state.update_document(uri.clone(), doc_state2);
7854
7855            let doc = state.get_document(&uri).unwrap();
7856            assert!(
7857                doc.outcomes.fetch_failure("time").is_none(),
7858                "removed dependency's fetch_failed entry must be pruned"
7859            );
7860        }
7861
7862        #[test]
7863        fn test_deps_to_fetch_includes_version_changed_dependencies() {
7864            // Security F1 (false negative direction): editing a dependency
7865            // from a safe pin to a yanked one, with no lock file, must
7866            // still trigger the registry fetch (and therefore the probe) —
7867            // otherwise the yanked pin is never checked at all, since
7868            // `deps_to_fetch.is_empty()` would skip the fetch entirely if
7869            // it only ever contained `diff.added`.
7870            let old = versions(&[("time", Some("=0.1.44"))]);
7871            let new = versions(&[("time", Some("=0.1.43"))]);
7872
7873            let diff = DependencyDiff::compute(&old, &new);
7874            assert!(diff.added.is_empty());
7875            assert_eq!(diff.version_changed, vec![PackageName::new("time")]);
7876            assert!(
7877                diff.needs_fetch(),
7878                "a version-only edit must trigger the registry fetch"
7879            );
7880
7881            // Mirrors the production construction at the `deps_to_fetch`
7882            // site in `handle_document_change`.
7883            let mut deps_to_fetch = diff.added;
7884            deps_to_fetch.extend(diff.version_changed);
7885            assert_eq!(
7886                deps_to_fetch,
7887                vec![PackageName::new("time")],
7888                "the version-changed dependency must be included in the fetch list"
7889            );
7890        }
7891
7892        #[tokio::test]
7893        async fn test_preserve_cache_yanked_versions_stale_after_lockfile_only_change() {
7894            // R3 (accepted, not fixed): the yanked map is computed during the
7895            // registry fetch. A didChange that adds no dependencies skips the
7896            // fetch entirely, so `preserve_cache` carries the *old* yanked
7897            // map forward verbatim even if a lockfile edited underneath (e.g.
7898            // `cargo update` pulling in a newly-yanked release) would have
7899            // changed the answer. This documents the existing behavior,
7900            // identical to `cached_versions`' staleness.
7901            let state = Arc::new(ServerState::new());
7902            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7903
7904            let content = r#"[dependencies]
7905time = "0.1.43"
7906"#;
7907            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7908            let parse_result1 = ecosystem.parse_manifest(content, &uri).await.unwrap();
7909            let doc_state1 = DocumentState::new_from_parse_result(
7910                EcosystemId::Cargo,
7911                content.to_string(),
7912                parse_result1,
7913            );
7914            state.update_document(uri.clone(), doc_state1);
7915
7916            // Stale: `time` was yanked as of the last fetch.
7917            {
7918                let mut doc = state.documents.get_mut(&uri).unwrap();
7919                doc.replace_outcomes(DependencyOutcomes::new().with_yanked(
7920                    "time",
7921                    (ConcreteVersion::new("0.1.43"), RemovalStatus::Yanked),
7922                ));
7923            }
7924
7925            // Identical manifest content re-parsed (as happens on a
7926            // didChangeWatchedFiles-less lockfile edit that doesn't touch the
7927            // manifest text) — no dependency added or removed, so the real
7928            // handler would skip the registry fetch and never re-run the
7929            // yanked probe.
7930            let parse_result2 = ecosystem.parse_manifest(content, &uri).await.unwrap();
7931            let mut doc_state2 = DocumentState::new_from_parse_result(
7932                EcosystemId::Cargo,
7933                content.to_string(),
7934                parse_result2,
7935            );
7936            if let Some(old_doc) = state.get_document(&uri) {
7937                preserve_cache(&mut doc_state2, &old_doc);
7938            }
7939            state.update_document(uri.clone(), doc_state2);
7940
7941            // The stale entry survives verbatim — even if `time` were
7942            // un-yanked (or a different version newly yanked) in the
7943            // lockfile in the meantime, nothing here would know.
7944            let doc = state.get_document(&uri).unwrap();
7945            assert_eq!(
7946                doc.outcomes.yanked("time"),
7947                Some(&(ConcreteVersion::new("0.1.43"), RemovalStatus::Yanked))
7948            );
7949        }
7950
7951        #[tokio::test]
7952        async fn test_first_open_has_empty_cache() {
7953            let state = Arc::new(ServerState::new());
7954            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7955
7956            let content = r#"[dependencies]
7957serde = "1.0"
7958"#;
7959
7960            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7961            let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
7962            let doc_state = DocumentState::new_from_parse_result(
7963                EcosystemId::Cargo,
7964                content.to_string(),
7965                parse_result,
7966            );
7967            state.update_document(uri.clone(), doc_state);
7968
7969            // First open: cache should be empty (no old state to preserve)
7970            let doc = state.get_document(&uri).unwrap();
7971            assert_eq!(
7972                doc.cached_versions.len(),
7973                0,
7974                "First open should have empty cache"
7975            );
7976        }
7977
7978        #[tokio::test]
7979        async fn test_preserve_cache_on_parse_failure() {
7980            let state = Arc::new(ServerState::new());
7981            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7982
7983            // Valid initial document
7984            let content1 = r#"[dependencies]
7985serde = "1.0"
7986"#;
7987
7988            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7989            let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
7990            let doc_state1 = DocumentState::new_from_parse_result(
7991                EcosystemId::Cargo,
7992                content1.to_string(),
7993                parse_result1,
7994            );
7995            state.update_document(uri.clone(), doc_state1);
7996
7997            // Populate cache
7998            {
7999                let mut doc = state.documents.get_mut(&uri).unwrap();
8000                doc.cached_versions
8001                    .insert("serde".into(), PackageVersions::latest_only("1.0.210"));
8002            }
8003
8004            // Invalid TOML (parse will fail)
8005            let content2 = r#"[dependencies
8006serde = "1.0"
8007"#;
8008
8009            let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.ok();
8010            assert!(
8011                parse_result2.is_none(),
8012                "Parse should fail for invalid TOML"
8013            );
8014
8015            let mut doc_state2 =
8016                DocumentState::new_without_parse_result(EcosystemId::Cargo, content2.to_string());
8017
8018            if let Some(old_doc) = state.get_document(&uri) {
8019                preserve_cache(&mut doc_state2, &old_doc);
8020            }
8021
8022            state.update_document(uri.clone(), doc_state2);
8023
8024            // Cache should be preserved despite parse failure
8025            let doc = state.get_document(&uri).unwrap();
8026            assert_eq!(
8027                doc.cached_versions.len(),
8028                1,
8029                "Cache should be preserved on parse failure"
8030            );
8031            assert_eq!(
8032                doc.cached_versions.get("serde").map(|v| v.latest.as_str()),
8033                Some("1.0.210")
8034            );
8035        }
8036
8037        fn versions(
8038            pairs: &[(&str, Option<&str>)],
8039        ) -> HashMap<PackageName, Vec<Option<VersionReq>>> {
8040            pairs
8041                .iter()
8042                .map(|(name, req)| (PackageName::new(*name), vec![req.map(VersionReq::new)]))
8043                .collect()
8044        }
8045
8046        #[test]
8047        fn test_dependency_diff_detects_additions() {
8048            let old = versions(&[("serde", Some("1.0")), ("tokio", Some("1.0"))]);
8049            let new = versions(&[
8050                ("serde", Some("1.0")),
8051                ("tokio", Some("1.0")),
8052                ("anyhow", Some("1.0")),
8053            ]);
8054
8055            let diff = DependencyDiff::compute(&old, &new);
8056
8057            assert_eq!(diff.added.len(), 1);
8058            assert!(diff.added.contains(&PackageName::new("anyhow")));
8059            assert!(diff.removed.is_empty());
8060            assert!(diff.needs_fetch());
8061            assert!(diff.needs_osv_rescan());
8062        }
8063
8064        #[test]
8065        fn test_dependency_diff_detects_removals() {
8066            let old = versions(&[
8067                ("serde", Some("1.0")),
8068                ("tokio", Some("1.0")),
8069                ("anyhow", Some("1.0")),
8070            ]);
8071            let new = versions(&[("serde", Some("1.0")), ("tokio", Some("1.0"))]);
8072
8073            let diff = DependencyDiff::compute(&old, &new);
8074
8075            assert!(diff.added.is_empty());
8076            assert_eq!(diff.removed.len(), 1);
8077            assert!(diff.removed.contains(&PackageName::new("anyhow")));
8078            assert!(!diff.needs_fetch());
8079            assert!(!diff.needs_osv_rescan());
8080        }
8081
8082        #[test]
8083        fn test_dependency_diff_no_changes() {
8084            let old = versions(&[("serde", Some("1.0")), ("tokio", Some("1.0"))]);
8085            let new = versions(&[("serde", Some("1.0")), ("tokio", Some("1.0"))]);
8086
8087            let diff = DependencyDiff::compute(&old, &new);
8088
8089            assert!(diff.added.is_empty());
8090            assert!(diff.removed.is_empty());
8091            assert!(diff.version_changed.is_empty());
8092            assert!(!diff.needs_fetch());
8093            assert!(!diff.needs_osv_rescan());
8094        }
8095
8096        #[test]
8097        fn test_dependency_diff_empty_to_new() {
8098            let old: HashMap<PackageName, Vec<Option<VersionReq>>> = HashMap::new();
8099            let new = versions(&[("serde", Some("1.0")), ("tokio", Some("1.0"))]);
8100
8101            let diff = DependencyDiff::compute(&old, &new);
8102
8103            assert_eq!(diff.added.len(), 2);
8104            assert!(diff.removed.is_empty());
8105            assert!(diff.needs_fetch());
8106        }
8107
8108        #[test]
8109        fn test_dependency_diff_detects_version_change_without_name_set_change() {
8110            // Regression guard for critique S1: editing only a dependency's
8111            // version must be detected even though the name set is unchanged.
8112            let old = versions(&[("time", Some("0.1.43"))]);
8113            let new = versions(&[("time", Some("0.1.44"))]);
8114
8115            let diff = DependencyDiff::compute(&old, &new);
8116
8117            assert!(diff.added.is_empty());
8118            assert!(diff.removed.is_empty());
8119            assert_eq!(diff.version_changed, vec![PackageName::new("time")]);
8120            assert!(
8121                diff.needs_fetch(),
8122                "a version-only edit must still trigger the registry fetch, \
8123                 so the yanked probe re-runs against the new version"
8124            );
8125            assert!(
8126                diff.needs_osv_rescan(),
8127                "a version-only edit must still trigger an OSV rescan"
8128            );
8129        }
8130
8131        #[tokio::test]
8132        async fn test_dependency_version_map_tracks_both_occurrences_of_duplicate_name() {
8133            // Regression guard for #394: `time` appears under both
8134            // `[dependencies]` and `[dev-dependencies]` with different
8135            // requirements. A name-keyed `HashMap<PackageName, Option<VersionReq>>`
8136            // would silently collapse this to one entry (whichever section's
8137            // entry iterates last); `dependency_version_map` must instead
8138            // keep one requirement per occurrence.
8139            let state = Arc::new(ServerState::new());
8140            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
8141            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
8142
8143            let content = r#"[dependencies]
8144time = "0.1.43"
8145
8146[dev-dependencies]
8147time = "0.1.44"
8148"#;
8149            let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
8150            assert_eq!(
8151                parse_result.dependencies().len(),
8152                2,
8153                "both `[dependencies]` and `[dev-dependencies]` occurrences of `time` must parse"
8154            );
8155
8156            let deps = dependency_version_map(parse_result.as_ref());
8157            let time_reqs = deps
8158                .get(&PackageName::new("time"))
8159                .expect("duplicated name must still be present in the map");
8160            assert_eq!(
8161                time_reqs,
8162                &vec![
8163                    Some(VersionReq::new("0.1.43")),
8164                    Some(VersionReq::new("0.1.44")),
8165                ],
8166                "both occurrences' version requirements must be tracked, not just the last one"
8167            );
8168        }
8169
8170        #[tokio::test]
8171        async fn test_dependency_diff_detects_edit_to_first_occurrence_of_duplicate_name() {
8172            // Regression guard for #394: editing the *first* (`[dependencies]`)
8173            // occurrence of a duplicated name, while the second
8174            // (`[dev-dependencies]`) occurrence stays unchanged, must still
8175            // produce a non-empty diff. Under the pre-fix name-only HashMap,
8176            // the unchanged second occurrence "won" the collapse in both the
8177            // old and new maps, so this edit was silently invisible to
8178            // `DependencyDiff` — the registry fetch and OSV rescan never ran.
8179            let state = Arc::new(ServerState::new());
8180            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
8181            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
8182
8183            let content1 = r#"[dependencies]
8184time = "0.1.43"
8185
8186[dev-dependencies]
8187time = "0.1.44"
8188"#;
8189            let old_deps = dependency_version_map(
8190                ecosystem
8191                    .parse_manifest(content1, &uri)
8192                    .await
8193                    .unwrap()
8194                    .as_ref(),
8195            );
8196
8197            let content2 = r#"[dependencies]
8198time = "0.1.50"
8199
8200[dev-dependencies]
8201time = "0.1.44"
8202"#;
8203            let new_deps = dependency_version_map(
8204                ecosystem
8205                    .parse_manifest(content2, &uri)
8206                    .await
8207                    .unwrap()
8208                    .as_ref(),
8209            );
8210
8211            let diff = DependencyDiff::compute(&old_deps, &new_deps);
8212            assert!(diff.added.is_empty());
8213            assert!(diff.removed.is_empty());
8214            assert_eq!(
8215                diff.version_changed,
8216                vec![PackageName::new("time")],
8217                "editing the losing (first) occurrence of a duplicated name must be detected"
8218            );
8219            assert!(diff.needs_fetch());
8220            assert!(diff.needs_osv_rescan());
8221        }
8222
8223        #[tokio::test]
8224        async fn test_dependency_diff_detects_edit_to_second_occurrence_of_duplicate_name() {
8225            // Mirrors the previous test in the opposite direction: editing
8226            // the *second* (`[dev-dependencies]`) occurrence, with the first
8227            // (`[dependencies]`) occurrence unchanged, must also be detected
8228            // — confirming the fix is not merely order-dependent.
8229            let state = Arc::new(ServerState::new());
8230            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
8231            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
8232
8233            let content1 = r#"[dependencies]
8234time = "0.1.43"
8235
8236[dev-dependencies]
8237time = "0.1.44"
8238"#;
8239            let old_deps = dependency_version_map(
8240                ecosystem
8241                    .parse_manifest(content1, &uri)
8242                    .await
8243                    .unwrap()
8244                    .as_ref(),
8245            );
8246
8247            let content2 = r#"[dependencies]
8248time = "0.1.43"
8249
8250[dev-dependencies]
8251time = "0.1.60"
8252"#;
8253            let new_deps = dependency_version_map(
8254                ecosystem
8255                    .parse_manifest(content2, &uri)
8256                    .await
8257                    .unwrap()
8258                    .as_ref(),
8259            );
8260
8261            let diff = DependencyDiff::compute(&old_deps, &new_deps);
8262            assert!(diff.added.is_empty());
8263            assert!(diff.removed.is_empty());
8264            assert_eq!(
8265                diff.version_changed,
8266                vec![PackageName::new("time")],
8267                "editing the winning (second) occurrence of a duplicated name must be detected"
8268            );
8269            assert!(diff.needs_fetch());
8270            assert!(diff.needs_osv_rescan());
8271        }
8272
8273        #[tokio::test]
8274        async fn test_dependency_diff_detects_edit_to_duplicate_name_across_target_blocks() {
8275            // #394's own headline reproduction: `time` declared under two
8276            // different `[target.'cfg(...)'.dependencies]` blocks (reachable
8277            // since #396's target-table parsing fix), pinned to different
8278            // versions. Also the only scenario that exercises the ordering
8279            // nuance noted on `dependency_version_map`'s doc: `deps-cargo`
8280            // walks a `BTreeMap`, so `cfg(unix)` (declared second, below)
8281            // sorts *before* `cfg(windows)` (declared first, above) in
8282            // `pr.dependencies()` — the fix must not depend on occurrences
8283            // appearing in source order to detect the edit correctly.
8284            let state = Arc::new(ServerState::new());
8285            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
8286            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
8287
8288            let content1 = r#"[target.'cfg(windows)'.dependencies]
8289time = "0.1.44"
8290
8291[target.'cfg(unix)'.dependencies]
8292time = "0.1.43"
8293"#;
8294            let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
8295            assert_eq!(
8296                parse_result1.dependencies().len(),
8297                2,
8298                "both target-block occurrences of `time` must parse"
8299            );
8300            let old_deps = dependency_version_map(parse_result1.as_ref());
8301            assert_eq!(
8302                old_deps.get(&PackageName::new("time")).map(Vec::len),
8303                Some(2),
8304                "duplicate-name occurrences under different target blocks must be tracked \
8305                 per-occurrence, not collapsed to one entry"
8306            );
8307
8308            // Edit only the `cfg(unix)` occurrence's version.
8309            let content2 = r#"[target.'cfg(windows)'.dependencies]
8310time = "0.1.44"
8311
8312[target.'cfg(unix)'.dependencies]
8313time = "0.1.50"
8314"#;
8315            let new_deps = dependency_version_map(
8316                ecosystem
8317                    .parse_manifest(content2, &uri)
8318                    .await
8319                    .unwrap()
8320                    .as_ref(),
8321            );
8322
8323            let diff = DependencyDiff::compute(&old_deps, &new_deps);
8324            assert!(diff.added.is_empty());
8325            assert!(diff.removed.is_empty());
8326            assert_eq!(
8327                diff.version_changed,
8328                vec![PackageName::new("time")],
8329                "editing one target-block occurrence of a duplicated name must still \
8330                 produce a non-empty diff, even though the other occurrence's \
8331                 requirement (\"0.1.44\") is unchanged"
8332            );
8333            assert!(diff.needs_fetch());
8334            assert!(diff.needs_osv_rescan());
8335        }
8336
8337        #[tokio::test]
8338        async fn test_cache_pruned_on_dependency_removal() {
8339            let state = Arc::new(ServerState::new());
8340            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
8341
8342            // Initial document with 3 dependencies
8343            let content1 = r#"[dependencies]
8344serde = "1.0"
8345tokio = "1.0"
8346anyhow = "1.0"
8347"#;
8348
8349            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
8350            let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
8351            let doc_state1 = DocumentState::new_from_parse_result(
8352                EcosystemId::Cargo,
8353                content1.to_string(),
8354                parse_result1,
8355            );
8356            state.update_document(uri.clone(), doc_state1);
8357
8358            // Populate cache for all 3 deps
8359            {
8360                let mut doc = state.documents.get_mut(&uri).unwrap();
8361                doc.cached_versions.insert(
8362                    PackageName::new("serde"),
8363                    PackageVersions::latest_only("1.0.210"),
8364                );
8365                doc.cached_versions.insert(
8366                    PackageName::new("tokio"),
8367                    PackageVersions::latest_only("1.40.0"),
8368                );
8369                doc.cached_versions.insert(
8370                    PackageName::new("anyhow"),
8371                    PackageVersions::latest_only("1.0.89"),
8372                );
8373            }
8374
8375            // Remove anyhow from manifest
8376            let content2 = r#"[dependencies]
8377serde = "1.0"
8378tokio = "1.0"
8379"#;
8380
8381            // Compute diff and apply cache pruning
8382            let old_deps: HashMap<PackageName, Vec<Option<VersionReq>>> =
8383                ["serde", "tokio", "anyhow"]
8384                    .iter()
8385                    .map(|s| (PackageName::new(*s), vec![None]))
8386                    .collect();
8387            let new_deps: HashMap<PackageName, Vec<Option<VersionReq>>> = ["serde", "tokio"]
8388                .iter()
8389                .map(|s| (PackageName::new(*s), vec![None]))
8390                .collect();
8391            let diff = DependencyDiff::compute(&old_deps, &new_deps);
8392
8393            let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.unwrap();
8394            let mut doc_state2 = DocumentState::new_from_parse_result(
8395                EcosystemId::Cargo,
8396                content2.to_string(),
8397                parse_result2,
8398            );
8399
8400            if let Some(old_doc) = state.get_document(&uri) {
8401                preserve_cache(&mut doc_state2, &old_doc);
8402            }
8403
8404            // Prune removed dependencies
8405            for removed_dep in &diff.removed {
8406                doc_state2.cached_versions.remove(removed_dep);
8407            }
8408
8409            state.update_document(uri.clone(), doc_state2);
8410
8411            // Verify cache was pruned
8412            let doc = state.get_document(&uri).unwrap();
8413            assert_eq!(
8414                doc.cached_versions.len(),
8415                2,
8416                "anyhow should be removed from cache"
8417            );
8418            assert!(doc.cached_versions.contains_key("serde"));
8419            assert!(doc.cached_versions.contains_key("tokio"));
8420            assert!(!doc.cached_versions.contains_key("anyhow"));
8421        }
8422    }
8423
8424    mod osv_scan_target_tests {
8425        use super::*;
8426        use deps_core::Dependency;
8427        use deps_core::lsp_helpers::{
8428            DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
8429            RequirementResolution, SourcePolicy,
8430        };
8431        use deps_core::parser::DependencySource;
8432        use std::any::Any;
8433        use tower_lsp_server::ls_types::{Position, Range};
8434
8435        struct MockFormatter;
8436        impl PackageNaming for MockFormatter {}
8437
8438        impl PackageRendering for MockFormatter {
8439            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
8440                version.to_string()
8441            }
8442
8443            fn package_url(&self, name: &PackageName) -> String {
8444                format!("https://example.com/{name}")
8445            }
8446        }
8447
8448        impl RequirementResolution for MockFormatter {}
8449
8450        impl DiagnosticMessages for MockFormatter {}
8451
8452        impl DiagnosticPolicy for MockFormatter {}
8453
8454        impl SourcePolicy for MockFormatter {}
8455
8456        impl OsvNaming for MockFormatter {}
8457
8458        struct MockDep {
8459            name: PackageName,
8460            version_req: Option<VersionReq>,
8461            source: DependencySource,
8462        }
8463
8464        impl Dependency for MockDep {
8465            fn name(&self) -> &PackageName {
8466                &self.name
8467            }
8468            fn name_range(&self) -> Range {
8469                // Distinct per instance (not a fixed constant): `vulnerability_keys`
8470                // (#394 S2) keys a `HashMap<Range, String>` by `name_range()`,
8471                // requiring it to uniquely identify each occurrence the way a
8472                // real parser's source-derived range always does. A hardcoded
8473                // range here would make every `MockDep` in a test collide on
8474                // one map entry.
8475                let addr = std::ptr::from_ref(self) as u32;
8476                Range::new(Position::new(0, addr), Position::new(0, addr + 1))
8477            }
8478            fn version_requirement(&self) -> Option<&VersionReq> {
8479                self.version_req.as_ref()
8480            }
8481            fn version_range(&self) -> Option<Range> {
8482                None
8483            }
8484            fn source(&self) -> DependencySource {
8485                self.source.clone()
8486            }
8487            fn as_any(&self) -> &dyn Any {
8488                self
8489            }
8490        }
8491
8492        struct MockParseResult {
8493            deps: Vec<MockDep>,
8494        }
8495
8496        impl deps_core::ParseResult for MockParseResult {
8497            fn dependencies(&self) -> Vec<&dyn Dependency> {
8498                self.deps.iter().map(|d| d as &dyn Dependency).collect()
8499            }
8500            fn workspace_root(&self) -> Option<&std::path::Path> {
8501                None
8502            }
8503            fn uri(&self) -> &Uri {
8504                static URI: std::sync::OnceLock<Uri> = std::sync::OnceLock::new();
8505                URI.get_or_init(|| deps_core::test_util::test_uri("/test/Cargo.toml"))
8506            }
8507            fn as_any(&self) -> &dyn Any {
8508                self
8509            }
8510        }
8511
8512        use deps_core::osv::{ScanOutcome, SkipReason};
8513
8514        // `is_concrete_version`/`concrete_pin_version` unit tests moved to
8515        // `deps-core`'s `lsp_helpers::in_use_version` module alongside the
8516        // functions themselves (#394).
8517
8518        #[test]
8519        fn build_scan_targets_step0_skips_non_registry_source_even_with_lockfile_version() {
8520            // A git/path/patched fork must never be flagged with a CVE for a
8521            // version it does not actually contain, even when its lockfile
8522            // entry carries a plausible-looking version (critique C2).
8523            let parse_result = MockParseResult {
8524                deps: vec![MockDep {
8525                    name: PackageName::new("time"),
8526                    version_req: Some(VersionReq::new("0.1.43")),
8527                    source: DependencySource::Git {
8528                        url: "https://github.com/example/time".to_string(),
8529                        rev: None,
8530                    },
8531                }],
8532            };
8533            let mut resolved = HashMap::new();
8534            resolved.insert(PackageName::new("time"), "0.1.43".into());
8535
8536            let (targets, skipped) =
8537                build_scan_targets(&parse_result, &resolved, &MockFormatter, EcosystemId::Cargo);
8538            assert!(targets.is_empty());
8539            assert_matches!(
8540                skipped.get("time"),
8541                Some(ScanOutcome::Skipped(SkipReason::NonRegistrySource))
8542            );
8543        }
8544
8545        #[test]
8546        fn build_scan_targets_step1_prefers_lockfile_resolved_version() {
8547            let parse_result = MockParseResult {
8548                deps: vec![MockDep {
8549                    name: PackageName::new("serde"),
8550                    version_req: Some(VersionReq::new("^1.0")),
8551                    source: DependencySource::Registry,
8552                }],
8553            };
8554            let mut resolved = HashMap::new();
8555            resolved.insert(PackageName::new("serde"), "1.0.195".into());
8556
8557            let (targets, skipped) =
8558                build_scan_targets(&parse_result, &resolved, &MockFormatter, EcosystemId::Cargo);
8559            assert_eq!(targets.len(), 1);
8560            assert_eq!(targets[0].version, "1.0.195");
8561            assert!(skipped.is_empty());
8562        }
8563
8564        /// Formatter stub mirroring `GoFormatter`'s override: every
8565        /// dependency's manifest requirement is itself the resolved version
8566        /// (#235's `manifest_requirement_is_resolved_version` unification).
8567        struct MockGoFormatter;
8568        impl PackageNaming for MockGoFormatter {}
8569
8570        impl PackageRendering for MockGoFormatter {
8571            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
8572                version.to_string()
8573            }
8574
8575            fn package_url(&self, name: &PackageName) -> String {
8576                format!("https://pkg.go.dev/{name}")
8577            }
8578        }
8579
8580        impl RequirementResolution for MockGoFormatter {
8581            fn manifest_requirement_is_resolved_version(&self, _dep: &dyn Dependency) -> bool {
8582                true
8583            }
8584        }
8585
8586        impl DiagnosticMessages for MockGoFormatter {}
8587
8588        impl DiagnosticPolicy for MockGoFormatter {}
8589
8590        impl SourcePolicy for MockGoFormatter {}
8591
8592        impl OsvNaming for MockGoFormatter {}
8593
8594        struct MockVPrefixFormatter;
8595        impl PackageNaming for MockVPrefixFormatter {}
8596
8597        impl PackageRendering for MockVPrefixFormatter {
8598            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
8599                version.to_string()
8600            }
8601
8602            fn package_url(&self, name: &PackageName) -> String {
8603                format!("https://example.com/{name}")
8604            }
8605        }
8606
8607        impl RequirementResolution for MockVPrefixFormatter {}
8608
8609        impl DiagnosticMessages for MockVPrefixFormatter {}
8610
8611        impl DiagnosticPolicy for MockVPrefixFormatter {}
8612
8613        impl SourcePolicy for MockVPrefixFormatter {}
8614
8615        impl OsvNaming for MockVPrefixFormatter {
8616            fn osv_version(&self, version: &str) -> String {
8617                version.strip_prefix('v').unwrap_or(version).to_string()
8618            }
8619        }
8620
8621        #[test]
8622        fn build_scan_targets_normalizes_version_via_formatter_osv_version_hook() {
8623            // Go module versions carry a mandatory "v" prefix that OSV's
8624            // SEMVER range matching forbids (#228) — build_scan_targets must
8625            // route the resolved version through the formatter hook rather
8626            // than sending the native spelling on the wire.
8627            let parse_result = MockParseResult {
8628                deps: vec![MockDep {
8629                    name: PackageName::new("github.com/gin-gonic/gin"),
8630                    version_req: Some(VersionReq::new("v1.9.0")),
8631                    source: DependencySource::Registry,
8632                }],
8633            };
8634            let mut resolved = HashMap::new();
8635            resolved.insert(
8636                PackageName::new("github.com/gin-gonic/gin"),
8637                "v1.9.0".into(),
8638            );
8639
8640            let (targets, skipped) = build_scan_targets(
8641                &parse_result,
8642                &resolved,
8643                &MockVPrefixFormatter,
8644                EcosystemId::Go,
8645            );
8646            assert_eq!(targets.len(), 1);
8647            assert_eq!(targets[0].version, "1.9.0");
8648            // display_version keeps the ecosystem-native "v" spelling (S1
8649            // regression guard) — only the wire-format `version` is stripped.
8650            assert_eq!(targets[0].display_version, "v1.9.0");
8651            assert!(skipped.is_empty());
8652        }
8653
8654        #[test]
8655        fn build_scan_targets_leaves_version_unaffected_for_default_identity_formatter() {
8656            // Regression guard: ecosystems that do not override osv_version
8657            // must keep sending the native spelling verbatim (no regression
8658            // from introducing the hook).
8659            let parse_result = MockParseResult {
8660                deps: vec![MockDep {
8661                    name: PackageName::new("serde"),
8662                    version_req: Some(VersionReq::new("^1.0")),
8663                    source: DependencySource::Registry,
8664                }],
8665            };
8666            let mut resolved = HashMap::new();
8667            resolved.insert(PackageName::new("serde"), "1.0.195".into());
8668
8669            let (targets, skipped) =
8670                build_scan_targets(&parse_result, &resolved, &MockFormatter, EcosystemId::Cargo);
8671            assert_eq!(targets.len(), 1);
8672            assert_eq!(targets[0].version, "1.0.195");
8673            assert_eq!(targets[0].display_version, "1.0.195");
8674            assert!(skipped.is_empty());
8675        }
8676
8677        #[test]
8678        fn build_scan_targets_go_ignores_stale_lockfile_version_uses_go_mod_requirement() {
8679            // go.sum is a checksum ledger that `go get`/`go build` only ever
8680            // append to — a stale, no-longer-selected higher version can
8681            // remain recorded there after a downgrade (only `go mod tidy`
8682            // prunes it), and since go.sum is written sorted ascending by
8683            // semver, that stale entry always sorts last and wins
8684            // last-occurrence-wins parsing. Unlike Cargo/npm, go.mod's
8685            // `require` line is already an exact pinned version, so for Go
8686            // the manifest itself — not the lockfile-derived
8687            // `resolved_versions` — must be authoritative for OSV scanning.
8688            let parse_result = MockParseResult {
8689                deps: vec![MockDep {
8690                    name: PackageName::new("github.com/pkg/errors"),
8691                    version_req: Some(VersionReq::new("v0.8.1")),
8692                    source: DependencySource::Registry,
8693                }],
8694            };
8695            let mut resolved = HashMap::new();
8696            // Stale entry: go.sum still records v0.9.1 from before a
8697            // downgrade back to v0.8.1 that only `go get` (not `go mod
8698            // tidy`) performed.
8699            resolved.insert(PackageName::new("github.com/pkg/errors"), "v0.9.1".into());
8700
8701            let (targets, skipped) =
8702                build_scan_targets(&parse_result, &resolved, &MockGoFormatter, EcosystemId::Go);
8703            assert_eq!(targets.len(), 1);
8704            assert_eq!(targets[0].version, "v0.8.1");
8705            assert_eq!(targets[0].display_version, "v0.8.1");
8706            assert!(skipped.is_empty());
8707        }
8708
8709        #[test]
8710        fn build_scan_targets_step2_uses_concrete_requirement_verbatim() {
8711            let parse_result = MockParseResult {
8712                deps: vec![MockDep {
8713                    name: PackageName::new("log4j-core"),
8714                    version_req: Some(VersionReq::new("2.14.1")),
8715                    source: DependencySource::Registry,
8716                }],
8717            };
8718            let resolved = HashMap::new();
8719
8720            let (targets, skipped) =
8721                build_scan_targets(&parse_result, &resolved, &MockFormatter, EcosystemId::Maven);
8722            assert_eq!(targets.len(), 1);
8723            assert_eq!(targets[0].version, "2.14.1");
8724            assert!(skipped.is_empty());
8725        }
8726
8727        #[test]
8728        fn build_scan_targets_step2_strips_pin_marker_for_operator_prefixed_requirements() {
8729            // impl-critic M2: the `concrete_pin_version` fix (originally
8730            // scoped to the PyPI `==` case) also strips Cargo's `=` and
8731            // NuGet's `[..]` exact-pin markers, since both callers share the
8732            // same helper — a strict improvement over the old verbatim
8733            // `"=1.2.3"`/`"[1.0.0]"` OSV scan targets, which would never
8734            // have matched a real advisory's affected-version range anyway.
8735            let cargo_result = MockParseResult {
8736                deps: vec![MockDep {
8737                    name: PackageName::new("time"),
8738                    version_req: Some(VersionReq::new("=1.2.3")),
8739                    source: DependencySource::Registry,
8740                }],
8741            };
8742            let (targets, skipped) = build_scan_targets(
8743                &cargo_result,
8744                &HashMap::new(),
8745                &MockFormatter,
8746                EcosystemId::Cargo,
8747            );
8748            assert_eq!(targets.len(), 1);
8749            assert_eq!(targets[0].version, "1.2.3");
8750            assert!(skipped.is_empty());
8751
8752            let nuget_result = MockParseResult {
8753                deps: vec![MockDep {
8754                    name: PackageName::new("Newtonsoft.Json"),
8755                    version_req: Some(VersionReq::new("[1.0.0]")),
8756                    source: DependencySource::Registry,
8757                }],
8758            };
8759            let (targets, skipped) = build_scan_targets(
8760                &nuget_result,
8761                &HashMap::new(),
8762                &MockFormatter,
8763                EcosystemId::NuGet,
8764            );
8765            assert_eq!(targets.len(), 1);
8766            assert_eq!(targets[0].version, "1.0.0");
8767            assert!(skipped.is_empty());
8768        }
8769
8770        #[test]
8771        fn build_scan_targets_step3_skips_caret_range_with_no_lockfile_entry() {
8772            let parse_result = MockParseResult {
8773                deps: vec![MockDep {
8774                    name: PackageName::new("serde"),
8775                    version_req: Some(VersionReq::new("^1.0")),
8776                    source: DependencySource::Registry,
8777                }],
8778            };
8779            let resolved = HashMap::new();
8780
8781            let (targets, skipped) =
8782                build_scan_targets(&parse_result, &resolved, &MockFormatter, EcosystemId::Cargo);
8783            assert!(targets.is_empty());
8784            assert_matches!(
8785                skipped.get("serde"),
8786                Some(ScanOutcome::Skipped(SkipReason::NoConcreteVersion))
8787            );
8788        }
8789
8790        #[test]
8791        fn build_scan_targets_step3_skips_wildcard_with_no_lockfile_entry() {
8792            let parse_result = MockParseResult {
8793                deps: vec![MockDep {
8794                    name: PackageName::new("serde"),
8795                    version_req: Some(VersionReq::new("*")),
8796                    source: DependencySource::Registry,
8797                }],
8798            };
8799            let resolved = HashMap::new();
8800
8801            let (targets, skipped) =
8802                build_scan_targets(&parse_result, &resolved, &MockFormatter, EcosystemId::Cargo);
8803            assert!(targets.is_empty());
8804            assert_matches!(
8805                skipped.get("serde"),
8806                Some(ScanOutcome::Skipped(SkipReason::NoConcreteVersion))
8807            );
8808        }
8809
8810        #[test]
8811        fn build_scan_targets_all_non_registry_sources_are_skipped() {
8812            let sources = vec![
8813                DependencySource::Path {
8814                    path: "../local".to_string(),
8815                },
8816                DependencySource::Url {
8817                    url: "https://example.com/pkg.tgz".to_string(),
8818                },
8819                DependencySource::Sdk {
8820                    sdk: "flutter".to_string(),
8821                },
8822                DependencySource::Workspace,
8823                DependencySource::CustomRegistry {
8824                    url: "https://private.example.com".to_string(),
8825                },
8826            ];
8827
8828            for source in sources {
8829                let parse_result = MockParseResult {
8830                    deps: vec![MockDep {
8831                        name: PackageName::new("pkg"),
8832                        version_req: Some(VersionReq::new("1.0.0")),
8833                        source: source.clone(),
8834                    }],
8835                };
8836                let mut resolved = HashMap::new();
8837                resolved.insert(PackageName::new("pkg"), "1.0.0".into());
8838
8839                let (targets, skipped) = build_scan_targets(
8840                    &parse_result,
8841                    &resolved,
8842                    &MockFormatter,
8843                    EcosystemId::Cargo,
8844                );
8845                assert!(targets.is_empty(), "{source:?} must be skipped (step 0)");
8846                assert_matches!(
8847                    skipped.get("pkg"),
8848                    Some(ScanOutcome::Skipped(SkipReason::NonRegistrySource))
8849                );
8850            }
8851        }
8852
8853        #[test]
8854        fn build_scan_targets_never_drops_a_dependency_silently() {
8855            // Critique C1: every dependency considered must end up in either
8856            // `targets` or `skipped` — never absent from both.
8857            let parse_result = MockParseResult {
8858                deps: vec![
8859                    MockDep {
8860                        name: PackageName::new("concrete"),
8861                        version_req: Some(VersionReq::new("2.14.1")),
8862                        source: DependencySource::Registry,
8863                    },
8864                    MockDep {
8865                        name: PackageName::new("range-only"),
8866                        version_req: Some(VersionReq::new("^1.0")),
8867                        source: DependencySource::Registry,
8868                    },
8869                    MockDep {
8870                        name: PackageName::new("git-dep"),
8871                        version_req: Some(VersionReq::new("1.0.0")),
8872                        source: DependencySource::Git {
8873                            url: "https://example.com/git-dep".to_string(),
8874                            rev: None,
8875                        },
8876                    },
8877                ],
8878            };
8879            let resolved = HashMap::new();
8880
8881            let (targets, skipped) =
8882                build_scan_targets(&parse_result, &resolved, &MockFormatter, EcosystemId::Maven);
8883
8884            assert_eq!(targets.len(), 1);
8885            assert_eq!(targets[0].key, "concrete");
8886            assert_eq!(skipped.len(), 2);
8887            assert_matches!(
8888                skipped.get("range-only"),
8889                Some(ScanOutcome::Skipped(SkipReason::NoConcreteVersion))
8890            );
8891            assert_matches!(
8892                skipped.get("git-dep"),
8893                Some(ScanOutcome::Skipped(SkipReason::NonRegistrySource))
8894            );
8895        }
8896
8897        // `collect_in_use_versions` (§4.6) reuses the same `in_use_version`
8898        // ladder as `build_scan_targets` above, plus its own step-0 filter —
8899        // these tests exercise that reuse directly.
8900
8901        #[test]
8902        fn collect_in_use_versions_prefers_lockfile_resolved_version() {
8903            let parse_result = MockParseResult {
8904                deps: vec![MockDep {
8905                    name: PackageName::new("serde"),
8906                    version_req: Some(VersionReq::new("^1.0")),
8907                    source: DependencySource::Registry,
8908                }],
8909            };
8910            let mut resolved = HashMap::new();
8911            resolved.insert(PackageName::new("serde"), "1.0.195".into());
8912
8913            let in_use = collect_in_use_versions(
8914                &parse_result,
8915                &resolved,
8916                &MockFormatter,
8917                EcosystemId::Cargo,
8918            );
8919            assert_eq!(
8920                in_use.get(&PackageName::new("serde")),
8921                Some(&vec!["1.0.195".to_string()])
8922            );
8923        }
8924
8925        #[test]
8926        fn collect_in_use_versions_concrete_pin_without_lockfile() {
8927            // Closes the former R4 gap: an exact pin with no lock file must
8928            // still produce an in-use version for the yanked probe.
8929            let parse_result = MockParseResult {
8930                deps: vec![MockDep {
8931                    name: PackageName::new("log4j-core"),
8932                    version_req: Some(VersionReq::new("2.14.1")),
8933                    source: DependencySource::Registry,
8934                }],
8935            };
8936            let resolved = HashMap::new();
8937
8938            let in_use = collect_in_use_versions(
8939                &parse_result,
8940                &resolved,
8941                &MockFormatter,
8942                EcosystemId::Maven,
8943            );
8944            assert_eq!(
8945                in_use.get(&PackageName::new("log4j-core")),
8946                Some(&vec!["2.14.1".to_string()])
8947            );
8948        }
8949
8950        #[test]
8951        fn collect_in_use_versions_strips_pep440_double_equals_pin_for_pypi() {
8952            // The scenario the plan's R4 closure claim actually targets:
8953            // a PyPI `requirements.txt`-style `==` exact pin with no lock
8954            // file. `in_use.get(..)` must be the bare `"4.9.0"` so it can
8955            // ever match a real registry version string during the probe.
8956            let parse_result = MockParseResult {
8957                deps: vec![MockDep {
8958                    name: PackageName::new("typing_extensions"),
8959                    version_req: Some(VersionReq::new("==4.9.0")),
8960                    source: DependencySource::Registry,
8961                }],
8962            };
8963            let resolved = HashMap::new();
8964
8965            let in_use = collect_in_use_versions(
8966                &parse_result,
8967                &resolved,
8968                &MockFormatter,
8969                EcosystemId::Pypi,
8970            );
8971            assert_eq!(
8972                in_use.get(&PackageName::new("typing_extensions")),
8973                Some(&vec!["4.9.0".to_string()]),
8974                "pep440 '==' comparator must be stripped, not carried into the in-use version"
8975            );
8976        }
8977
8978        #[test]
8979        fn collect_in_use_versions_skips_non_concrete_requirement_with_no_lockfile() {
8980            let parse_result = MockParseResult {
8981                deps: vec![MockDep {
8982                    name: PackageName::new("serde"),
8983                    version_req: Some(VersionReq::new("^1.0")),
8984                    source: DependencySource::Registry,
8985                }],
8986            };
8987            let resolved = HashMap::new();
8988
8989            let in_use = collect_in_use_versions(
8990                &parse_result,
8991                &resolved,
8992                &MockFormatter,
8993                EcosystemId::Cargo,
8994            );
8995            assert!(in_use.is_empty());
8996        }
8997
8998        #[test]
8999        fn collect_in_use_versions_excludes_non_registry_source_even_with_lockfile_version() {
9000            // Step 0 (§4.5): a patched git/path fork must never be flagged
9001            // for a registry version it does not contain.
9002            let parse_result = MockParseResult {
9003                deps: vec![MockDep {
9004                    name: PackageName::new("time"),
9005                    version_req: Some(VersionReq::new("0.1.43")),
9006                    source: DependencySource::Git {
9007                        url: "https://github.com/example/time".to_string(),
9008                        rev: None,
9009                    },
9010                }],
9011            };
9012            let mut resolved = HashMap::new();
9013            resolved.insert(PackageName::new("time"), "0.1.43".into());
9014
9015            let in_use = collect_in_use_versions(
9016                &parse_result,
9017                &resolved,
9018                &MockFormatter,
9019                EcosystemId::Cargo,
9020            );
9021            assert!(in_use.is_empty());
9022        }
9023
9024        #[test]
9025        fn collect_in_use_versions_tracks_all_occurrences_of_duplicate_name() {
9026            // Regression guard for #394: two occurrences of the same
9027            // dependency name (e.g. under different
9028            // `[target.*.dependencies]` blocks, or `[dependencies]` +
9029            // `[dev-dependencies]`) with different concrete pins and no lock
9030            // file must both surface an in-use version for the yanked probe
9031            // — a name-keyed `HashMap<PackageName, String>` would silently
9032            // drop all but the last occurrence's pin.
9033            let parse_result = MockParseResult {
9034                deps: vec![
9035                    MockDep {
9036                        name: PackageName::new("time"),
9037                        version_req: Some(VersionReq::new("=0.1.43")),
9038                        source: DependencySource::Registry,
9039                    },
9040                    MockDep {
9041                        name: PackageName::new("time"),
9042                        version_req: Some(VersionReq::new("=0.1.44")),
9043                        source: DependencySource::Registry,
9044                    },
9045                ],
9046            };
9047            let resolved = HashMap::new();
9048
9049            let in_use = collect_in_use_versions(
9050                &parse_result,
9051                &resolved,
9052                &MockFormatter,
9053                EcosystemId::Cargo,
9054            );
9055            assert_eq!(
9056                in_use.get(&PackageName::new("time")),
9057                Some(&vec!["0.1.43".to_string(), "0.1.44".to_string()]),
9058                "both occurrences' in-use versions must be tracked, not just the last one"
9059            );
9060        }
9061    }
9062
9063    /// #462: `resolve_fix_target`'s pure per-dependency decision logic (reuse / provably
9064    /// clean / needs a live check / skip), and `apply_live_fix_target_statuses`'s handling of
9065    /// a live-check result map that may be missing keys (timeout/outage).
9066    mod fix_target_verification_tests {
9067        use super::*;
9068        use deps_core::lsp_helpers::{
9069            DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
9070            RequirementResolution, SourcePolicy,
9071        };
9072        use deps_core::osv::{
9073            Advisory, Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity,
9074            VulnerabilityMap,
9075        };
9076        use std::sync::Arc;
9077
9078        struct IdentityFormatter;
9079        impl PackageNaming for IdentityFormatter {}
9080
9081        impl PackageRendering for IdentityFormatter {
9082            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
9083                version.to_string()
9084            }
9085
9086            fn package_url(&self, name: &PackageName) -> String {
9087                format!("https://example.com/{name}")
9088            }
9089        }
9090
9091        impl RequirementResolution for IdentityFormatter {}
9092
9093        impl DiagnosticMessages for IdentityFormatter {}
9094
9095        impl DiagnosticPolicy for IdentityFormatter {}
9096
9097        impl SourcePolicy for IdentityFormatter {}
9098
9099        impl OsvNaming for IdentityFormatter {}
9100
9101        fn advisory(id: &str, fixed_versions: &[&str]) -> Arc<Advisory> {
9102            Arc::new(Advisory {
9103                id: id.to_string(),
9104                modified: "2023-01-01T00:00:00Z".to_string(),
9105                summary: None,
9106                aliases: vec![],
9107                severity: VulnSeverity::High,
9108                cvss_vector: None,
9109                fixed_versions: fixed_versions.iter().map(ToString::to_string).collect(),
9110                url: String::new(),
9111            })
9112        }
9113
9114        fn dv(
9115            advisories: Vec<Arc<Advisory>>,
9116            upgrade_status: UpgradeStatus,
9117        ) -> DependencyVulnerabilities {
9118            let total = advisories.len();
9119            DependencyVulnerabilities {
9120                advisories: Capped::new(advisories, total),
9121                upgrade_status,
9122                fix_target_status: UpgradeStatus::NotChecked,
9123            }
9124        }
9125
9126        #[test]
9127        fn resolve_fix_target_skips_when_no_fix_is_recommended() {
9128            // No advisory has a known fix, so `recommended_fix()` returns `None`.
9129            let dv = dv(vec![advisory("A1", &[])], UpgradeStatus::NotChecked);
9130            let resolution = resolve_fix_target(
9131                &dv,
9132                "pkg",
9133                &HashMap::new(),
9134                &HashMap::new(),
9135                &IdentityFormatter,
9136            );
9137            assert_eq!(resolution, FixTargetResolution::Skip);
9138        }
9139
9140        #[test]
9141        fn resolve_fix_target_reuses_latest_when_f_equals_latest() {
9142            // Case (c): F (1.2.0, the only advisory's fix) coincides with the already-checked
9143            // "latest" candidate — reuse its result, no live check queued.
9144            let latest_status = UpgradeStatus::CandidateClean {
9145                version: "1.2.0".to_string(),
9146            };
9147            let dv = dv(vec![advisory("A1", &["1.2.0"])], latest_status.clone());
9148            let mut latest_native_by_key = HashMap::new();
9149            latest_native_by_key.insert("pkg".to_string(), "1.2.0".to_string());
9150
9151            let resolution = resolve_fix_target(
9152                &dv,
9153                "pkg",
9154                &latest_native_by_key,
9155                &HashMap::new(),
9156                &IdentityFormatter,
9157            );
9158            assert_eq!(resolution, FixTargetResolution::Resolved(latest_status));
9159        }
9160
9161        #[test]
9162        fn resolve_fix_target_always_needs_live_check_when_f_differs_from_latest() {
9163            // #462 critic C1: there is no data-derived shortcut. Even though every known
9164            // advisory's fix (1.2.0) is already at or below F, that is a tautology — F is
9165            // *computed from* these exact advisories, so this check would always pass at its
9166            // only call site and prove nothing about an advisory phase A never fetched at
9167            // all. F (1.2.0) differs from latest (3.0.0), so this must always queue a live
9168            // check, batched under the fix-target key suffix.
9169            let dv = dv(
9170                vec![advisory("A1", &["1.2.0"])],
9171                UpgradeStatus::CandidateClean {
9172                    version: "3.0.0".to_string(),
9173                },
9174            );
9175            let mut latest_native_by_key = HashMap::new();
9176            latest_native_by_key.insert("pkg".to_string(), "3.0.0".to_string());
9177            let mut osv_name_by_key = HashMap::new();
9178            osv_name_by_key.insert("pkg".to_string(), "pkg".to_string());
9179
9180            let resolution = resolve_fix_target(
9181                &dv,
9182                "pkg",
9183                &latest_native_by_key,
9184                &osv_name_by_key,
9185                &IdentityFormatter,
9186            );
9187            assert_eq!(
9188                resolution,
9189                FixTargetResolution::NeedsLiveCheck(deps_core::osv::ScanTarget {
9190                    key: format!("pkg{FIX_TARGET_KEY_SUFFIX}"),
9191                    osv_name: "pkg".to_string(),
9192                    version: "1.2.0".to_string(),
9193                    display_version: "1.2.0".to_string(),
9194                })
9195            );
9196        }
9197
9198        #[test]
9199        fn resolve_fix_target_skips_when_osv_name_is_unavailable() {
9200            // A live check is needed (F != latest) but no `osv_name` is on record for this
9201            // key — nothing to query, so this degrades to `Skip` rather than panicking or
9202            // building a `ScanTarget` with an empty name.
9203            let dv = dv(vec![advisory("A1", &["1.0.0"])], UpgradeStatus::NotChecked);
9204            let resolution = resolve_fix_target(
9205                &dv,
9206                "pkg",
9207                &HashMap::new(),
9208                &HashMap::new(),
9209                &IdentityFormatter,
9210            );
9211            assert_eq!(resolution, FixTargetResolution::Skip);
9212        }
9213
9214        #[test]
9215        fn resolve_fix_target_skips_when_f_is_not_a_safe_version_string() {
9216            // A malformed `fixed_versions` entry (as if it somehow reached this dependency's
9217            // `advisories` despite OSV's own wire-boundary validation) must never be queued
9218            // for a live check or treated as any kind of resolvable target — `is_safe_version_string`
9219            // rejects it before anything else runs.
9220            let dv = dv(
9221                vec![advisory("A1", &["1.2.0\", \"evil\": \"true"])],
9222                UpgradeStatus::NotChecked,
9223            );
9224            let resolution = resolve_fix_target(
9225                &dv,
9226                "pkg",
9227                &HashMap::new(),
9228                &HashMap::new(),
9229                &IdentityFormatter,
9230            );
9231            assert_eq!(resolution, FixTargetResolution::Skip);
9232        }
9233
9234        #[test]
9235        fn collect_fix_target_resolutions_batches_multiple_dependencies_needing_live_check_into_one_vec()
9236         {
9237            // #462 NFR-001: three vulnerable dependencies — "reused" (F == latest, resolved
9238            // without a call), "live-a" and "live-b" (F != latest, both need a live check) —
9239            // must collapse into exactly one `resolved` entry and one `live_check_candidates`
9240            // Vec of length 2, proving multiple dependencies needing verification are batched
9241            // into a single prospective `check_candidates` call rather than one per dependency.
9242            let mut vulnerabilities = VulnerabilityMap::new();
9243            vulnerabilities.insert(
9244                "reused".to_string(),
9245                ScanOutcome::Vulnerable(dv(
9246                    vec![advisory("A1", &["1.0.0"])],
9247                    UpgradeStatus::CandidateClean {
9248                        version: "1.0.0".to_string(),
9249                    },
9250                )),
9251            );
9252            vulnerabilities.insert(
9253                "live-a".to_string(),
9254                ScanOutcome::Vulnerable(dv(
9255                    vec![advisory("A2", &["1.2.0"])],
9256                    UpgradeStatus::NotChecked,
9257                )),
9258            );
9259            vulnerabilities.insert(
9260                "live-b".to_string(),
9261                ScanOutcome::Vulnerable(dv(
9262                    vec![advisory("A3", &["2.2.0"])],
9263                    UpgradeStatus::NotChecked,
9264                )),
9265            );
9266
9267            let vulnerable_keys = vec![
9268                "reused".to_string(),
9269                "live-a".to_string(),
9270                "live-b".to_string(),
9271            ];
9272            let mut latest_native_by_key = HashMap::new();
9273            latest_native_by_key.insert("reused".to_string(), "1.0.0".to_string());
9274            latest_native_by_key.insert("live-a".to_string(), "9.0.0".to_string());
9275            latest_native_by_key.insert("live-b".to_string(), "9.0.0".to_string());
9276            let mut osv_name_by_key = HashMap::new();
9277            osv_name_by_key.insert("reused".to_string(), "reused".to_string());
9278            osv_name_by_key.insert("live-a".to_string(), "live-a".to_string());
9279            osv_name_by_key.insert("live-b".to_string(), "live-b".to_string());
9280
9281            let (resolved, live_check_candidates) = collect_fix_target_resolutions(
9282                &vulnerabilities,
9283                &vulnerable_keys,
9284                &osv_name_by_key,
9285                &latest_native_by_key,
9286                &IdentityFormatter,
9287            );
9288
9289            assert_eq!(resolved.len(), 1, "{resolved:?}");
9290            assert_eq!(resolved[0].0, "reused");
9291
9292            assert_eq!(live_check_candidates.len(), 2, "{live_check_candidates:?}");
9293            let keys: std::collections::HashSet<&str> = live_check_candidates
9294                .iter()
9295                .map(|t| t.key.as_str())
9296                .collect();
9297            assert!(keys.contains(format!("live-a{FIX_TARGET_KEY_SUFFIX}").as_str()));
9298            assert!(keys.contains(format!("live-b{FIX_TARGET_KEY_SUFFIX}").as_str()));
9299        }
9300
9301        #[test]
9302        fn apply_live_fix_target_statuses_sets_only_matching_keys_leaving_others_untouched() {
9303            // Case (e): a live-check batch that timed out for one dependency simply omits
9304            // its key from `statuses` — that dependency's `fix_target_status` must stay
9305            // `NotChecked` afterward, with no panic, while a dependency whose result did
9306            // arrive gets it applied.
9307            let mut vulnerabilities = VulnerabilityMap::new();
9308            vulnerabilities.insert(
9309                "checked".to_string(),
9310                ScanOutcome::Vulnerable(dv(
9311                    vec![advisory("A1", &["1.0.0"])],
9312                    UpgradeStatus::NotChecked,
9313                )),
9314            );
9315            vulnerabilities.insert(
9316                "timed-out".to_string(),
9317                ScanOutcome::Vulnerable(dv(
9318                    vec![advisory("A2", &["1.0.0"])],
9319                    UpgradeStatus::NotChecked,
9320                )),
9321            );
9322
9323            let mut statuses = HashMap::new();
9324            statuses.insert(
9325                format!("checked{FIX_TARGET_KEY_SUFFIX}"),
9326                UpgradeStatus::CandidateClean {
9327                    version: "1.0.0".to_string(),
9328                },
9329            );
9330            // "timed-out" deliberately has no entry in `statuses`.
9331
9332            apply_live_fix_target_statuses(&mut vulnerabilities, statuses);
9333
9334            let ScanOutcome::Vulnerable(checked) = vulnerabilities.get("checked").unwrap() else {
9335                panic!("expected Vulnerable");
9336            };
9337            assert_eq!(
9338                checked.fix_target_status,
9339                UpgradeStatus::CandidateClean {
9340                    version: "1.0.0".to_string()
9341                }
9342            );
9343
9344            let ScanOutcome::Vulnerable(timed_out) = vulnerabilities.get("timed-out").unwrap()
9345            else {
9346                panic!("expected Vulnerable");
9347            };
9348            assert_eq!(timed_out.fix_target_status, UpgradeStatus::NotChecked);
9349        }
9350    }
9351
9352    mod yanked_check_tests {
9353        use super::*;
9354        use deps_core::{Metadata, Version};
9355        use std::any::Any;
9356        use std::sync::atomic::{AtomicUsize, Ordering};
9357
9358        #[derive(Debug, Clone)]
9359        struct MockYankVersion {
9360            version: ConcreteVersion,
9361            yanked: bool,
9362        }
9363
9364        impl Version for MockYankVersion {
9365            fn version_string(&self) -> &ConcreteVersion {
9366                &self.version
9367            }
9368            fn removal_status(&self) -> deps_core::RemovalStatus {
9369                deps_core::RemovalStatus::from_yanked(self.yanked)
9370            }
9371            fn as_any(&self) -> &dyn Any {
9372                self
9373            }
9374        }
9375
9376        /// Per-package outcome for the primary (and, under #206, only)
9377        /// `get_versions` fetch.
9378        enum FetchOutcome {
9379            Versions(Vec<(&'static str, bool)>),
9380            Error,
9381            Timeout,
9382        }
9383
9384        /// Configurable mock registry for exercising the yanked-check wiring
9385        /// in `fetch_latest_versions_parallel`. Under #206's single-fetch
9386        /// design, `get_versions` is both the source of "latest" (via
9387        /// `select_latest_matching`, mirrored here by picking the first
9388        /// non-yanked entry) and, in the same in-memory list, the source of
9389        /// the yanked check — there is no second registry call to mock.
9390        /// `latest_fallback` only feeds the `get_latest_matching` fallback
9391        /// path, exercised when `select_latest_matching` finds nothing (all
9392        /// yanked, or an empty list).
9393        struct MockRegistry {
9394            reports_yanked: bool,
9395            versions: HashMap<&'static str, FetchOutcome>,
9396            latest_fallback: HashMap<&'static str, (&'static str, bool)>,
9397            fetch_calls: Arc<AtomicUsize>,
9398        }
9399
9400        impl Registry for MockRegistry {
9401            fn get_versions<'a>(
9402                &'a self,
9403                name: &'a PackageName,
9404            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
9405            {
9406                self.fetch_calls.fetch_add(1, Ordering::Relaxed);
9407                let outcome = self.versions.get(name.as_str());
9408                Box::pin(async move {
9409                    match outcome {
9410                        Some(FetchOutcome::Versions(vs)) => Ok(vs
9411                            .iter()
9412                            .map(|(v, y)| {
9413                                Box::new(MockYankVersion {
9414                                    version: (*v).into(),
9415                                    yanked: *y,
9416                                }) as Box<dyn Version>
9417                            })
9418                            .collect()),
9419                        Some(FetchOutcome::Error) => Err(deps_core::error::DepsError::CacheError(
9420                            "mock fetch error".to_string(),
9421                        )),
9422                        Some(FetchOutcome::Timeout) => {
9423                            tokio::time::sleep(std::time::Duration::from_secs(10)).await;
9424                            Ok(vec![])
9425                        }
9426                        None => Ok(vec![]),
9427                    }
9428                })
9429            }
9430
9431            fn select_latest_matching(
9432                &self,
9433                versions: &[Box<dyn Version>],
9434                _req: &VersionReq,
9435            ) -> Option<usize> {
9436                versions
9437                    .iter()
9438                    .position(|v| !v.removal_status().blocks_resolution())
9439            }
9440
9441            fn get_latest_matching<'a>(
9442                &'a self,
9443                name: &'a PackageName,
9444                _req: &'a VersionReq,
9445            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
9446            {
9447                let outcome = self.latest_fallback.get(name.as_str()).copied();
9448                Box::pin(async move {
9449                    Ok(outcome.map(|(v, y)| {
9450                        Box::new(MockYankVersion {
9451                            version: v.into(),
9452                            yanked: y,
9453                        }) as Box<dyn Version>
9454                    }))
9455                })
9456            }
9457
9458            fn search<'a>(
9459                &'a self,
9460                _query: &'a str,
9461                _limit: usize,
9462            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
9463            {
9464                Box::pin(async move { Ok(vec![]) })
9465            }
9466
9467            fn reports_yanked(&self) -> bool {
9468                self.reports_yanked
9469            }
9470
9471            fn as_any(&self) -> &dyn Any {
9472                self
9473            }
9474        }
9475
9476        #[tokio::test]
9477        async fn reports_yanked_false_never_recorded() {
9478            // The fetched list carries a yanked in-use entry, but
9479            // `reports_yanked() == false` means `removal_status()` must never be
9480            // trusted, even though the data is already in hand for free.
9481            let fetch_calls = Arc::new(AtomicUsize::new(0));
9482            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9483                reports_yanked: false,
9484                versions: HashMap::from([(
9485                    "pkg",
9486                    FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", true)]),
9487                )]),
9488                latest_fallback: HashMap::new(),
9489                fetch_calls: Arc::clone(&fetch_calls),
9490            });
9491            let mut in_use = HashMap::new();
9492            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
9493
9494            let result = fetch_latest_versions_parallel(
9495                registry,
9496                vec![(PackageName::new("pkg"), DependencySource::Registry)],
9497                &in_use,
9498                None,
9499                deps_core::freshness::FreshnessSettings::default(),
9500                5,
9501                10,
9502                None,
9503            )
9504            .await;
9505
9506            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
9507            assert!(result.yanked_versions.is_empty());
9508        }
9509
9510        #[tokio::test]
9511        async fn in_use_equal_to_latest_not_yanked() {
9512            let fetch_calls = Arc::new(AtomicUsize::new(0));
9513            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9514                reports_yanked: true,
9515                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", false)]))]),
9516                latest_fallback: HashMap::new(),
9517                fetch_calls: Arc::clone(&fetch_calls),
9518            });
9519            let mut in_use = HashMap::new();
9520            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
9521
9522            let result = fetch_latest_versions_parallel(
9523                registry,
9524                vec![(PackageName::new("pkg"), DependencySource::Registry)],
9525                &in_use,
9526                None,
9527                deps_core::freshness::FreshnessSettings::default(),
9528                5,
9529                10,
9530                None,
9531            )
9532            .await;
9533
9534            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
9535            assert!(result.yanked_versions.is_empty());
9536        }
9537
9538        #[tokio::test]
9539        async fn no_known_in_use_version_skips_the_check() {
9540            let fetch_calls = Arc::new(AtomicUsize::new(0));
9541            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9542                reports_yanked: true,
9543                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("2.0.0", false)]))]),
9544                latest_fallback: HashMap::new(),
9545                fetch_calls: Arc::clone(&fetch_calls),
9546            });
9547
9548            let result = fetch_latest_versions_parallel(
9549                registry,
9550                vec![(PackageName::new("pkg"), DependencySource::Registry)],
9551                &HashMap::new(),
9552                None,
9553                deps_core::freshness::FreshnessSettings::default(),
9554                5,
9555                10,
9556                None,
9557            )
9558            .await;
9559
9560            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
9561            assert!(result.yanked_versions.is_empty());
9562        }
9563
9564        #[tokio::test]
9565        async fn in_use_differs_and_yanked_is_recorded() {
9566            // No second registry call under #206: the in-use check is a
9567            // search over the same `versions` list already fetched for
9568            // "latest" — `fetch_calls` stays at 1.
9569            let fetch_calls = Arc::new(AtomicUsize::new(0));
9570            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9571                reports_yanked: true,
9572                versions: HashMap::from([(
9573                    "pkg",
9574                    FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", true)]),
9575                )]),
9576                latest_fallback: HashMap::new(),
9577                fetch_calls: Arc::clone(&fetch_calls),
9578            });
9579            let mut in_use = HashMap::new();
9580            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
9581
9582            let result = fetch_latest_versions_parallel(
9583                registry,
9584                vec![(PackageName::new("pkg"), DependencySource::Registry)],
9585                &in_use,
9586                None,
9587                deps_core::freshness::FreshnessSettings::default(),
9588                5,
9589                10,
9590                None,
9591            )
9592            .await;
9593
9594            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
9595            assert_eq!(
9596                result.yanked_versions.get(&PackageName::new("pkg")),
9597                Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
9598            );
9599        }
9600
9601        #[tokio::test]
9602        async fn in_use_differs_and_not_yanked_is_not_recorded() {
9603            let fetch_calls = Arc::new(AtomicUsize::new(0));
9604            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9605                reports_yanked: true,
9606                versions: HashMap::from([(
9607                    "pkg",
9608                    FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", false)]),
9609                )]),
9610                latest_fallback: HashMap::new(),
9611                fetch_calls: Arc::clone(&fetch_calls),
9612            });
9613            let mut in_use = HashMap::new();
9614            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
9615
9616            let result = fetch_latest_versions_parallel(
9617                registry,
9618                vec![(PackageName::new("pkg"), DependencySource::Registry)],
9619                &in_use,
9620                None,
9621                deps_core::freshness::FreshnessSettings::default(),
9622                5,
9623                10,
9624                None,
9625            )
9626            .await;
9627
9628            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
9629            assert!(result.yanked_versions.is_empty());
9630        }
9631
9632        #[tokio::test]
9633        async fn every_version_yanked_still_checks_in_use() {
9634            // Critique M2: every version filtered out by the wildcard
9635            // requirement (here, all yanked) is the most severe case, not a
9636            // silent skip. `select_latest_matching` finds nothing, the
9637            // `get_latest_matching` fallback also finds nothing (no entry in
9638            // `latest_fallback`), so `result.versions` stays empty — but the
9639            // yanked check still runs against the originally fetched list.
9640            let fetch_calls = Arc::new(AtomicUsize::new(0));
9641            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9642                reports_yanked: true,
9643                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
9644                latest_fallback: HashMap::new(),
9645                fetch_calls: Arc::clone(&fetch_calls),
9646            });
9647            let mut in_use = HashMap::new();
9648            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
9649
9650            let result = fetch_latest_versions_parallel(
9651                registry,
9652                vec![(PackageName::new("pkg"), DependencySource::Registry)],
9653                &in_use,
9654                None,
9655                deps_core::freshness::FreshnessSettings::default(),
9656                5,
9657                10,
9658                None,
9659            )
9660            .await;
9661
9662            assert_eq!(
9663                result.yanked_versions.get(&PackageName::new("pkg")),
9664                Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
9665            );
9666            assert!(result.versions.is_empty());
9667        }
9668
9669        #[tokio::test]
9670        async fn latest_pick_needs_fallback_in_use_yanked_still_found() {
9671            // When the list-based pick fails (all yanked) and the
9672            // `get_latest_matching` fallback succeeds with a *different*,
9673            // non-yanked version, `result.versions` is populated from the
9674            // fallback — but the in-use yanked check still searches the
9675            // originally fetched list, not the fallback's single version.
9676            let fetch_calls = Arc::new(AtomicUsize::new(0));
9677            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9678                reports_yanked: true,
9679                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
9680                latest_fallback: HashMap::from([("pkg", ("2.0.0", false))]),
9681                fetch_calls: Arc::clone(&fetch_calls),
9682            });
9683            let mut in_use = HashMap::new();
9684            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
9685
9686            let result = fetch_latest_versions_parallel(
9687                registry,
9688                vec![(PackageName::new("pkg"), DependencySource::Registry)],
9689                &in_use,
9690                None,
9691                deps_core::freshness::FreshnessSettings::default(),
9692                5,
9693                10,
9694                None,
9695            )
9696            .await;
9697
9698            assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
9699            assert_eq!(
9700                result
9701                    .versions
9702                    .get(&PackageName::new("pkg"))
9703                    .map(|v| v.latest.as_str()),
9704                Some("2.0.0")
9705            );
9706            assert_eq!(
9707                result.yanked_versions.get(&PackageName::new("pkg")),
9708                Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
9709            );
9710        }
9711
9712        #[tokio::test]
9713        async fn in_use_checks_every_occurrence_of_a_duplicate_name() {
9714            // Regression guard for #394: a package can appear more than once
9715            // in a manifest under the same name (e.g. `[dependencies]` +
9716            // `[dev-dependencies]`), each pinned to a different in-use
9717            // version. Only one occurrence ("2.0.0") is yanked; the other
9718            // ("3.0.0", not fetched here, not yanked) must not shadow it.
9719            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9720                reports_yanked: true,
9721                versions: HashMap::from([(
9722                    "pkg",
9723                    FetchOutcome::Versions(vec![
9724                        ("3.0.0", false),
9725                        ("2.0.0", true),
9726                        ("1.0.0", false),
9727                    ]),
9728                )]),
9729                latest_fallback: HashMap::new(),
9730                fetch_calls: Arc::new(AtomicUsize::new(0)),
9731            });
9732            let mut in_use = HashMap::new();
9733            in_use.insert(
9734                PackageName::new("pkg"),
9735                vec!["1.0.0".to_string(), "2.0.0".to_string()],
9736            );
9737
9738            let result = fetch_latest_versions_parallel(
9739                registry,
9740                vec![(PackageName::new("pkg"), DependencySource::Registry)],
9741                &in_use,
9742                None,
9743                deps_core::freshness::FreshnessSettings::default(),
9744                5,
9745                10,
9746                None,
9747            )
9748            .await;
9749
9750            assert_eq!(
9751                result.yanked_versions.get(&PackageName::new("pkg")),
9752                Some(&(ConcreteVersion::new("2.0.0"), RemovalStatus::Yanked)),
9753                "the yanked occurrence must be found even though a name-keyed \
9754                 single-value map could have kept only the non-yanked \"1.0.0\" pin"
9755            );
9756        }
9757
9758        #[tokio::test]
9759        async fn latest_is_yanked_recorded_as_defense_in_depth() {
9760            // §4.7 row 1: a contract-violating registry (its wildcard
9761            // `get_latest_matching` fallback returns a yanked version) still
9762            // gets recorded, at zero extra cost. `select_latest_matching`
9763            // filters yanked entries by construction, so the list-based pick
9764            // finds nothing here and the fallback is what "lies".
9765            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9766                reports_yanked: true,
9767                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
9768                latest_fallback: HashMap::from([("pkg", ("1.0.0", true))]),
9769                fetch_calls: Arc::new(AtomicUsize::new(0)),
9770            });
9771
9772            let result = fetch_latest_versions_parallel(
9773                registry,
9774                vec![(PackageName::new("pkg"), DependencySource::Registry)],
9775                &HashMap::new(),
9776                None,
9777                deps_core::freshness::FreshnessSettings::default(),
9778                5,
9779                10,
9780                None,
9781            )
9782            .await;
9783
9784            assert_eq!(
9785                result.yanked_versions.get(&PackageName::new("pkg")),
9786                Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
9787            );
9788        }
9789
9790        #[tokio::test]
9791        async fn latest_is_yanked_not_recorded_when_reports_yanked_false() {
9792            // impl-critic M1: row 1 must respect the same `reports_yanked()`
9793            // gate as the in-memory in-use check. Harmless today only
9794            // because every opt-out registry also hardcodes `removal_status`
9795            // to `Available` — this guards against a follow-up (§8.2/§8.3)
9796            // making an opt-out registry's `removal_status()` real without
9797            // also flipping `reports_yanked()`, which would otherwise
9798            // silently reintroduce a #233-class bug through this exact row.
9799            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9800                reports_yanked: false,
9801                versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
9802                latest_fallback: HashMap::from([("pkg", ("1.0.0", true))]),
9803                fetch_calls: Arc::new(AtomicUsize::new(0)),
9804            });
9805
9806            let result = fetch_latest_versions_parallel(
9807                registry,
9808                vec![(PackageName::new("pkg"), DependencySource::Registry)],
9809                &HashMap::new(),
9810                None,
9811                deps_core::freshness::FreshnessSettings::default(),
9812                5,
9813                10,
9814                None,
9815            )
9816            .await;
9817
9818            assert!(
9819                result.yanked_versions.is_empty(),
9820                "a `reports_yanked() == false` registry's `removal_status()` must never be \
9821                 trusted, even on the zero-cost row-1 path"
9822            );
9823            assert!(
9824                result
9825                    .versions
9826                    .get(&PackageName::new("pkg"))
9827                    .expect("pkg was fetched")
9828                    .yanked
9829                    .is_empty(),
9830                "`PackageVersions::yanked` must stay empty for a `reports_yanked() == false` \
9831                 registry, even though the fetched version is itself flagged"
9832            );
9833        }
9834
9835        #[tokio::test]
9836        async fn primary_fetch_error_counts_as_failed_no_yanked_data() {
9837            // Under #206's single-fetch design there is no separate "probe"
9838            // that can fail independently of the primary fetch — a
9839            // `get_versions` failure loses both the "latest" and the yanked
9840            // data together, and is counted as a real fetch failure (unlike
9841            // the pre-#206 best-effort probe).
9842            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9843                reports_yanked: true,
9844                versions: HashMap::from([("pkg", FetchOutcome::Error)]),
9845                latest_fallback: HashMap::new(),
9846                fetch_calls: Arc::new(AtomicUsize::new(0)),
9847            });
9848            let mut in_use = HashMap::new();
9849            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
9850
9851            let result = fetch_latest_versions_parallel(
9852                registry,
9853                vec![(PackageName::new("pkg"), DependencySource::Registry)],
9854                &in_use,
9855                None,
9856                deps_core::freshness::FreshnessSettings::default(),
9857                5,
9858                10,
9859                None,
9860            )
9861            .await;
9862
9863            assert!(result.yanked_versions.is_empty());
9864            assert_eq!(result.failed_count, 1);
9865            assert!(result.versions.is_empty());
9866        }
9867
9868        #[tokio::test]
9869        async fn primary_fetch_timeout_counts_as_failed_no_yanked_data() {
9870            // Same reasoning as the error case above, for the timeout path.
9871            let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9872                reports_yanked: true,
9873                versions: HashMap::from([("pkg", FetchOutcome::Timeout)]),
9874                latest_fallback: HashMap::new(),
9875                fetch_calls: Arc::new(AtomicUsize::new(0)),
9876            });
9877            let mut in_use = HashMap::new();
9878            in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
9879
9880            // 1 second timeout for test speed.
9881            let result = fetch_latest_versions_parallel(
9882                registry,
9883                vec![(PackageName::new("pkg"), DependencySource::Registry)],
9884                &in_use,
9885                None,
9886                deps_core::freshness::FreshnessSettings::default(),
9887                1,
9888                10,
9889                None,
9890            )
9891            .await;
9892
9893            assert!(result.yanked_versions.is_empty());
9894            assert_eq!(result.failed_count, 1);
9895            assert!(result.versions.is_empty());
9896        }
9897    }
9898
9899    /// #205: the `fetch_latest_versions_parallel` wiring that derives `FetchResult::deprecations`
9900    /// from the `resolved`/"latest" pick, self-contained rather than extending
9901    /// `yanked_check_tests`'s shared `MockYankVersion`/`FetchOutcome` (whose tuple shape has
9902    /// no room for a per-version `Deprecation` payload without touching its many existing
9903    /// call sites).
9904    mod deprecation_derivation_tests {
9905        use super::*;
9906        use deps_core::{Metadata, Version};
9907        use std::any::Any;
9908
9909        struct MockDeprecatedVersion {
9910            version: ConcreteVersion,
9911            deprecation: Option<Deprecation>,
9912        }
9913
9914        impl Version for MockDeprecatedVersion {
9915            fn version_string(&self) -> &ConcreteVersion {
9916                &self.version
9917            }
9918            fn removal_status(&self) -> RemovalStatus {
9919                RemovalStatus::from_advisory(self.deprecation.is_some())
9920            }
9921            fn deprecation(&self) -> Option<&Deprecation> {
9922                self.deprecation.as_ref()
9923            }
9924            fn as_any(&self) -> &dyn Any {
9925                self
9926            }
9927        }
9928
9929        /// Always resolves to its single configured version.
9930        struct SingleVersionRegistry {
9931            deprecation: Option<Deprecation>,
9932        }
9933
9934        impl Registry for SingleVersionRegistry {
9935            fn get_versions<'a>(
9936                &'a self,
9937                _name: &'a PackageName,
9938            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
9939            {
9940                let deprecation = self.deprecation.clone();
9941                Box::pin(async move {
9942                    Ok(vec![Box::new(MockDeprecatedVersion {
9943                        version: "1.0.0".into(),
9944                        deprecation,
9945                    }) as Box<dyn Version>])
9946                })
9947            }
9948
9949            fn select_latest_matching(
9950                &self,
9951                versions: &[Box<dyn Version>],
9952                _req: &VersionReq,
9953            ) -> Option<usize> {
9954                (!versions.is_empty()).then_some(0)
9955            }
9956
9957            fn get_latest_matching<'a>(
9958                &'a self,
9959                _name: &'a PackageName,
9960                _req: &'a VersionReq,
9961            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
9962            {
9963                Box::pin(async move { Ok(None) })
9964            }
9965
9966            fn search<'a>(
9967                &'a self,
9968                _query: &'a str,
9969                _limit: usize,
9970            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
9971            {
9972                Box::pin(async move { Ok(vec![]) })
9973            }
9974
9975            fn as_any(&self) -> &dyn Any {
9976                self
9977            }
9978        }
9979
9980        #[tokio::test]
9981        async fn fetch_result_carries_deprecation_from_resolved_pick() {
9982            let registry: Arc<dyn Registry> = Arc::new(SingleVersionRegistry {
9983                deprecation: Some(Deprecation {
9984                    reason: Some("archived".to_string()),
9985                    replacement: Some("other/pkg".to_string()),
9986                }),
9987            });
9988
9989            let result = fetch_latest_versions_parallel(
9990                registry,
9991                vec![(PackageName::new("pkg"), DependencySource::Registry)],
9992                &HashMap::new(),
9993                None,
9994                deps_core::freshness::FreshnessSettings::default(),
9995                5,
9996                10,
9997                None,
9998            )
9999            .await;
10000
10001            assert_eq!(
10002                result.deprecations.get(&PackageName::new("pkg")),
10003                Some(&Deprecation {
10004                    reason: Some("archived".to_string()),
10005                    replacement: Some("other/pkg".to_string()),
10006                })
10007            );
10008        }
10009
10010        #[tokio::test]
10011        async fn fetch_result_has_no_deprecation_when_resolved_pick_is_clean() {
10012            let registry: Arc<dyn Registry> = Arc::new(SingleVersionRegistry { deprecation: None });
10013
10014            let result = fetch_latest_versions_parallel(
10015                registry,
10016                vec![(PackageName::new("pkg"), DependencySource::Registry)],
10017                &HashMap::new(),
10018                None,
10019                deps_core::freshness::FreshnessSettings::default(),
10020                5,
10021                10,
10022                None,
10023            )
10024            .await;
10025
10026            assert!(result.deprecations.is_empty());
10027        }
10028    }
10029}