Skip to main content

deps_core/lsp_helpers/
hover.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use tower_lsp_server::ls_types::{Hover, HoverContents, MarkupContent, MarkupKind, Position};
5
6use crate::deps_dev::deps_dev_system;
7use crate::osv::ScanOutcome;
8use crate::{
9    ConcreteVersion, Dependency, DependencySource, Deprecation, ParseResult, ProvenanceStatus,
10    PublishTime, Registry, SupplyChainTrustSignal, Version, VersionReq, format_relative_age,
11    is_within_cooldown,
12};
13
14use super::{
15    EcosystemFormatter, HOVER_RECENT_VERSIONS, VersionData, escape_markdown, in_use_version,
16    markdown_code_span, position_in_range,
17};
18
19/// Bounds how long [`generate_hover`] *waits* for the spawned deps.dev trust-signal
20/// fetch — never the fetch itself, which keeps running to completion and warms
21/// [`crate::deps_dev::DepsDevClient`]'s memo even after this deadline elapses
22/// (spec 037, plan.md §8's "spawn-and-warm" design). Deliberately not named
23/// `..._TOTAL_...`: merging this with the fetch's own per-call timeouts would
24/// silently kill spawn-and-warm — an over-budget fetch would then die entirely
25/// instead of finishing into the memo, and the next hover would re-fire it under
26/// the short error TTL rather than getting a memo hit.
27const DEPS_DEV_WAIT_BUDGET: Duration = Duration::from_millis(700);
28
29/// Formats the relative-age suffix for one "Recent versions" hover entry.
30///
31/// Returns an empty string when the registry doesn't expose a publish timestamp for
32/// `version` (`published_at()` is `None`), so the entry renders exactly as it did
33/// before this feature existed (graceful degradation, US-003).
34///
35/// `now` is taken as an explicit parameter rather than read internally so every entry
36/// in the same "Recent versions" list is aged against one consistent instant.
37fn version_age_suffix(version: &dyn Version, now: PublishTime) -> String {
38    version
39        .published_at()
40        .map(|published| format!(" — {}", format_relative_age(published.age_secs_from(now))))
41        .unwrap_or_default()
42}
43
44/// Bounds the `Registry::get_latest_matching` fallback (#373) hover fires when the
45/// list-based `**Latest**` pick fails on a non-empty live list. Hover responses must
46/// return quickly (`.claude/rules/rust-code.md`), and without this the fallback would
47/// stack on top of `get_versions_with`'s own up-to-30s `reqwest` client timeout
48/// (`HttpCache`), doubling worst-case hover latency to ~60s. `generate_hover` has no
49/// `timeout_secs` config threaded in the way `lifecycle.rs`'s background fetch does, so
50/// this is a fixed local bound rather than a configurable one — a few seconds is enough
51/// slack for the already-rare "list-based pick failed" path without meaningfully
52/// delaying the common case, which never reaches this fallback at all.
53const HOVER_FALLBACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
54
55/// The `Cmd+.` update-footer markdown this function appends when a code action may exist
56/// for the hovered dependency.
57///
58/// `pub` (re-exported from `lsp_helpers`) so an ecosystem's own `generate_hover` override
59/// can restore it post-hoc for an action source this shared gate has no visibility into
60/// (e.g. GHA's `TagIndex`-driven SHA-pin quickfix, #501) without hand-copying the literal
61/// and risking drift between the two.
62pub const CMD_DOT_FOOTER: &str = "\n---\n⌨️ **Press `Cmd+.` to update version**";
63
64pub async fn generate_hover<R: Registry + ?Sized>(
65    parse_result: &dyn ParseResult,
66    position: Position,
67    versions: VersionData<'_>,
68    registry: &R,
69    formatter: &dyn EcosystemFormatter,
70    freshness: crate::freshness::FreshnessSettings,
71    now: PublishTime,
72) -> Option<Hover> {
73    let dep = parse_result.dependencies().into_iter().find(|d| {
74        let on_name = position_in_range(position, d.name_range());
75        let on_version = d
76            .version_range()
77            .is_some_and(|r| position_in_range(position, r));
78        on_name || on_version
79    })?;
80
81    // A non-resolvable source (e.g. `CustomRegistry`, Git, Path) doesn't resolve
82    // against `registry` at all — fetching by name here would silently check an
83    // unrelated or coincidentally-named public-registry package (#248), so hover
84    // must skip the registry lookup and every section built from it entirely.
85    // `can_resolve_source` (not the bare `DependencySource::is_version_resolvable`)
86    // so an ecosystem whose registry routes more sources than the generic default
87    // (e.g. `deps-cargo`'s resolved `AlternateRegistry`) gets hover support for
88    // them without a new gate here.
89    let dep_source = dep.source();
90    let resolvable = formatter.can_resolve_source(&dep_source);
91
92    // Hoisted above the registry fetch below (moved from its previous position
93    // right before the `**Current**`/`**Requirement**` line) so the deps.dev gate
94    // just below can use it: both need "the normalized name", and the gate must be
95    // built and the fetch spawned *before* awaiting the registry fetch so the two
96    // requests overlap instead of stacking (spec 037, plan.md §8 M6).
97    let normalized_name = formatter.normalize_package_name(dep.name());
98
99    // Spawned concurrently with the registry fetch just below, so a slow or
100    // cold-memo deps.dev never adds its own latency on top of the registry fetch's.
101    let trust_handle = spawn_trust_signal_fetch(
102        dep,
103        &dep_source,
104        &versions,
105        formatter,
106        normalized_name.as_str(),
107    );
108
109    // `now` is a caller-supplied parameter (issue #227 M4) rather than computed
110    // internally via `PublishTime::now()` — this is what lets tests pin an exact
111    // cooldown-boundary instant deterministically, and guarantees every age rendered
112    // in this single hover response (the `**Latest**` line and the "Recent versions"
113    // list below) is aged against the same instant.
114    //
115    // `.ok()`, not `.ok()?`: a fetch failure here (off-VPN, an expired token, a
116    // DNS-blocked internal host — routine for a self-hosted registry's normal users)
117    // must degrade to the same basic name/requirement/features card the `!resolvable`
118    // branch below renders, not vanish the entire hover response. Propagating `None`
119    // out of the whole function on any transient fetch error was a real regression
120    // once a resolvable source could be a private index rather than always crates.io.
121    let available_versions = if resolvable {
122        registry
123            .get_versions_from(dep.name(), &dep_source, freshness)
124            .await
125            .ok()
126    } else {
127        None
128    };
129
130    // FR-014: a resolved-but-not-crates.io source (e.g. Cargo's `AlternateRegistry`) must
131    // not carry a link to the ecosystem's *default* registry — once live version data from
132    // the real registry renders below, an unrelated link reads as confirmation it's real.
133    //
134    // `.filter(|u| !u.is_empty())`: an `EcosystemFormatter::package_url` implementation can
135    // return an empty string for a dependency name it can't turn into a real URL (e.g. a
136    // name that isn't a valid identity for that ecosystem's registry) without also
137    // overriding `suppress_package_url` — defense-in-depth so an empty URL can never render
138    // as a dead `[name]()` markdown link regardless of which ecosystem forgot the override
139    // (#474).
140    let url = (!formatter.suppress_package_url(&dep_source))
141        .then(|| formatter.package_url(dep.name()))
142        .filter(|u| !u.is_empty());
143
144    // Pre-allocate with estimated capacity to reduce allocations
145    let mut markdown = String::with_capacity(512);
146    push_header_hover_section(&mut markdown, dep, url.as_deref());
147
148    let resolved: Option<&str> = if formatter.manifest_requirement_is_resolved_version(dep) {
149        dep.version_requirement().map(VersionReq::as_str)
150    } else {
151        versions
152            .resolved
153            .get(normalized_name.as_str())
154            .or_else(|| versions.resolved.get(dep.name()))
155            .map(ConcreteVersion::as_str)
156    };
157    push_current_or_requirement_hover_section(&mut markdown, dep, resolved);
158
159    push_markers_hover_section(&mut markdown, dep);
160
161    // The `**Latest**` line prefers the just-fetched Ch2 list (`available_versions`) over
162    // the Ch1 cache (`versions.cached`, populated by the lifecycle's background fetch)
163    // whenever a live fetch is available. Ch1 alone would let this line render a version
164    // older than the one the "Recent versions" list right below it shows as `*(latest)*` —
165    // a self-contradictory response when a new version is published between the last
166    // background fetch and this hover call, with the cooldown callout then decided off the
167    // stale operand too (issue #227 F5). Falls back to Ch1 only when there is no live list
168    // at all (non-resolvable source) — NOT merely when the live list has no stable entry:
169    // a live list that's all pre-release still means a live fetch happened, and rendering
170    // a stale Ch1 version that may not even appear in the live "Recent versions" list below
171    // would be exactly the self-contradiction #227 F5 fixed, via a different path (#313).
172    //
173    // Exception (#373): when the list-based pick fails on a non-empty list, `latest_line`
174    // can render a version sourced from `list_fallback_latest` — `Registry::get_latest_matching`
175    // instead of the list — which the "Recent versions" list below is built from `available_versions`
176    // alone and so may not contain (Go's `/@latest` can answer with a pseudo-version `/@v/list`
177    // never enumerates). No entry is marked `*(latest)*` in that case, since `live_latest_idx`
178    // is `None`. This is an accepted, narrower trade-off than the contradiction #227 F5/#313
179    // guard against: rendering *a* correct latest version, even one absent from or unmarked in
180    // the list below, beats rendering no `**Latest**` line at all.
181    //
182    // `live_latest_idx` (not raw index 0) picks the entry: `available_versions` is sorted
183    // purely by version number, so a pre-release with the highest number can sort to index 0
184    // even though it isn't the ecosystem's "latest stable" pick — mirroring this line to the
185    // raw top entry would tag a pre-release as `(latest)` below. The pick is delegated to
186    // `Registry::select_latest_matching` — the exact same call `lifecycle.rs`'s background
187    // fetch uses to populate `versions.cached`'s `latest` and every cache-backed diagnostic —
188    // rather than re-derived here with a generic `is_stable()` scan. The two must never
189    // disagree about what "latest" is: an ecosystem whose `select_latest_matching` applies a
190    // ranking preference beyond plain resolvability (e.g. npm's #338 NFR-002, which prefers a
191    // non-deprecated version over a newer deprecated one) gets that same preference reflected
192    // in this hover response instead of hover independently picking a different version and
193    // silently dropping that version's `*(deprecated)*`/`*(yanked)*` label because it thinks
194    // it's `(latest)` (#347/#348 S1). Recorded as an index rather than a version string so the
195    // "Recent versions" marker below can match by position instead of string equality, which
196    // could spuriously tag more than one entry if two ever shared a version string.
197    let wildcard_req = VersionReq::new("*");
198    let live_latest_idx = available_versions
199        .as_ref()
200        .and_then(|v| registry.select_latest_matching(v, &wildcard_req));
201    // #373: `live_latest_idx` can be `None` even though a live fetch DID happen and the
202    // list is non-empty — e.g. Go's `/@v/list` never enumerates pseudo-versions, so an
203    // untagged module whose whole tagged history is pre-release fails the list-based pick
204    // entirely. This must not fall straight to the Ch1 cache (see the comment on
205    // `latest_line` below) — instead mirror the exact fallback `lifecycle.rs`'s background
206    // fetch already uses for this same case: a second call to `Registry::get_latest_matching`,
207    // which some registries (Go's `/@latest`) answer from a source more complete than the
208    // list endpoint. Only attempted for a non-empty live list with no list-based pick; an
209    // empty or absent live list keeps falling back to Ch1 untouched. Bounded by
210    // `HOVER_FALLBACK_TIMEOUT` and logged like `lifecycle.rs`'s own fallback — a failure,
211    // timeout, or `None` here degrades gracefully to no `**Latest**` line, same as today:
212    // `available_versions` already succeeded, so this fallback's own error must not abort
213    // the rest of the hover.
214    let list_fallback_latest = if available_versions.as_ref().is_some_and(|v| !v.is_empty())
215        && live_latest_idx.is_none()
216    {
217        match tokio::time::timeout(
218            HOVER_FALLBACK_TIMEOUT,
219            registry.get_latest_matching_from(dep.name(), &dep_source, &wildcard_req, None),
220        )
221        .await
222        {
223            Ok(Ok(found)) => {
224                tracing::debug!(package = %dep.name(), found = found.is_some(), "hover latest fallback (get_latest_matching) resolved");
225                found
226            }
227            Ok(Err(error)) => {
228                tracing::warn!(package = %dep.name(), %error, "hover latest fallback (get_latest_matching) failed");
229                None
230            }
231            Err(_) => {
232                tracing::warn!(
233                    package = %dep.name(),
234                    timeout_secs = HOVER_FALLBACK_TIMEOUT.as_secs(),
235                    "hover latest fallback (get_latest_matching) timed out"
236                );
237                None
238            }
239        }
240    } else {
241        None
242    };
243    let cached_latest = resolvable
244        .then(|| {
245            versions
246                .cached
247                .get(normalized_name.as_str())
248                .or_else(|| versions.cached.get(dep.name()))
249        })
250        .flatten();
251    // A non-empty live list with no stable entry is deliberately treated differently from
252    // an empty (or absent) live list: the former tries `list_fallback_latest` (#373) first —
253    // a second registry call for the rare "list-based pick failed but a live fetch happened"
254    // case — before giving up, rather than falling back to the Ch1 cache, since the cache's
255    // version wouldn't be part of what the live list just showed. Only once that fallback
256    // also yields nothing does the line render nothing at all (no header, matching the empty
257    // "Recent versions" list right beneath it). An empty live list carries no such
258    // contradiction risk — it has nothing to contradict — so it keeps falling back to Ch1,
259    // same as when there's no live fetch.
260    let latest_line: Option<(&str, Option<PublishTime>)> = match &available_versions {
261        Some(v) if !v.is_empty() => live_latest_idx
262            .map(|idx| &v[idx])
263            .map(|live| (live.version_string().as_str(), live.published_at()))
264            .or_else(|| {
265                list_fallback_latest
266                    .as_deref()
267                    .map(|live| (live.version_string().as_str(), live.published_at()))
268            }),
269        _ => cached_latest.map(|v| (v.latest.as_str(), v.published_at)),
270    };
271    push_latest_hover_section(&mut markdown, latest_line, freshness, now);
272
273    // #394 S2: prefer the version-qualified key so a hover on one occurrence
274    // of a duplicated name never shows another occurrence's OSV result. See
275    // `crate::osv::vulnerability_keys` for when qualification kicks in.
276    let vuln_key = versions.ecosystem.and_then(|ecosystem| {
277        crate::osv::vulnerability_keys(parse_result, versions.resolved, formatter, ecosystem)
278            .remove(&dep.name_range())
279    });
280    let vuln_outcome = versions.vulnerabilities.and_then(|m| {
281        vuln_key
282            .as_deref()
283            .and_then(|key| m.get(key))
284            .or_else(|| m.get(&normalized_name))
285            .or_else(|| m.get(dep.name().as_str()))
286    });
287    let deprecation = versions
288        .outcomes
289        .and_then(|o| o.deprecation(&normalized_name));
290    // Package-level context (#205) renders before per-version security advisories:
291    // deprecation is a property of the package, advisories of the version.
292    push_deprecation_hover_section(&mut markdown, formatter, deprecation);
293    push_vulnerability_hover_section(&mut markdown, vuln_outcome);
294
295    // Awaited last, after every other section above that needed no network I/O of
296    // its own, so the wait below overlaps as much of this function's own work as
297    // possible — by now `available_versions` has already resolved too. Bounds only
298    // the *wait*: over budget, the spawned task above keeps running and warms
299    // `DepsDevClient`'s memo regardless (see `DEPS_DEV_WAIT_BUDGET`'s docs). A
300    // `JoinHandle` `Err` (the task panicked) is swallowed exactly like a timeout or a
301    // fetch failure — FR-006 must hold on this path too, not just the network ones.
302    let trust_signal = match trust_handle {
303        Some(handle) => match tokio::time::timeout(DEPS_DEV_WAIT_BUDGET, handle).await {
304            Ok(Ok(signal)) => signal,
305            Ok(Err(_)) | Err(_) => None,
306        },
307        None => None,
308    };
309    push_trust_signal_hover_section(&mut markdown, trust_signal.as_ref());
310
311    // `!v.is_empty()`, not just `Some(_)` (#550): a resolvable source's live fetch can
312    // succeed with a genuinely empty list — e.g. a real GitHub repository whose only
313    // tags don't parse as full semver (`dtolnay/rust-toolchain`'s sole tag `v1`) — and
314    // a "**Recent versions**:" header with no entries under it is never useful,
315    // regardless of ecosystem.
316    if let Some(available_versions) = available_versions.as_ref().filter(|v| !v.is_empty()) {
317        push_recent_versions_hover_section(
318            &mut markdown,
319            available_versions,
320            live_latest_idx,
321            freshness,
322            now,
323            formatter,
324        );
325    }
326
327    push_cmd_dot_footer_hover_section(
328        &mut markdown,
329        resolvable,
330        available_versions.as_deref(),
331        cached_latest,
332        vuln_outcome,
333        deprecation,
334        versions.offline,
335    );
336
337    push_offline_footer_hover_section(&mut markdown, resolvable, versions.offline);
338
339    Some(Hover {
340        contents: HoverContents::Markup(MarkupContent {
341            kind: MarkupKind::Markdown,
342            value: markdown,
343        }),
344        range: Some(dep.name_range()),
345    })
346}
347
348/// Spawns the deps.dev supply-chain trust-signal fetch (spec 037) as a detached
349/// background task. Only `handlers/hover.rs` (deps-lsp) ever sets `versions.trust`,
350/// which is what makes FR-010's hover-only scope structural: every other surface
351/// (diagnostics, code actions, inlay hints, code lenses) is never handed a client and
352/// so can never reach deps.dev.
353///
354/// Gated on all of: a client was handed in, `network.offline` is not set, the source
355/// resolves against a **public** registry, the ecosystem is one of the seven
356/// `deps_dev_system` maps, and a concrete in-use version exists — the last two
357/// checked with **no** network I/O, so an ecosystem `deps_dev_system` excludes
358/// (Composer, Dart, Swift, ...) spawns nothing at all.
359///
360/// `formatter.source_is_public_registry_content(dep_source)`, not the weaker
361/// `EcosystemFormatter::can_resolve_source`: that predicate is deliberately widened
362/// by some ecosystems (e.g. `deps-cargo`'s `AlternateRegistry`) to cover *any*
363/// configured registry, private/internal ones included — reusing it here would send
364/// a private package's name and version to deps.dev by default (security audit M2).
365/// `source_is_public_registry_content` is the same, stricter predicate this server's
366/// other third-party lookup (OSV) already gates on (`lifecycle.rs`).
367///
368/// `!versions.offline`: every other network-gated hover section [`generate_hover`]
369/// builds checks `versions.offline` (the `Cmd+.` footer, the offline footer) —
370/// without it here, offline mode still spawns a task per hover and writes a 90s
371/// negative memo entry, keeping the signal absent for up to 90s per package after
372/// reconnecting for no reason (critic C4).
373///
374/// `tokio::spawn` panics outside a Tokio runtime — every caller of [`generate_hover`]
375/// is `#[tokio::test]`-async or the real LSP server, so this is safe here, but no
376/// doc-test may call it directly. The spawned future captures only owned/`'static`
377/// data (`Arc<DepsDevClient>`, `&'static str`, owned `String`s) so it satisfies
378/// `Send + 'static` with no borrow from `dep`.
379fn spawn_trust_signal_fetch(
380    dep: &dyn Dependency,
381    dep_source: &DependencySource,
382    versions: &VersionData<'_>,
383    formatter: &dyn EcosystemFormatter,
384    normalized_name: &str,
385) -> Option<tokio::task::JoinHandle<Option<SupplyChainTrustSignal>>> {
386    versions.trust.and_then(|client| {
387        let ecosystem = versions.ecosystem?;
388        let system = deps_dev_system(ecosystem)?;
389        if versions.offline || !formatter.source_is_public_registry_content(dep_source) {
390            return None;
391        }
392        let version = in_use_version(
393            dep,
394            normalized_name,
395            versions.resolved,
396            formatter,
397            ecosystem,
398        )?;
399        let client = Arc::clone(client);
400        let name = dep.name().to_string();
401        Some(tokio::spawn(async move {
402            client.trust_signal(system, &name, &version).await
403        }))
404    })
405}
406
407/// Appends the hover header: the dependency name, linked to its registry page when
408/// `url` (from [`EcosystemFormatter::package_url`]) is present.
409fn push_header_hover_section(markdown: &mut String, dep: &dyn Dependency, url: Option<&str>) {
410    use std::fmt::Write as _;
411
412    match url {
413        Some(url) => write!(
414            markdown,
415            "# [{}]({})\n\n",
416            escape_markdown(dep.name().as_str()),
417            url
418        ),
419        None => write!(markdown, "# {}\n\n", escape_markdown(dep.name().as_str())),
420    }
421    .unwrap();
422}
423
424/// Appends the hover "Current"/"Requirement" line. `resolved` — already selecting
425/// between an ecosystem's resolved manifest requirement and the lockfile-resolved
426/// version, per [`EcosystemFormatter::manifest_requirement_is_resolved_version`] —
427/// wins over the bare manifest requirement when present.
428fn push_current_or_requirement_hover_section(
429    markdown: &mut String,
430    dep: &dyn Dependency,
431    resolved: Option<&str>,
432) {
433    use std::fmt::Write as _;
434
435    if let Some(resolved_ver) = resolved {
436        write!(
437            markdown,
438            "**Current**: {}\n\n",
439            markdown_code_span(resolved_ver)
440        )
441        .unwrap();
442    } else if let Some(version_req) = dep.version_requirement() {
443        write!(
444            markdown,
445            "**Requirement**: {}\n\n",
446            markdown_code_span(version_req.as_str())
447        )
448        .unwrap();
449    }
450}
451
452/// Appends the hover "Active when" line for an environment-marker-gated dependency
453/// (e.g. PEP 508's `python_version >= '3.8'`). Ecosystem-specific; renders nothing
454/// when [`Dependency::markers`] is `None`.
455fn push_markers_hover_section(markdown: &mut String, dep: &dyn Dependency) {
456    use std::fmt::Write as _;
457
458    if let Some(marker_expr) = dep.markers() {
459        write!(
460            markdown,
461            "**Active when**: {}\n\n",
462            markdown_code_span(marker_expr)
463        )
464        .unwrap();
465    }
466}
467
468/// Appends the hover "Latest" line and, when the version is still within the
469/// configured cooldown window, the "Recently published" callout beneath it.
470///
471/// `latest_line` renders nothing when `None` — no header, matching an empty "Recent
472/// versions" list below. See [`generate_hover`]'s derivation of `latest_line` for the
473/// full Ch1/Ch2/fallback precedence rules (issue #227 F5, #313, #373).
474fn push_latest_hover_section(
475    markdown: &mut String,
476    latest_line: Option<(&str, Option<PublishTime>)>,
477    freshness: crate::freshness::FreshnessSettings,
478    now: PublishTime,
479) {
480    use std::fmt::Write as _;
481
482    let Some((latest_ver, raw_published_at)) = latest_line else {
483        return;
484    };
485    let published_at = freshness.enabled.then_some(raw_published_at).flatten();
486    let age_secs = published_at.map(|p| p.age_secs_from(now));
487    write!(markdown, "**Latest**: {}", markdown_code_span(latest_ver)).unwrap();
488    if let Some(age_secs) = age_secs {
489        write!(markdown, " *(published {})*", format_relative_age(age_secs)).unwrap();
490    }
491    markdown.push_str("\n\n");
492    if age_secs.is_some_and(|age| is_within_cooldown(age, freshness.cooldown_secs)) {
493        markdown.push_str(
494            "> ⏳ **Recently published** — this release is still within the cooldown window.\n\
495             > It may still be yanked or superseded; consider verifying before upgrading.\n\n",
496        );
497    }
498}
499
500/// Appends the "Recent versions" list: the top [`HOVER_RECENT_VERSIONS`] entries of
501/// `available_versions`, each optionally aged (freshness-gated) and marked
502/// `*(latest)*` at `live_latest_idx` — matched by position against the header's
503/// stable-latest pick rather than raw index 0 or string equality: `available_versions`
504/// is sorted purely by version number, so index 0 can be a pre-release the header
505/// itself doesn't call "latest" (issue #313), and matching by version string instead
506/// of index could tag more than one entry if two ever shared a version string. No
507/// match in the rendered top-N slice simply omits the marker.
508///
509/// Renders nothing when `available_versions` is empty (issue #550): an empty
510/// "Recent versions" header with no entries under it is never useful.
511fn push_recent_versions_hover_section(
512    markdown: &mut String,
513    available_versions: &[Box<dyn Version>],
514    live_latest_idx: Option<usize>,
515    freshness: crate::freshness::FreshnessSettings,
516    now: PublishTime,
517    formatter: &dyn EcosystemFormatter,
518) {
519    use std::fmt::Write as _;
520
521    if available_versions.is_empty() {
522        return;
523    }
524
525    markdown.push_str("**Recent versions**:\n");
526    for (i, version) in available_versions
527        .iter()
528        .take(HOVER_RECENT_VERSIONS)
529        .enumerate()
530    {
531        let version_span = markdown_code_span(version.version_string().as_str());
532        let age_suffix = if freshness.enabled {
533            version_age_suffix(version.as_ref(), now)
534        } else {
535            String::new()
536        };
537        if Some(i) == live_latest_idx {
538            if version.removal_status().is_flagged() {
539                // The resolved "latest" can itself be flagged (e.g. npm's ranking
540                // preference falls through to a deprecated version when no clean one
541                // exists) — the deprecation/yank warning must not silently vanish just
542                // because this entry also carries the `(latest)` marker (#347/#348 S1).
543                writeln!(
544                    markdown,
545                    "- {version_span} *(latest)* {}{age_suffix}",
546                    formatter.yanked_label()
547                )
548                .unwrap();
549            } else {
550                writeln!(markdown, "- {version_span} *(latest)*{age_suffix}").unwrap();
551            }
552        } else if version.removal_status().is_flagged() {
553            writeln!(
554                markdown,
555                "- {} {}{}",
556                version_span,
557                formatter.yanked_label(),
558                age_suffix
559            )
560            .unwrap();
561        } else {
562            writeln!(markdown, "- {version_span}{age_suffix}").unwrap();
563        }
564    }
565}
566
567/// Appends the `Cmd+.` code-action footer — advertised only when a fix action could
568/// actually exist for this dependency, since none is ever offered for a source that
569/// is deliberately non-resolvable (e.g. a local composite action or a Docker image
570/// ref), so rendering it unconditionally is misleading there (#474).
571///
572/// Gated on `resolvable` alone, not on `available_versions`/`cached_latest` also
573/// being populated: a vulnerability-fix or unsatisfiable-fix code action
574/// (`code_actions.rs`) can exist from `vuln_outcome` — populated independently of the
575/// registry fetch (`lifecycle.rs`) — even when both of those are empty (e.g. a
576/// registry fetch failure), so requiring them too would silently drop the footer
577/// while `Cmd+.` still offers a fix.
578///
579/// Also gated on offline data availability (#501): `HttpCache` deliberately serves
580/// warm entries while offline (it force-enables caching in that mode), and doc-state
581/// fields — vulnerabilities/cached latest/deprecation — survive an online-to-offline
582/// transition via `preserve_cache`, so `Cmd+.` can still produce a real
583/// REFACTOR/fix/replacement action offline as long as *some* version, vulnerability,
584/// or deprecation data was actually rendered above. Only suppress when offline AND
585/// none of that data is present — a cold process with nothing cached yet, where no
586/// producer in `generate_code_actions` has anything to act on.
587///
588/// `matches!(vuln_outcome, Some(ScanOutcome::Vulnerable(_)))`, not
589/// `vuln_outcome.is_some()` (#501 C5): offline does not skip the OSV scan, it lets it
590/// run and fail, which writes `ScanOutcome::Skipped(_)` for every dependency —
591/// `is_some()` would be true in exactly #501's own cold-start repro and only
592/// `Vulnerable` ever backs `build_vulnerability_fix_action` (`code_actions.rs`).
593///
594/// A live fetch that genuinely succeeded with zero entries (`Some(&[])`, distinct
595/// from `None` — a fetch that errored or never ran, where an unrelated Cmd+. action
596/// such as an unsatisfiable-fix might still exist per the reasoning above) is
597/// definitive proof there is nothing version-wise to update to (#550): combined with
598/// no cached/vulnerability/deprecation data either, the footer would otherwise
599/// advertise an action that provably does not exist — the "empty Recent versions
600/// section plus a stray footer" bug reported against GHA's
601/// `dtolnay/rust-toolchain@stable` but not specific to any one ecosystem. Every other
602/// online case (a non-empty live list, or no live fetch at all) keeps the pre-#550
603/// unconditional-when-resolvable behavior.
604fn push_cmd_dot_footer_hover_section(
605    markdown: &mut String,
606    resolvable: bool,
607    available_versions: Option<&[Box<dyn Version>]>,
608    cached_latest: Option<&super::PackageVersions>,
609    vuln_outcome: Option<&ScanOutcome>,
610    deprecation: Option<&Deprecation>,
611    offline: bool,
612) {
613    let has_offline_actionable_data = available_versions.is_some_and(|v| !v.is_empty())
614        || cached_latest.is_some()
615        || matches!(vuln_outcome, Some(ScanOutcome::Vulnerable(_)))
616        || deprecation.is_some();
617    let live_fetch_definitively_empty = available_versions.is_some_and(<[_]>::is_empty);
618    let footer_actionable =
619        has_offline_actionable_data || (!live_fetch_definitively_empty && !offline);
620    if resolvable && footer_actionable {
621        markdown.push_str(CMD_DOT_FOOTER);
622    }
623}
624
625/// Appends the "Offline: version and vulnerability data not checked" footer (issue
626/// #483) — the OSV lookup that produced `ScanOutcome::Skipped` for this dependency
627/// renders nothing in the vulnerability section, which would otherwise be visually
628/// indistinguishable from a scanned, vulnerability-free dependency; this footer calls
629/// out that vulnerability data specifically was not checked, not just version data
630/// (S2).
631///
632/// Gated on `resolvable` too, matching the `Cmd+.` footer (#474/#475): a dependency
633/// that is never network-resolved under any setting (a local composite action, a
634/// Docker image ref, a Git/path dependency) must not claim its version or
635/// vulnerability data went unchecked *because of* `network.offline` — nothing there
636/// was ever going to be checked regardless.
637fn push_offline_footer_hover_section(markdown: &mut String, resolvable: bool, offline: bool) {
638    if offline && resolvable {
639        markdown.push_str("\n---\n📴 *Offline: version and vulnerability data not checked*");
640    }
641}
642
643/// Lowercase display label for a [`crate::osv::VulnSeverity`], used only in hover text.
644const fn severity_label(severity: crate::osv::VulnSeverity) -> &'static str {
645    match severity {
646        crate::osv::VulnSeverity::Critical => "critical",
647        crate::osv::VulnSeverity::High => "high",
648        crate::osv::VulnSeverity::Medium => "medium",
649        crate::osv::VulnSeverity::Low => "low",
650        crate::osv::VulnSeverity::Unknown => "unknown severity",
651    }
652}
653
654/// Appends the hover "Deprecated" section (issue #205), gated strictly on `deprecation`
655/// being present — never rendered as "not deprecated" for a clean package, the same
656/// discipline [`push_vulnerability_hover_section`] applies to `Skipped`/`None`.
657///
658/// Deliberately not deduped against npm's per-row `*(deprecated)*` "Recent versions"
659/// labels (S4, plan.md D6): suppressing those would require threading this finding into
660/// the version-list renderer, which takes no such parameter today.
661fn push_deprecation_hover_section(
662    markdown: &mut String,
663    formatter: &dyn EcosystemFormatter,
664    deprecation: Option<&Deprecation>,
665) {
666    use std::fmt::Write as _;
667
668    let Some(deprecation) = deprecation else {
669        return;
670    };
671
672    // I3: each part gets its own blank-line-separated paragraph, mirroring
673    // `push_vulnerability_hover_section`'s discipline — three bare consecutive
674    // `writeln!` lines with no blank line between them collapse into one CommonMark
675    // paragraph, rendering the message/reason/replacement joined instead of as the
676    // visually distinct lines the section is meant to show.
677    markdown.push_str("### Deprecated\n\n");
678    let _ = writeln!(markdown, "{}\n", formatter.deprecated_message());
679    if let Some(reason) = deprecation.reason.as_deref().filter(|r| !r.is_empty()) {
680        let _ = writeln!(markdown, "{}\n", escape_markdown(reason));
681    }
682    if let Some(replacement) = deprecation.replacement.as_deref().filter(|r| !r.is_empty()) {
683        let _ = writeln!(
684            markdown,
685            "Suggested replacement: {}\n",
686            markdown_code_span(replacement)
687        );
688    }
689}
690
691/// Appends the hover "Security advisories" section, gated strictly on the
692/// scan outcome — never on map absence.
693///
694/// `Vulnerable` gets the advisories list, `Clean` may state the affirmative
695/// "no known vulnerabilities", and `Skipped` (or no scan at all) says
696/// **nothing**: saying "clean" about a dependency that was never queried is
697/// worse than saying nothing at all (`architecture.md` §8 invariant 0).
698fn push_vulnerability_hover_section(markdown: &mut String, outcome: Option<&ScanOutcome>) {
699    use std::fmt::Write;
700
701    match outcome {
702        Some(ScanOutcome::Vulnerable(dv)) => {
703            markdown.push_str("### Security advisories\n\n");
704
705            for advisory in dv.advisories.items() {
706                writeln!(
707                    markdown,
708                    "- **[{}]({})** — {}",
709                    escape_markdown(&advisory.id),
710                    advisory.url,
711                    severity_label(advisory.severity)
712                )
713                .unwrap();
714                writeln!(
715                    markdown,
716                    "  {}",
717                    escape_markdown(
718                        advisory
719                            .summary
720                            .as_deref()
721                            .unwrap_or("(no summary provided)")
722                    )
723                )
724                .unwrap();
725
726                let mut details = Vec::with_capacity(2);
727                if let Some(fixed) = advisory.fixed_versions.last() {
728                    details.push(format!("Fixed in: {}", markdown_code_span(fixed)));
729                }
730                if !advisory.aliases.is_empty() {
731                    details.push(format!(
732                        "Aliases: {}",
733                        escape_markdown(&advisory.aliases.join(", "))
734                    ));
735                }
736                if !details.is_empty() {
737                    writeln!(markdown, "  {}", details.join(" \u{b7} ")).unwrap();
738                }
739            }
740
741            let remaining = dv.advisories.remaining();
742            if remaining > 0 {
743                writeln!(markdown, "- *(+{remaining} more advisories)*").unwrap();
744            }
745
746            if let crate::osv::UpgradeStatus::CandidateVulnerable { version, .. } =
747                &dv.upgrade_status
748            {
749                writeln!(
750                    markdown,
751                    "\n\u{26a0}\u{fe0f} Latest version {} is also affected.",
752                    markdown_code_span(version)
753                )
754                .unwrap();
755            }
756
757            markdown.push('\n');
758        }
759        Some(ScanOutcome::Clean) => {
760            markdown.push_str("**No known vulnerabilities** (OSV.dev)\n\n");
761        }
762        Some(ScanOutcome::Skipped(_)) | None => {}
763    }
764}
765
766/// Appends the hover "Supply chain" line (spec 037): one line, no `###` header,
767/// deliberately lighter than the deprecation/advisory sections above — this signal
768/// is informational-only (FR-012) and carries no severity language.
769///
770/// `signal` is `None` both when no fetch was ever attempted (deps.dev disabled,
771/// unsupported ecosystem, no in-use version, ...) and when both deps.dev calls
772/// failed or the wait budget elapsed — every case renders nothing, matching
773/// FR-006/US-004. A `Some` signal whose scorecard and provenance are *both*
774/// `None` (possible only via `SupplyChainTrustSignal::default()`, never returned by
775/// `DepsDevClient::trust_signal` itself) also renders nothing, defensively.
776fn push_trust_signal_hover_section(markdown: &mut String, signal: Option<&SupplyChainTrustSignal>) {
777    use std::fmt::Write;
778
779    let Some(signal) = signal else {
780        return;
781    };
782    if signal.scorecard.is_none() && signal.provenance.is_none() {
783        return;
784    }
785
786    let mut parts: Vec<String> = Vec::with_capacity(2);
787    if let Some(scorecard) = &signal.scorecard {
788        let mut part = format!(
789            "OpenSSF Scorecard {}/10",
790            markdown_code_span(&format!("{:.1}", scorecard.overall_score))
791        );
792        if scorecard.self_reported {
793            part.push_str(" *(self-reported repo)*");
794        }
795        parts.push(part);
796    }
797    if let Some(provenance) = signal.provenance {
798        let label = match provenance {
799            ProvenanceStatus::Verified => "verified",
800            ProvenanceStatus::Unverified => "attested but unverified",
801            ProvenanceStatus::None => "none found",
802        };
803        // "Provenance", not "SLSA provenance": `classify_provenance` deliberately unions
804        // `slsaProvenances[]` with `attestations[]` (plan-mandated), and an attestation
805        // entry's `type` is not necessarily SLSA — labeling every verified entry as SLSA
806        // specifically would misrepresent which standard was actually verified for a
807        // package whose only verified entry came from `attestations[]` (critic C3).
808        parts.push(format!("Provenance: {label}"));
809    }
810
811    writeln!(
812        markdown,
813        "\u{1f510} **Supply chain**: {}",
814        parts.join(" \u{b7} ")
815    )
816    .unwrap();
817    markdown.push('\n');
818}
819
820#[cfg(test)]
821mod tests {
822    use super::*;
823    use crate::RemovalStatus;
824    use crate::lsp_helpers::test_support::*;
825    use crate::lsp_helpers::*;
826
827    use std::collections::HashMap;
828    use std::sync::Arc;
829
830    #[tokio::test]
831    async fn test_generate_hover_recent_versions_shows_age_when_known() {
832        use std::collections::HashMap;
833
834        let registry = MockRegistryWithVersions {
835            versions: vec![MockVersionWithAge {
836                version: "1.2.3".into(),
837                yanked: false,
838                // 2 days ago — safely mid-bucket, immune to sub-second test flakiness.
839                published_at: Some(PublishTime::from_unix_secs(
840                    PublishTime::now().as_unix_secs() - 2 * 24 * 60 * 60,
841                )),
842            }],
843        };
844        let parse_result = freshness_test_parse_result("serde");
845
846        let hover = generate_hover(
847            &parse_result,
848            Position::new(0, 2),
849            VersionData::new(&HashMap::new(), &HashMap::new()),
850            &registry,
851            &MockFormatter,
852            crate::freshness::FreshnessSettings::default(),
853            PublishTime::now(),
854        )
855        .await
856        .expect("hover should be generated for a dependency at the cursor");
857
858        let HoverContents::Markup(content) = hover.contents else {
859            panic!("expected markup hover contents");
860        };
861        assert!(
862            content.value.contains("- `1.2.3` *(latest)* — 2 days ago"),
863            "got: {}",
864            content.value
865        );
866    }
867
868    #[tokio::test]
869    async fn test_generate_hover_recent_versions_omits_age_when_unknown() {
870        use std::collections::HashMap;
871
872        let registry = MockRegistryWithVersions {
873            versions: vec![MockVersionWithAge {
874                version: "1.2.3".into(),
875                yanked: false,
876                published_at: None,
877            }],
878        };
879        let parse_result = freshness_test_parse_result("serde");
880
881        let hover = generate_hover(
882            &parse_result,
883            Position::new(0, 2),
884            VersionData::new(&HashMap::new(), &HashMap::new()),
885            &registry,
886            &MockFormatter,
887            crate::freshness::FreshnessSettings::default(),
888            PublishTime::now(),
889        )
890        .await
891        .expect("hover should be generated for a dependency at the cursor");
892
893        let HoverContents::Markup(content) = hover.contents else {
894            panic!("expected markup hover contents");
895        };
896        // Exactly the pre-feature line: no trailing age suffix.
897        assert!(content.value.contains("- `1.2.3` *(latest)*\n"));
898        assert!(!content.value.contains("ago"));
899    }
900
901    #[tokio::test]
902    async fn test_generate_hover_latest_marker_skips_prerelease_at_raw_top() {
903        use std::collections::HashMap;
904
905        // Raw registry order (newest by version number first): a pre-release sorts above
906        // the actual stable latest, mirroring NuGet's Newtonsoft.Json 13.0.5-beta1 vs
907        // 13.0.4 (#313).
908        let registry = MockRegistryWithVersions {
909            versions: vec![
910                MockVersionWithAge {
911                    version: "13.0.5-beta1".into(),
912                    yanked: false,
913                    published_at: None,
914                },
915                MockVersionWithAge {
916                    version: "13.0.4".into(),
917                    yanked: false,
918                    published_at: None,
919                },
920            ],
921        };
922        let parse_result = freshness_test_parse_result("Newtonsoft.Json");
923
924        let hover = generate_hover(
925            &parse_result,
926            Position::new(0, 2),
927            VersionData::new(&HashMap::new(), &HashMap::new()),
928            &registry,
929            &MockFormatter,
930            crate::freshness::FreshnessSettings::default(),
931            PublishTime::now(),
932        )
933        .await
934        .expect("hover should be generated for a dependency at the cursor");
935
936        let HoverContents::Markup(content) = hover.contents else {
937            panic!("expected markup hover contents");
938        };
939        assert!(
940            content.value.contains("**Latest**: `13.0.4`"),
941            "got: {}",
942            content.value
943        );
944        assert!(
945            content.value.contains("- `13.0.4` *(latest)*"),
946            "the stable version, not the raw-top pre-release, should carry the marker; got: {}",
947            content.value
948        );
949        assert!(
950            !content.value.contains("13.0.5-beta1` *(latest)*"),
951            "the pre-release must not be tagged latest; got: {}",
952            content.value
953        );
954    }
955
956    #[tokio::test]
957    async fn test_generate_hover_latest_marker_omitted_when_stable_outside_top_n() {
958        use std::collections::HashMap;
959
960        // Nine pre-releases followed by one stable version: the stable pick sits past
961        // `HOVER_RECENT_VERSIONS`, so it never appears in the rendered list.
962        let mut versions: Vec<MockVersionWithAge> = (0..=HOVER_RECENT_VERSIONS)
963            .map(|i| MockVersionWithAge {
964                version: format!("2.0.0-alpha{i}").into(),
965                yanked: false,
966                published_at: None,
967            })
968            .collect();
969        versions.push(MockVersionWithAge {
970            version: "1.9.0".into(),
971            yanked: false,
972            published_at: None,
973        });
974        let registry = MockRegistryWithVersions { versions };
975        let parse_result = freshness_test_parse_result("example");
976
977        let hover = generate_hover(
978            &parse_result,
979            Position::new(0, 2),
980            VersionData::new(&HashMap::new(), &HashMap::new()),
981            &registry,
982            &MockFormatter,
983            crate::freshness::FreshnessSettings::default(),
984            PublishTime::now(),
985        )
986        .await
987        .expect("hover should be generated for a dependency at the cursor");
988
989        let HoverContents::Markup(content) = hover.contents else {
990            panic!("expected markup hover contents");
991        };
992        assert!(
993            content.value.contains("**Latest**: `1.9.0`"),
994            "got: {}",
995            content.value
996        );
997        assert!(
998            !content.value.contains("*(latest)*"),
999            "the stable latest isn't in the truncated top-N slice, so no entry should be marked; got: {}",
1000            content.value
1001        );
1002    }
1003
1004    #[tokio::test]
1005    async fn test_generate_hover_latest_marker_all_prerelease_degrades_gracefully() {
1006        use std::collections::HashMap;
1007
1008        // No stable version exists anywhere in the list: `find_latest_stable` returns
1009        // `None`, and there is no Ch1 cache to fall back to either (#313 edge case).
1010        let registry = MockRegistryWithVersions {
1011            versions: vec![
1012                MockVersionWithAge {
1013                    version: "2.0.0-beta2".into(),
1014                    yanked: false,
1015                    published_at: None,
1016                },
1017                MockVersionWithAge {
1018                    version: "2.0.0-beta1".into(),
1019                    yanked: false,
1020                    published_at: None,
1021                },
1022                MockVersionWithAge {
1023                    version: "1.9.0-alpha1".into(),
1024                    yanked: false,
1025                    published_at: None,
1026                },
1027            ],
1028        };
1029        let parse_result = freshness_test_parse_result("example");
1030
1031        let hover = generate_hover(
1032            &parse_result,
1033            Position::new(0, 2),
1034            VersionData::new(&HashMap::new(), &HashMap::new()),
1035            &registry,
1036            &MockFormatter,
1037            crate::freshness::FreshnessSettings::default(),
1038            PublishTime::now(),
1039        )
1040        .await
1041        .expect("hover should be generated for a dependency at the cursor, not panic");
1042
1043        let HoverContents::Markup(content) = hover.contents else {
1044            panic!("expected markup hover contents");
1045        };
1046        assert!(
1047            !content.value.contains("**Latest**:"),
1048            "no stable version exists, so the header should be omitted rather than picking a pre-release; got: {}",
1049            content.value
1050        );
1051        assert!(
1052            !content.value.contains("*(latest)*"),
1053            "no stable version exists, so no entry in the list should be marked latest; got: {}",
1054            content.value
1055        );
1056        assert!(
1057            content.value.contains("2.0.0-beta2"),
1058            "the raw version list should still render even without a latest marker; got: {}",
1059            content.value
1060        );
1061    }
1062
1063    #[tokio::test]
1064    async fn test_generate_hover_latest_marker_all_prerelease_live_list_ignores_stale_cache() {
1065        use std::collections::HashMap;
1066
1067        // A live fetch happened (so `available_versions` is `Some`), but every entry in it
1068        // is a pre-release: `live_latest_idx` is `None`. A stale Ch1 cache is also present,
1069        // recording a version that isn't part of the live list at all. Falling back to that
1070        // stale cached value here would render a `**Latest**` line that contradicts the live
1071        // "Recent versions" list right below it — exactly the self-contradiction #227 F5 was
1072        // fixed to prevent, just reached through this all-prerelease path instead (#313 S2).
1073        let registry = MockRegistryWithVersions {
1074            versions: vec![MockVersionWithAge {
1075                version: "2.0.0-beta2".into(),
1076                yanked: false,
1077                published_at: None,
1078            }],
1079        };
1080        let parse_result = freshness_test_parse_result("example");
1081        let mut cached_versions = HashMap::new();
1082        cached_versions.insert("example".into(), PackageVersions::latest_only("1.5.0"));
1083
1084        let hover = generate_hover(
1085            &parse_result,
1086            Position::new(0, 2),
1087            VersionData::new(&cached_versions, &HashMap::new()),
1088            &registry,
1089            &MockFormatter,
1090            crate::freshness::FreshnessSettings::default(),
1091            PublishTime::now(),
1092        )
1093        .await
1094        .expect("hover should be generated for a dependency at the cursor, not panic");
1095
1096        let HoverContents::Markup(content) = hover.contents else {
1097            panic!("expected markup hover contents");
1098        };
1099        assert!(
1100            !content.value.contains("**Latest**:"),
1101            "a live fetch with no stable entry must not fall back to a stale cached version \
1102             that isn't in the live list; got: {}",
1103            content.value
1104        );
1105        assert!(
1106            !content.value.contains("1.5.0"),
1107            "the stale cached version must not leak into the response at all; got: {}",
1108            content.value
1109        );
1110    }
1111
1112    /// #373: Go's `/@v/list` never enumerates pseudo-versions, so an untagged module whose
1113    /// entire tagged history is pre-release fails `select_latest_matching`'s list-based pick
1114    /// even though the live fetch succeeded and returned a non-empty list. Hover must fall
1115    /// back to `Registry::get_latest_matching` (mirroring `lifecycle.rs`'s background-fetch
1116    /// fallback, which answers this from Go's `/@latest` endpoint) instead of rendering no
1117    /// `**Latest**` line at all.
1118    #[tokio::test]
1119    async fn test_generate_hover_latest_falls_back_to_get_latest_matching_when_list_pick_fails() {
1120        use std::collections::HashMap;
1121
1122        let registry = MockRegistryListFailsLatestFallbackSucceeds {
1123            versions: vec![MockVersionWithAge {
1124                version: "v0.0.0-20230101000000-abcdef123456".into(),
1125                yanked: false,
1126                published_at: None,
1127            }],
1128            fallback_latest: MockVersionWithAge {
1129                version: "v1.2.3".into(),
1130                yanked: false,
1131                published_at: None,
1132            },
1133            list_pick_index: None,
1134            get_latest_matching_calls: std::sync::atomic::AtomicUsize::new(0),
1135        };
1136        let parse_result = freshness_test_parse_result("example.com/mod");
1137
1138        let hover = generate_hover(
1139            &parse_result,
1140            Position::new(0, 2),
1141            VersionData::new(&HashMap::new(), &HashMap::new()),
1142            &registry,
1143            &MockFormatter,
1144            crate::freshness::FreshnessSettings::default(),
1145            PublishTime::now(),
1146        )
1147        .await
1148        .expect("hover should be generated for a dependency at the cursor");
1149
1150        let HoverContents::Markup(content) = hover.contents else {
1151            panic!("expected markup hover contents");
1152        };
1153        assert!(
1154            content.value.contains("**Latest**: `v1.2.3`"),
1155            "the list-based pick failed, so hover must fall back to get_latest_matching's \
1156             result instead of omitting the Latest line; got: {}",
1157            content.value
1158        );
1159    }
1160
1161    /// #373 M4: the fallback must not fire on the common "list-based pick already
1162    /// succeeded" path — asserted via a call counter on the mock, guarding against a
1163    /// future regression that would make every hover pay for a second registry round
1164    /// trip regardless of whether the list-based pick worked.
1165    #[tokio::test]
1166    async fn test_generate_hover_does_not_call_fallback_when_list_pick_succeeds() {
1167        use std::collections::HashMap;
1168        use std::sync::atomic::Ordering;
1169
1170        let registry = MockRegistryListFailsLatestFallbackSucceeds {
1171            versions: vec![MockVersionWithAge {
1172                version: "v1.2.3".into(),
1173                yanked: false,
1174                published_at: None,
1175            }],
1176            fallback_latest: MockVersionWithAge {
1177                version: "v9.9.9".into(),
1178                yanked: false,
1179                published_at: None,
1180            },
1181            list_pick_index: Some(0),
1182            get_latest_matching_calls: std::sync::atomic::AtomicUsize::new(0),
1183        };
1184        let parse_result = freshness_test_parse_result("example.com/mod");
1185
1186        let hover = generate_hover(
1187            &parse_result,
1188            Position::new(0, 2),
1189            VersionData::new(&HashMap::new(), &HashMap::new()),
1190            &registry,
1191            &MockFormatter,
1192            crate::freshness::FreshnessSettings::default(),
1193            PublishTime::now(),
1194        )
1195        .await
1196        .expect("hover should be generated for a dependency at the cursor");
1197
1198        let HoverContents::Markup(content) = hover.contents else {
1199            panic!("expected markup hover contents");
1200        };
1201        assert!(
1202            content.value.contains("**Latest**: `v1.2.3`"),
1203            "expected the list-based pick's own version, not the fallback's; got: {}",
1204            content.value
1205        );
1206        assert_eq!(
1207            registry.get_latest_matching_calls.load(Ordering::Relaxed),
1208            0,
1209            "get_latest_matching must not be called when the list-based pick already succeeded"
1210        );
1211    }
1212
1213    /// Review regression: a fetch failure for a resolvable source (off-VPN, an expired
1214    /// token, a DNS-blocked internal host — routine for a private-registry user) must
1215    /// degrade to the basic name/requirement/features card, not vanish the whole hover
1216    /// response. `.ok()?` on the fetch would have propagated `None` out of the entire
1217    /// function here; `.ok()` must let `available_versions` become `None` instead.
1218    #[tokio::test]
1219    async fn test_generate_hover_renders_basic_card_when_fetch_fails() {
1220        use std::collections::HashMap;
1221
1222        let parse_result = freshness_test_parse_result("serde");
1223
1224        let hover = generate_hover(
1225            &parse_result,
1226            Position::new(0, 2),
1227            VersionData::new(&HashMap::new(), &HashMap::new()),
1228            &ErrorRegistry,
1229            &MockFormatter,
1230            crate::freshness::FreshnessSettings::default(),
1231            PublishTime::now(),
1232        )
1233        .await
1234        .expect("hover must still render on a fetch failure, not disappear entirely");
1235
1236        let HoverContents::Markup(content) = hover.contents else {
1237            panic!("expected markup hover contents");
1238        };
1239        assert!(
1240            content.value.contains("serde"),
1241            "basic card must still render the package name; got: {}",
1242            content.value
1243        );
1244        assert!(
1245            !content.value.contains("**Latest**"),
1246            "no version data is available on a fetch failure; got: {}",
1247            content.value
1248        );
1249    }
1250
1251    #[tokio::test]
1252    async fn test_generate_hover_go_prefers_manifest_requirement_over_stale_resolved_version() {
1253        use std::collections::HashMap;
1254
1255        let parse_result = MockParseResult {
1256            deps: vec![MockDep {
1257                name: "example.com/mod".into(),
1258                version_req: "v0.8.1".into(),
1259                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1260                name_range: Range::new(Position::new(0, 0), Position::new(0, 16)),
1261            }],
1262            uri: crate::test_util::test_uri("/test/go.mod"),
1263        };
1264
1265        // Stale go.sum entry left behind by a downgrade (#235): go.mod's `require`
1266        // line was downgraded back to v0.8.1, but the ledger-only go.sum still
1267        // records the higher v0.9.1 and sorts last, so it would win naive
1268        // last-occurrence-wins parsing if hover trusted `versions.resolved` here.
1269        let mut resolved_versions = HashMap::new();
1270        resolved_versions.insert("example.com/mod".into(), "v0.9.1".into());
1271        let cached_versions = HashMap::new();
1272
1273        let hover = generate_hover(
1274            &parse_result,
1275            Position::new(0, 2),
1276            VersionData::new(&cached_versions, &resolved_versions),
1277            &MockRegistry,
1278            &MockGoFormatter,
1279            crate::freshness::FreshnessSettings::default(),
1280            PublishTime::now(),
1281        )
1282        .await
1283        .expect("hover should be generated for a dependency at the cursor");
1284
1285        let HoverContents::Markup(content) = hover.contents else {
1286            panic!("expected markup hover contents");
1287        };
1288        assert!(
1289            content.value.contains("**Current**: `v0.8.1`"),
1290            "expected hover to show go.mod's pinned version, got: {}",
1291            content.value
1292        );
1293        assert!(
1294            !content.value.contains("v0.9.1"),
1295            "hover must not surface the stale go.sum version: {}",
1296            content.value
1297        );
1298    }
1299
1300    #[tokio::test]
1301    async fn test_generate_hover_non_go_formatter_uses_resolved_lockfile_version() {
1302        use std::collections::HashMap;
1303
1304        let parse_result = MockParseResult {
1305            deps: vec![MockDep {
1306                name: "serde".into(),
1307                version_req: "1.0.0".into(),
1308                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1309                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
1310            }],
1311            uri: crate::test_util::test_uri("/test/Cargo.toml"),
1312        };
1313
1314        let mut resolved_versions = HashMap::new();
1315        resolved_versions.insert("serde".into(), "1.2.0".into());
1316        let cached_versions = HashMap::new();
1317
1318        let hover = generate_hover(
1319            &parse_result,
1320            Position::new(0, 2),
1321            VersionData::new(&cached_versions, &resolved_versions),
1322            &MockRegistry,
1323            &MockFormatter,
1324            crate::freshness::FreshnessSettings::default(),
1325            PublishTime::now(),
1326        )
1327        .await
1328        .expect("hover should be generated for a dependency at the cursor");
1329
1330        let HoverContents::Markup(content) = hover.contents else {
1331            panic!("expected markup hover contents");
1332        };
1333        // Non-Go formatters must keep showing the lockfile-resolved version
1334        // ("1.2.0"), not the raw manifest requirement ("1.0.0") — confirms the Go
1335        // override does not leak into other ecosystems.
1336        assert!(
1337            content.value.contains("**Current**: `1.2.0`"),
1338            "expected hover to show the resolved lockfile version, got: {}",
1339            content.value
1340        );
1341        assert!(!content.value.contains("**Current**: `1.0.0`"));
1342    }
1343
1344    #[tokio::test]
1345    async fn test_generate_hover_recent_versions_preserves_yanked_marker_with_age() {
1346        use std::collections::HashMap;
1347
1348        let registry = MockRegistryWithVersions {
1349            versions: vec![
1350                MockVersionWithAge {
1351                    version: "1.2.3".into(),
1352                    yanked: false,
1353                    published_at: None,
1354                },
1355                MockVersionWithAge {
1356                    version: "1.2.1".into(),
1357                    yanked: true,
1358                    // ~5 months ago.
1359                    published_at: Some(PublishTime::from_unix_secs(
1360                        PublishTime::now().as_unix_secs() - 5 * 30 * 24 * 60 * 60,
1361                    )),
1362                },
1363            ],
1364        };
1365        let parse_result = freshness_test_parse_result("serde");
1366
1367        let hover = generate_hover(
1368            &parse_result,
1369            Position::new(0, 2),
1370            VersionData::new(&HashMap::new(), &HashMap::new()),
1371            &registry,
1372            &MockFormatter,
1373            crate::freshness::FreshnessSettings::default(),
1374            PublishTime::now(),
1375        )
1376        .await
1377        .expect("hover should be generated for a dependency at the cursor");
1378
1379        let HoverContents::Markup(content) = hover.contents else {
1380            panic!("expected markup hover contents");
1381        };
1382        assert!(
1383            content
1384                .value
1385                .contains("- `1.2.1` *(yanked)* — 5 months ago"),
1386            "got: {}",
1387            content.value
1388        );
1389    }
1390
1391    #[tokio::test]
1392    async fn test_generate_hover_recent_versions_respects_freshness_disabled() {
1393        use std::collections::HashMap;
1394
1395        let registry = MockRegistryWithVersions {
1396            versions: vec![MockVersionWithAge {
1397                version: "1.2.3".into(),
1398                yanked: false,
1399                published_at: Some(PublishTime::from_unix_secs(
1400                    PublishTime::now().as_unix_secs() - 2 * 24 * 60 * 60,
1401                )),
1402            }],
1403        };
1404        let parse_result = freshness_test_parse_result("serde");
1405
1406        let hover = generate_hover(
1407            &parse_result,
1408            Position::new(0, 2),
1409            VersionData::new(&HashMap::new(), &HashMap::new()),
1410            &registry,
1411            &MockFormatter,
1412            crate::freshness::FreshnessSettings {
1413                enabled: false,
1414                cooldown_secs: crate::freshness::DEFAULT_COOLDOWN_SECS,
1415            },
1416            PublishTime::now(),
1417        )
1418        .await
1419        .expect("hover should be generated for a dependency at the cursor");
1420
1421        let HoverContents::Markup(content) = hover.contents else {
1422            panic!("expected markup hover contents");
1423        };
1424        assert!(content.value.contains("- `1.2.3` *(latest)*\n"));
1425        assert!(!content.value.contains("ago"));
1426    }
1427
1428    /// Issue #227 §4.2a: the `**Latest**` line gets a publish-age suffix and, within the
1429    /// cooldown window, the "Recently published" callout.
1430    #[tokio::test]
1431    async fn test_generate_hover_latest_line_shows_age_and_cooldown_callout_when_within_cooldown() {
1432        use std::collections::HashMap;
1433
1434        let parse_result = freshness_test_parse_result("serde");
1435        let mut cached_versions = HashMap::new();
1436        cached_versions.insert(
1437            "serde".into(),
1438            PackageVersions {
1439                latest: "2.0.0".into(),
1440                available: Arc::from(vec!["2.0.0".into()]),
1441                yanked: Arc::from(Vec::new()),
1442                // 1 hour ago — well within the default 3-day cooldown.
1443                published_at: Some(PublishTime::from_unix_secs(
1444                    PublishTime::now().as_unix_secs() - 60 * 60,
1445                )),
1446            },
1447        );
1448        let resolved_versions = HashMap::new();
1449
1450        let hover = generate_hover(
1451            &parse_result,
1452            Position::new(0, 2),
1453            VersionData::new(&cached_versions, &resolved_versions),
1454            &MockRegistry,
1455            &MockFormatter,
1456            crate::freshness::FreshnessSettings::default(),
1457            PublishTime::now(),
1458        )
1459        .await
1460        .expect("hover should be generated for a dependency at the cursor");
1461
1462        let HoverContents::Markup(content) = hover.contents else {
1463            panic!("expected markup hover contents");
1464        };
1465        assert!(
1466            content
1467                .value
1468                .contains("**Latest**: `2.0.0` *(published 1 hour ago)*"),
1469            "got: {}",
1470            content.value
1471        );
1472        assert!(
1473            content.value.contains(
1474                "> ⏳ **Recently published** — this release is still within the cooldown window."
1475            ),
1476            "got: {}",
1477            content.value
1478        );
1479    }
1480
1481    /// Same setup, but `latest` was published well outside the cooldown window — the age
1482    /// suffix still renders, but the callout must not.
1483    #[tokio::test]
1484    async fn test_generate_hover_latest_line_no_callout_when_outside_cooldown() {
1485        use std::collections::HashMap;
1486
1487        let parse_result = freshness_test_parse_result("serde");
1488        let mut cached_versions = HashMap::new();
1489        cached_versions.insert(
1490            "serde".into(),
1491            PackageVersions {
1492                latest: "2.0.0".into(),
1493                available: Arc::from(vec!["2.0.0".into()]),
1494                yanked: Arc::from(Vec::new()),
1495                // 10 days ago — outside the default 3-day cooldown.
1496                published_at: Some(PublishTime::from_unix_secs(
1497                    PublishTime::now().as_unix_secs() - 10 * 24 * 60 * 60,
1498                )),
1499            },
1500        );
1501        let resolved_versions = HashMap::new();
1502
1503        let hover = generate_hover(
1504            &parse_result,
1505            Position::new(0, 2),
1506            VersionData::new(&cached_versions, &resolved_versions),
1507            &MockRegistry,
1508            &MockFormatter,
1509            crate::freshness::FreshnessSettings::default(),
1510            PublishTime::now(),
1511        )
1512        .await
1513        .expect("hover should be generated for a dependency at the cursor");
1514
1515        let HoverContents::Markup(content) = hover.contents else {
1516            panic!("expected markup hover contents");
1517        };
1518        assert!(
1519            content
1520                .value
1521                .contains("**Latest**: `2.0.0` *(published 1 week ago)*"),
1522            "got: {}",
1523            content.value
1524        );
1525        assert!(!content.value.contains("Recently published"));
1526    }
1527
1528    /// A `latest` with no known publish time renders exactly the pre-feature line — no age
1529    /// suffix, no callout.
1530    #[tokio::test]
1531    async fn test_generate_hover_latest_line_omits_age_when_published_at_unknown() {
1532        use std::collections::HashMap;
1533
1534        let parse_result = freshness_test_parse_result("serde");
1535        let mut cached_versions = HashMap::new();
1536        cached_versions.insert("serde".into(), PackageVersions::latest_only("2.0.0"));
1537        let resolved_versions = HashMap::new();
1538
1539        let hover = generate_hover(
1540            &parse_result,
1541            Position::new(0, 2),
1542            VersionData::new(&cached_versions, &resolved_versions),
1543            &MockRegistry,
1544            &MockFormatter,
1545            crate::freshness::FreshnessSettings::default(),
1546            PublishTime::now(),
1547        )
1548        .await
1549        .expect("hover should be generated for a dependency at the cursor");
1550
1551        let HoverContents::Markup(content) = hover.contents else {
1552            panic!("expected markup hover contents");
1553        };
1554        assert!(content.value.contains("**Latest**: `2.0.0`\n\n"));
1555        assert!(!content.value.contains("published"));
1556        assert!(!content.value.contains("Recently published"));
1557    }
1558
1559    /// `freshness.enabled: false` suppresses both the age suffix and the cooldown callout
1560    /// on the `**Latest**` line, even when the publish time would otherwise qualify.
1561    #[tokio::test]
1562    async fn test_generate_hover_latest_line_respects_freshness_disabled() {
1563        use std::collections::HashMap;
1564
1565        let parse_result = freshness_test_parse_result("serde");
1566        let mut cached_versions = HashMap::new();
1567        cached_versions.insert(
1568            "serde".into(),
1569            PackageVersions {
1570                latest: "2.0.0".into(),
1571                available: Arc::from(vec!["2.0.0".into()]),
1572                yanked: Arc::from(Vec::new()),
1573                published_at: Some(PublishTime::from_unix_secs(
1574                    PublishTime::now().as_unix_secs() - 60 * 60,
1575                )),
1576            },
1577        );
1578        let resolved_versions = HashMap::new();
1579
1580        let hover = generate_hover(
1581            &parse_result,
1582            Position::new(0, 2),
1583            VersionData::new(&cached_versions, &resolved_versions),
1584            &MockRegistry,
1585            &MockFormatter,
1586            crate::freshness::FreshnessSettings {
1587                enabled: false,
1588                cooldown_secs: crate::freshness::DEFAULT_COOLDOWN_SECS,
1589            },
1590            PublishTime::now(),
1591        )
1592        .await
1593        .expect("hover should be generated for a dependency at the cursor");
1594
1595        let HoverContents::Markup(content) = hover.contents else {
1596            panic!("expected markup hover contents");
1597        };
1598        assert!(content.value.contains("**Latest**: `2.0.0`\n\n"));
1599        assert!(!content.value.contains("published"));
1600        assert!(!content.value.contains("Recently published"));
1601    }
1602
1603    /// Deterministic boundary test (issue #227 M4): `now` is threaded in as a parameter
1604    /// rather than read internally, so `published_at`/`now`/`cooldown_secs` can be pinned
1605    /// to fixed absolute values with no wall-clock dependency. `age == cooldown_secs`
1606    /// exactly must NOT be within cooldown — the bound is exclusive (`age < cooldown`).
1607    #[tokio::test]
1608    async fn test_generate_hover_latest_line_cooldown_boundary_is_exclusive() {
1609        use std::collections::HashMap;
1610
1611        const COOLDOWN_SECS: u64 = 100;
1612        let now = PublishTime::from_unix_secs(10_000);
1613        let published_at_at_boundary =
1614            PublishTime::from_unix_secs(10_000 - COOLDOWN_SECS.cast_signed());
1615
1616        let parse_result = freshness_test_parse_result("serde");
1617        let mut cached_versions = HashMap::new();
1618        cached_versions.insert(
1619            "serde".into(),
1620            PackageVersions {
1621                latest: "2.0.0".into(),
1622                available: Arc::from(vec!["2.0.0".into()]),
1623                yanked: Arc::from(Vec::new()),
1624                published_at: Some(published_at_at_boundary),
1625            },
1626        );
1627        let resolved_versions = HashMap::new();
1628
1629        let hover = generate_hover(
1630            &parse_result,
1631            Position::new(0, 2),
1632            VersionData::new(&cached_versions, &resolved_versions),
1633            &MockRegistry,
1634            &MockFormatter,
1635            crate::freshness::FreshnessSettings {
1636                enabled: true,
1637                cooldown_secs: COOLDOWN_SECS,
1638            },
1639            now,
1640        )
1641        .await
1642        .expect("hover should be generated for a dependency at the cursor");
1643
1644        let HoverContents::Markup(content) = hover.contents else {
1645            panic!("expected markup hover contents");
1646        };
1647        assert!(
1648            !content.value.contains("Recently published"),
1649            "age exactly equal to cooldown_secs must not be within cooldown, got: {}",
1650            content.value
1651        );
1652    }
1653
1654    /// Same fixture, one second younger — must flip to within cooldown.
1655    #[tokio::test]
1656    async fn test_generate_hover_latest_line_cooldown_boundary_one_second_inside_shows_callout() {
1657        use std::collections::HashMap;
1658
1659        const COOLDOWN_SECS: u64 = 100;
1660        let now = PublishTime::from_unix_secs(10_000);
1661        let published_at_just_inside =
1662            PublishTime::from_unix_secs(10_000 - (COOLDOWN_SECS.cast_signed() - 1));
1663
1664        let parse_result = freshness_test_parse_result("serde");
1665        let mut cached_versions = HashMap::new();
1666        cached_versions.insert(
1667            "serde".into(),
1668            PackageVersions {
1669                latest: "2.0.0".into(),
1670                available: Arc::from(vec!["2.0.0".into()]),
1671                yanked: Arc::from(Vec::new()),
1672                published_at: Some(published_at_just_inside),
1673            },
1674        );
1675        let resolved_versions = HashMap::new();
1676
1677        let hover = generate_hover(
1678            &parse_result,
1679            Position::new(0, 2),
1680            VersionData::new(&cached_versions, &resolved_versions),
1681            &MockRegistry,
1682            &MockFormatter,
1683            crate::freshness::FreshnessSettings {
1684                enabled: true,
1685                cooldown_secs: COOLDOWN_SECS,
1686            },
1687            now,
1688        )
1689        .await
1690        .expect("hover should be generated for a dependency at the cursor");
1691
1692        let HoverContents::Markup(content) = hover.contents else {
1693            panic!("expected markup hover contents");
1694        };
1695        assert!(
1696            content.value.contains("Recently published"),
1697            "age == cooldown_secs - 1 must be within cooldown, got: {}",
1698            content.value
1699        );
1700    }
1701
1702    /// Issue #227 F5: the Ch1 cache (`versions.cached`, populated by the lifecycle's
1703    /// background fetch) can go stale relative to the live Ch2 fetch this same hover call
1704    /// just made (`registry.get_versions_with`) — e.g. a new version published between the
1705    /// last background fetch and now. Before this fix, the `**Latest**` line and cooldown
1706    /// callout read Ch1 alone, so hover could render a self-contradictory response: an
1707    /// older `**Latest**` line sitting above a "Recent versions" list whose own `*(latest)*`
1708    /// entry is a newer version. The line must prefer the live entry instead.
1709    #[tokio::test]
1710    async fn test_generate_hover_latest_line_prefers_live_fetch_over_stale_ch1_cache() {
1711        use std::collections::HashMap;
1712
1713        let now = PublishTime::now();
1714        let live_latest_published = PublishTime::from_unix_secs(now.as_unix_secs() - 60 * 60);
1715        let registry = MockRegistryWithVersions {
1716            versions: vec![
1717                MockVersionWithAge {
1718                    version: "1.0.214".into(),
1719                    yanked: false,
1720                    published_at: Some(live_latest_published),
1721                },
1722                MockVersionWithAge {
1723                    version: "1.0.213".into(),
1724                    yanked: false,
1725                    published_at: Some(PublishTime::from_unix_secs(
1726                        now.as_unix_secs() - 30 * 24 * 60 * 60,
1727                    )),
1728                },
1729            ],
1730        };
1731
1732        let parse_result = freshness_test_parse_result("serde");
1733        let mut cached_versions = HashMap::new();
1734        // Stale Ch1 entry: an older version, with an even older publish time, standing in
1735        // for a background fetch that ran before 1.0.214 was published.
1736        cached_versions.insert(
1737            "serde".into(),
1738            PackageVersions {
1739                latest: "1.0.213".into(),
1740                available: Arc::from(vec!["1.0.213".into()]),
1741                yanked: Arc::from(Vec::new()),
1742                published_at: Some(PublishTime::from_unix_secs(
1743                    now.as_unix_secs() - 90 * 24 * 60 * 60,
1744                )),
1745            },
1746        );
1747        let resolved_versions = HashMap::new();
1748
1749        let hover = generate_hover(
1750            &parse_result,
1751            Position::new(0, 2),
1752            VersionData::new(&cached_versions, &resolved_versions),
1753            &registry,
1754            &MockFormatter,
1755            crate::freshness::FreshnessSettings::default(),
1756            now,
1757        )
1758        .await
1759        .expect("hover should be generated for a dependency at the cursor");
1760
1761        let HoverContents::Markup(content) = hover.contents else {
1762            panic!("expected markup hover contents");
1763        };
1764        assert!(
1765            content
1766                .value
1767                .contains("**Latest**: `1.0.214` *(published 1 hour ago)*"),
1768            "Latest line must reflect the live Ch2 fetch, not the stale Ch1 cache entry \
1769             `1.0.213`, got: {}",
1770            content.value
1771        );
1772        assert!(
1773            !content.value.contains("**Latest**: `1.0.213`"),
1774            "must not render the stale Ch1 version, got: {}",
1775            content.value
1776        );
1777        assert!(
1778            content
1779                .value
1780                .contains("- `1.0.214` *(latest)* — 1 hour ago"),
1781            "the Recent versions list's own *(latest)* entry must agree with the Latest \
1782             line above it, got: {}",
1783            content.value
1784        );
1785    }
1786
1787    /// #347/#348 S1: npm-shaped package `[2.0.0 AdvisoryDeprecated, 1.9.0 Available]`.
1788    /// `is_stable()` accepts `AdvisoryDeprecated`, so a naive `is_stable()`-based scan for
1789    /// "latest" picks `2.0.0` — disagreeing with npm's own `select_latest_matching`, which
1790    /// deliberately ranks a non-deprecated version ahead of a deprecated one (#338 NFR-002)
1791    /// and would resolve `1.9.0` instead (this is exactly what `lifecycle.rs` caches and
1792    /// diagnostics read). Hover must delegate to the registry's `select_latest_matching`
1793    /// instead of re-deriving the pick, so it agrees with that cached value, and the
1794    /// resolved `2.0.0`-would-be-latest case below must still carry its deprecated label
1795    /// when a deprecated version *is* the resolved latest.
1796    #[tokio::test]
1797    async fn test_generate_hover_latest_agrees_with_npm_shaped_deprecated_ranking() {
1798        use std::collections::HashMap;
1799
1800        let now = PublishTime::now();
1801        let registry = MockRegistryPreferringUnflagged {
1802            versions: vec![
1803                MockVersionWithStatus {
1804                    version: "2.0.0".into(),
1805                    status: RemovalStatus::AdvisoryDeprecated,
1806                },
1807                MockVersionWithStatus {
1808                    version: "1.9.0".into(),
1809                    status: RemovalStatus::Available,
1810                },
1811            ],
1812        };
1813
1814        let parse_result = freshness_test_parse_result("pkg");
1815        let cached_versions = HashMap::new();
1816        let resolved_versions = HashMap::new();
1817
1818        let hover = generate_hover(
1819            &parse_result,
1820            Position::new(0, 2),
1821            VersionData::new(&cached_versions, &resolved_versions),
1822            &registry,
1823            &MockFormatter,
1824            crate::freshness::FreshnessSettings::default(),
1825            now,
1826        )
1827        .await
1828        .expect("hover should be generated for a dependency at the cursor");
1829
1830        let HoverContents::Markup(content) = hover.contents else {
1831            panic!("expected markup hover contents");
1832        };
1833        assert!(
1834            content.value.contains("**Latest**: `1.9.0`"),
1835            "hover must agree with select_latest_matching's non-deprecated-preferred pick \
1836             (1.9.0), not a naive is_stable() scan that would pick the newer but deprecated \
1837             2.0.0, got: {}",
1838            content.value
1839        );
1840        assert!(
1841            content.value.contains("- `1.9.0` *(latest)*"),
1842            "the Recent versions list's own *(latest)* marker must agree with the Latest \
1843             line above it, got: {}",
1844            content.value
1845        );
1846        assert!(
1847            content.value.contains("- `2.0.0` *(yanked)*"),
1848            "2.0.0 must keep its flagged label even though it isn't the resolved latest, \
1849             got: {}",
1850            content.value
1851        );
1852    }
1853
1854    /// #347/#348 S1: when every version is flagged, the registry's own ranking (rung 2 of
1855    /// `MockRegistryPreferringUnflagged`) can still resolve a flagged version as "latest" —
1856    /// hover must keep that entry's deprecated/yanked label instead of letting `*(latest)*`
1857    /// silently replace it (issue #227-F5/#313's self-contradiction class).
1858    #[tokio::test]
1859    async fn test_generate_hover_latest_keeps_flagged_label_when_resolved_version_is_flagged() {
1860        use std::collections::HashMap;
1861
1862        let now = PublishTime::now();
1863        let registry = MockRegistryPreferringUnflagged {
1864            versions: vec![MockVersionWithStatus {
1865                version: "2.0.0".into(),
1866                status: RemovalStatus::AdvisoryDeprecated,
1867            }],
1868        };
1869
1870        let parse_result = freshness_test_parse_result("pkg");
1871        let cached_versions = HashMap::new();
1872        let resolved_versions = HashMap::new();
1873
1874        let hover = generate_hover(
1875            &parse_result,
1876            Position::new(0, 2),
1877            VersionData::new(&cached_versions, &resolved_versions),
1878            &registry,
1879            &MockFormatter,
1880            crate::freshness::FreshnessSettings::default(),
1881            now,
1882        )
1883        .await
1884        .expect("hover should be generated for a dependency at the cursor");
1885
1886        let HoverContents::Markup(content) = hover.contents else {
1887            panic!("expected markup hover contents");
1888        };
1889        assert!(
1890            content.value.contains("**Latest**: `2.0.0`"),
1891            "the only version resolves as latest even though it's flagged, got: {}",
1892            content.value
1893        );
1894        assert!(
1895            content.value.contains("- `2.0.0` *(latest)* *(yanked)*"),
1896            "the resolved latest must keep its flagged label instead of the warning \
1897             silently vanishing behind *(latest)*, got: {}",
1898            content.value
1899        );
1900    }
1901
1902    /// #364 rung 3: an *all-yanked* package (Cargo/PyPI/Dart-shaped —
1903    /// `RemovalStatus::Yanked` blocks resolution, unlike npm's `AdvisoryDeprecated`) must
1904    /// still resolve a "latest" via [`crate::select_latest_for_existence`]'s unconditional
1905    /// last rung, instead of hover rendering no `**Latest**` line at all (the pre-#364
1906    /// `None` behavior that read as a false "Unknown package").
1907    #[tokio::test]
1908    async fn test_generate_hover_latest_resolves_when_all_versions_yanked() {
1909        use std::collections::HashMap;
1910
1911        let now = PublishTime::now();
1912        let registry = MockRegistryPreferringUnflagged {
1913            versions: vec![
1914                MockVersionWithStatus {
1915                    version: "2.0.0".into(),
1916                    status: RemovalStatus::Yanked,
1917                },
1918                MockVersionWithStatus {
1919                    version: "1.9.0".into(),
1920                    status: RemovalStatus::Yanked,
1921                },
1922            ],
1923        };
1924
1925        let parse_result = freshness_test_parse_result("pkg");
1926        let cached_versions = HashMap::new();
1927        let resolved_versions = HashMap::new();
1928
1929        let hover = generate_hover(
1930            &parse_result,
1931            Position::new(0, 2),
1932            VersionData::new(&cached_versions, &resolved_versions),
1933            &registry,
1934            &MockFormatter,
1935            crate::freshness::FreshnessSettings::default(),
1936            now,
1937        )
1938        .await
1939        .expect("hover should be generated for a dependency at the cursor");
1940
1941        let HoverContents::Markup(content) = hover.contents else {
1942            panic!("expected markup hover contents");
1943        };
1944        assert!(
1945            content.value.contains("**Latest**: `2.0.0`"),
1946            "an all-yanked package still exists: hover must resolve the newest yanked \
1947             version as latest rather than showing no Latest line, got: {}",
1948            content.value
1949        );
1950        assert!(
1951            content.value.contains("- `2.0.0` *(latest)* *(yanked)*"),
1952            "the resolved latest must keep its yanked label, got: {}",
1953            content.value
1954        );
1955    }
1956
1957    /// npm-shaped formatter stub for T7: overrides `yanked_label` to npm's actual
1958    /// `"*(deprecated)*"` wording (`deps-npm/src/formatter.rs`), everything else default.
1959    struct NpmLikeFormatter;
1960
1961    impl PackageNaming for NpmLikeFormatter {}
1962
1963    impl PackageRendering for NpmLikeFormatter {
1964        fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
1965            version.to_string()
1966        }
1967
1968        fn package_url(&self, name: &crate::PackageName) -> String {
1969            format!("https://example.com/{name}")
1970        }
1971    }
1972
1973    impl RequirementResolution for NpmLikeFormatter {}
1974
1975    impl DiagnosticMessages for NpmLikeFormatter {
1976        fn yanked_label(&self) -> &'static str {
1977            "*(deprecated)*"
1978        }
1979    }
1980
1981    impl DiagnosticPolicy for NpmLikeFormatter {}
1982
1983    impl SourcePolicy for NpmLikeFormatter {}
1984
1985    impl OsvNaming for NpmLikeFormatter {}
1986
1987    /// T7 (S4, accepted redundancy): hover for a deprecated npm-shaped package renders
1988    /// **both** the new `### Deprecated` section (D6) and the pre-existing per-row
1989    /// `*(deprecated)*` "Recent versions" labels — pinning the deliberate decision not to
1990    /// dedupe them (plan.md D6), so a later dedupe reads as an intentional change rather
1991    /// than a silent regression.
1992    #[tokio::test]
1993    async fn test_generate_hover_deprecated_section_and_per_row_labels_both_render() {
1994        use std::collections::HashMap;
1995
1996        let now = PublishTime::now();
1997        let registry = MockRegistryPreferringUnflagged {
1998            versions: vec![MockVersionWithStatus {
1999                version: "1.0.0".into(),
2000                status: RemovalStatus::AdvisoryDeprecated,
2001            }],
2002        };
2003
2004        let parse_result = freshness_test_parse_result("pkg");
2005        let cached_versions = HashMap::new();
2006        let resolved_versions = HashMap::new();
2007        let outcomes = crate::lsp_helpers::DependencyOutcomes::new().with_deprecation(
2008            "pkg",
2009            crate::Deprecation {
2010                reason: Some("no longer maintained".to_string()),
2011                replacement: None,
2012            },
2013        );
2014
2015        let hover = generate_hover(
2016            &parse_result,
2017            Position::new(0, 2),
2018            VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
2019            &registry,
2020            &NpmLikeFormatter,
2021            crate::freshness::FreshnessSettings::default(),
2022            now,
2023        )
2024        .await
2025        .expect("hover should be generated for a dependency at the cursor");
2026
2027        let HoverContents::Markup(content) = hover.contents else {
2028            panic!("expected markup hover contents");
2029        };
2030        assert!(
2031            content.value.contains("### Deprecated"),
2032            "expected the package-level Deprecated section, got: {}",
2033            content.value
2034        );
2035        assert!(
2036            content.value.contains("no longer maintained"),
2037            "expected the deprecation reason, got: {}",
2038            content.value
2039        );
2040        // I3: the message and the reason must render as separate CommonMark paragraphs
2041        // (blank-line separated), not collapse into one joined paragraph.
2042        assert!(
2043            content
2044                .value
2045                .contains("This package is deprecated\n\nno longer maintained"),
2046            "expected the message and reason on separate paragraphs, got: {}",
2047            content.value
2048        );
2049        assert!(
2050            content
2051                .value
2052                .contains("- `1.0.0` *(latest)* *(deprecated)*"),
2053            "expected the pre-existing per-row label to still render alongside the new \
2054             section (deliberately not deduped, S4), got: {}",
2055            content.value
2056        );
2057    }
2058
2059    #[tokio::test]
2060    async fn test_generate_hover_surfaces_markers() {
2061        use std::collections::HashMap;
2062        use tower_lsp_server::ls_types::{Position, Range};
2063
2064        let parse_result = MockMarkedParseResult {
2065            dep: MockMarkedDep {
2066                name: "numpy".into(),
2067                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2068                markers: Some("python_full_version >= '3.9'".to_string()),
2069            },
2070            uri: crate::test_util::test_uri("/test/pyproject.toml"),
2071        };
2072
2073        let hover = generate_hover(
2074            &parse_result,
2075            Position::new(0, 2),
2076            VersionData::new(&HashMap::new(), &HashMap::new()),
2077            &MockRegistry,
2078            &MockFormatter,
2079            crate::freshness::FreshnessSettings::default(),
2080            PublishTime::now(),
2081        )
2082        .await
2083        .expect("hover should be generated for a dependency at the cursor");
2084
2085        let HoverContents::Markup(content) = hover.contents else {
2086            panic!("expected markup hover contents");
2087        };
2088        assert!(
2089            content
2090                .value
2091                .contains("**Active when**: `python_full_version >= '3.9'`")
2092        );
2093    }
2094
2095    #[tokio::test]
2096    async fn test_generate_hover_omits_active_when_without_markers() {
2097        use std::collections::HashMap;
2098        use tower_lsp_server::ls_types::{Position, Range};
2099
2100        let parse_result = MockMarkedParseResult {
2101            dep: MockMarkedDep {
2102                name: "requests".into(),
2103                name_range: Range::new(Position::new(0, 0), Position::new(0, 8)),
2104                markers: None,
2105            },
2106            uri: crate::test_util::test_uri("/test/pyproject.toml"),
2107        };
2108
2109        let hover = generate_hover(
2110            &parse_result,
2111            Position::new(0, 2),
2112            VersionData::new(&HashMap::new(), &HashMap::new()),
2113            &MockRegistry,
2114            &MockFormatter,
2115            crate::freshness::FreshnessSettings::default(),
2116            PublishTime::now(),
2117        )
2118        .await
2119        .expect("hover should be generated for a dependency at the cursor");
2120
2121        let HoverContents::Markup(content) = hover.contents else {
2122            panic!("expected markup hover contents");
2123        };
2124        assert!(!content.value.contains("Active when"));
2125    }
2126
2127    #[tokio::test]
2128    async fn test_generate_hover_escapes_malicious_dependency_name() {
2129        use std::collections::HashMap;
2130        use tower_lsp_server::ls_types::{Position, Range};
2131
2132        let malicious_name = "real-pkg](https://legit-looking-typosquat.example/download)[real-pkg";
2133
2134        let parse_result = MockParseResult {
2135            deps: vec![MockDep {
2136                name: malicious_name.into(),
2137                version_req: "1.0.0".into(),
2138                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2139                name_range: Range::new(
2140                    Position::new(0, 0),
2141                    Position::new(0, malicious_name.len() as u32),
2142                ),
2143            }],
2144            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2145        };
2146
2147        let hover = generate_hover(
2148            &parse_result,
2149            Position::new(0, 2),
2150            VersionData::new(&HashMap::new(), &HashMap::new()),
2151            &MockRegistry,
2152            &MockFormatter,
2153            crate::freshness::FreshnessSettings::default(),
2154            PublishTime::now(),
2155        )
2156        .await
2157        .expect("hover should be generated for a dependency at the cursor");
2158
2159        let HoverContents::Markup(content) = hover.contents else {
2160            panic!("expected markup hover contents");
2161        };
2162
2163        // The link label (between the H1's "# [" and the "](") must be the fully
2164        // escaped name, with no raw "](" sequence that could close the label early
2165        // and splice in an attacker-controlled markdown link.
2166        let header_line = content
2167            .value
2168            .lines()
2169            .next()
2170            .expect("hover markdown has a header line");
2171        let label = header_line
2172            .strip_prefix("# [")
2173            .expect("header starts with link label")
2174            .split("](")
2175            .next()
2176            .expect("header contains label/url separator");
2177        assert_eq!(
2178            label,
2179            r"real\-pkg\]\(https\:\/\/legit\-looking\-typosquat\.example\/download\)\[real\-pkg"
2180        );
2181    }
2182
2183    #[tokio::test]
2184    async fn test_generate_hover_newline_in_name_cannot_forge_new_heading() {
2185        use std::collections::HashMap;
2186        use tower_lsp_server::ls_types::{Position, Range};
2187
2188        // Combines S1 (newline breaks out of the ATX heading line) with an
2189        // autolink payload that needs no brackets/parens at all.
2190        let malicious_name = "react\n# [fake](https://evil.example) <https://evil.example>";
2191
2192        let parse_result = MockParseResult {
2193            deps: vec![MockDep {
2194                name: malicious_name.into(),
2195                version_req: "1.0.0".into(),
2196                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2197                name_range: Range::new(
2198                    Position::new(0, 0),
2199                    Position::new(0, malicious_name.len() as u32),
2200                ),
2201            }],
2202            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2203        };
2204
2205        let hover = generate_hover(
2206            &parse_result,
2207            Position::new(0, 2),
2208            VersionData::new(&HashMap::new(), &HashMap::new()),
2209            &MockRegistry,
2210            &MockFormatter,
2211            crate::freshness::FreshnessSettings::default(),
2212            PublishTime::now(),
2213        )
2214        .await
2215        .expect("hover should be generated for a dependency at the cursor");
2216
2217        let HoverContents::Markup(content) = hover.contents else {
2218            panic!("expected markup hover contents");
2219        };
2220
2221        // The link label must be the exact single-line escaped name: no raw
2222        // newline breaking the ATX heading, and the autolink's `<`/`>` escaped so
2223        // it cannot render as a live link independent of the `[]`/`()` escaping.
2224        let header_line = content
2225            .value
2226            .lines()
2227            .next()
2228            .expect("hover markdown has a header line");
2229        let label = header_line
2230            .strip_prefix("# [")
2231            .expect("header starts with link label")
2232            .split("](")
2233            .next()
2234            .expect("header contains label/url separator");
2235        assert_eq!(label, escape_markdown(malicious_name));
2236        assert!(!label.contains('\n'));
2237        assert!(label.contains(r"\<https"));
2238    }
2239
2240    #[tokio::test]
2241    async fn test_generate_hover_marker_with_parens_renders_unescaped() {
2242        use std::collections::HashMap;
2243        use tower_lsp_server::ls_types::{Position, Range};
2244
2245        // Regression guard (M4): a legitimate PEP 508 marker with parentheses must
2246        // render as-is inside its code span, not with visible `\(`/`\)` escapes —
2247        // backslash-escaping does not apply inside code spans.
2248        let marker = "python_version >= \"3.8\" and (sys_platform == \"linux\")";
2249        let parse_result = MockMarkedParseResult {
2250            dep: MockMarkedDep {
2251                name: "numpy".into(),
2252                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2253                markers: Some(marker.to_string()),
2254            },
2255            uri: crate::test_util::test_uri("/test/pyproject.toml"),
2256        };
2257
2258        let hover = generate_hover(
2259            &parse_result,
2260            Position::new(0, 2),
2261            VersionData::new(&HashMap::new(), &HashMap::new()),
2262            &MockRegistry,
2263            &MockFormatter,
2264            crate::freshness::FreshnessSettings::default(),
2265            PublishTime::now(),
2266        )
2267        .await
2268        .expect("hover should be generated for a dependency at the cursor");
2269
2270        let HoverContents::Markup(content) = hover.contents else {
2271            panic!("expected markup hover contents");
2272        };
2273        assert!(
2274            content
2275                .value
2276                .contains(&format!("**Active when**: `{marker}`"))
2277        );
2278    }
2279
2280    #[tokio::test]
2281    async fn test_generate_hover_registry_sections_suppressed_for_non_registry_sources() {
2282        use crate::parser::DependencySource;
2283
2284        let registry = MockRegistryWithVersions {
2285            versions: vec![MockVersionWithAge {
2286                version: "9.9.9".into(),
2287                yanked: false,
2288                published_at: None,
2289            }],
2290        };
2291        let cached_versions = {
2292            let mut m = HashMap::new();
2293            m.insert("dep".into(), PackageVersions::latest_only("9.9.9"));
2294            m
2295        };
2296        let resolved_versions = HashMap::new();
2297        let uri = crate::test_util::test_uri("/test/Cargo.toml");
2298
2299        let parse_result = SingleDepParseResult {
2300            dep: NonRegistryDep(
2301                dep_at("dep"),
2302                DependencySource::CustomRegistry {
2303                    url: "my-corp".into(),
2304                },
2305            ),
2306            uri: uri.clone(),
2307        };
2308
2309        let hover = generate_hover(
2310            &parse_result,
2311            Position::new(0, 2),
2312            VersionData::new(&cached_versions, &resolved_versions),
2313            &registry,
2314            &MockFormatter,
2315            crate::freshness::FreshnessSettings::default(),
2316            PublishTime::now(),
2317        )
2318        .await
2319        .expect("hover should still be generated for a non-resolvable-source dependency");
2320
2321        let HoverContents::Markup(content) = hover.contents else {
2322            panic!("expected markup hover contents");
2323        };
2324        assert!(!content.value.contains("**Latest**"));
2325        assert!(!content.value.contains("**Recent versions**"));
2326        assert!(content.value.contains("**Requirement**"));
2327
2328        // Control: the same fixture on a Registry-source dependency DOES show
2329        // both registry-derived sections, proving the fixture isn't vacuous.
2330        let registry_parse_result = SingleDepParseResult {
2331            dep: dep_at("dep"),
2332            uri,
2333        };
2334        let hover = generate_hover(
2335            &registry_parse_result,
2336            Position::new(0, 2),
2337            VersionData::new(&cached_versions, &resolved_versions),
2338            &registry,
2339            &MockFormatter,
2340            crate::freshness::FreshnessSettings::default(),
2341            PublishTime::now(),
2342        )
2343        .await
2344        .expect("hover should be generated");
2345        let HoverContents::Markup(content) = hover.contents else {
2346            panic!("expected markup hover contents");
2347        };
2348        assert!(content.value.contains("**Latest**"));
2349        assert!(content.value.contains("**Recent versions**"));
2350    }
2351
2352    #[tokio::test]
2353    async fn test_generate_hover_clean_outcome_states_no_known_vulnerabilities() {
2354        use crate::osv::{ScanOutcome, VulnerabilityMap};
2355
2356        let parse_result = MockParseResult {
2357            deps: vec![dep_at("clean-pkg")],
2358            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2359        };
2360        let cached_versions = HashMap::new();
2361        let resolved_versions = HashMap::new();
2362
2363        let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
2364        vulns.insert("clean-pkg".to_string(), ScanOutcome::Clean);
2365
2366        let hover = generate_hover(
2367            &parse_result,
2368            Position::new(0, 2),
2369            VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
2370            &MockRegistry,
2371            &MockFormatter,
2372            crate::FreshnessSettings::default(),
2373            PublishTime::now(),
2374        )
2375        .await
2376        .expect("hover should be generated");
2377
2378        let HoverContents::Markup(content) = hover.contents else {
2379            panic!("expected markup hover contents");
2380        };
2381        assert!(content.value.contains("No known vulnerabilities"));
2382    }
2383
2384    #[tokio::test]
2385    async fn test_generate_hover_skipped_outcome_says_nothing_about_vulnerabilities() {
2386        use crate::osv::{ScanOutcome, SkipReason, VulnerabilityMap};
2387
2388        let parse_result = MockParseResult {
2389            deps: vec![dep_at("path-pkg")],
2390            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2391        };
2392        let cached_versions = HashMap::new();
2393        let resolved_versions = HashMap::new();
2394
2395        let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
2396        vulns.insert(
2397            "path-pkg".to_string(),
2398            ScanOutcome::Skipped(SkipReason::NonRegistrySource),
2399        );
2400
2401        let hover = generate_hover(
2402            &parse_result,
2403            Position::new(0, 2),
2404            VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
2405            &MockRegistry,
2406            &MockFormatter,
2407            crate::FreshnessSettings::default(),
2408            PublishTime::now(),
2409        )
2410        .await
2411        .expect("hover should be generated");
2412
2413        let HoverContents::Markup(content) = hover.contents else {
2414            panic!("expected markup hover contents");
2415        };
2416        assert!(!content.value.contains("Security advisories"));
2417        assert!(!content.value.contains("No known vulnerabilities"));
2418    }
2419
2420    #[tokio::test]
2421    async fn test_generate_hover_vulnerable_outcome_shows_advisories_and_more_count() {
2422        use crate::osv::{
2423            Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity,
2424            VulnerabilityMap,
2425        };
2426
2427        let parse_result = MockParseResult {
2428            deps: vec![dep_at("bad-pkg")],
2429            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2430        };
2431        let cached_versions = HashMap::new();
2432        let resolved_versions = HashMap::new();
2433
2434        let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
2435        vulns.insert(
2436            "bad-pkg".to_string(),
2437            ScanOutcome::Vulnerable(DependencyVulnerabilities {
2438                advisories: Capped::new(
2439                    vec![sample_advisory("RUSTSEC-2020-0071", VulnSeverity::Critical)],
2440                    3,
2441                ),
2442                fix_target_status: UpgradeStatus::NotChecked,
2443                upgrade_status: UpgradeStatus::CandidateVulnerable {
2444                    version: "2.0.0".into(),
2445                    advisory_ids: Capped::new(vec!["RUSTSEC-2020-0071".to_string()], 1),
2446                },
2447            }),
2448        );
2449
2450        let hover = generate_hover(
2451            &parse_result,
2452            Position::new(0, 2),
2453            VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
2454            &MockRegistry,
2455            &MockFormatter,
2456            crate::FreshnessSettings::default(),
2457            PublishTime::now(),
2458        )
2459        .await
2460        .expect("hover should be generated");
2461
2462        let HoverContents::Markup(content) = hover.contents else {
2463            panic!("expected markup hover contents");
2464        };
2465        assert!(content.value.contains("Security advisories"));
2466        assert!(content.value.contains("RUSTSEC-2020-0071"));
2467        assert!(content.value.contains("Fixed in"));
2468        assert!(
2469            content.value.contains("1.5.0"),
2470            "must show highest fixed version"
2471        );
2472        assert!(content.value.contains("+2 more advisories"));
2473        assert!(content.value.contains("also affected"));
2474    }
2475
2476    #[tokio::test]
2477    async fn test_generate_hover_vulnerability_not_shared_across_duplicate_name_occurrences() {
2478        // #394 S2: `pkg` declared twice with different pins — one vulnerable,
2479        // one patched. Hover on the patched occurrence must not show the
2480        // vulnerable occurrence's advisory just because they share a name.
2481        use crate::osv::{
2482            Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity,
2483            VulnerabilityMap,
2484        };
2485
2486        let vulnerable_dep = MockDep {
2487            name: "pkg".into(),
2488            version_req: "=1.0.0".into(),
2489            version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2490            name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2491        };
2492        let patched_dep = MockDep {
2493            name: "pkg".into(),
2494            version_req: "=2.0.0".into(),
2495            version_range: Range::new(Position::new(3, 10), Position::new(3, 20)),
2496            name_range: Range::new(Position::new(3, 0), Position::new(3, 5)),
2497        };
2498        let parse_result = MockParseResult {
2499            deps: vec![vulnerable_dep, patched_dep],
2500            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2501        };
2502        let cached_versions = HashMap::new();
2503        let resolved_versions = HashMap::new();
2504
2505        let keys = crate::osv::vulnerability_keys(
2506            &parse_result,
2507            &resolved_versions,
2508            &MockFormatter,
2509            crate::EcosystemId::Cargo,
2510        );
2511        let deps = parse_result.dependencies();
2512        let vulnerable_key = keys.get(&deps[0].name_range()).unwrap().clone();
2513        let patched_key = keys.get(&deps[1].name_range()).unwrap().clone();
2514        assert_ne!(vulnerable_key, patched_key);
2515
2516        let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
2517        vulns.insert(
2518            vulnerable_key,
2519            ScanOutcome::Vulnerable(DependencyVulnerabilities {
2520                advisories: Capped::new(
2521                    vec![sample_advisory("RUSTSEC-2020-0071", VulnSeverity::Critical)],
2522                    1,
2523                ),
2524                fix_target_status: UpgradeStatus::NotChecked,
2525                upgrade_status: UpgradeStatus::NotChecked,
2526            }),
2527        );
2528        vulns.insert(patched_key, ScanOutcome::Clean);
2529
2530        let versions = VersionData::new(&cached_versions, &resolved_versions)
2531            .with_vulnerabilities(&vulns)
2532            .with_ecosystem(crate::EcosystemId::Cargo);
2533
2534        let hover_on_patched = generate_hover(
2535            &parse_result,
2536            Position::new(3, 2),
2537            versions,
2538            &MockRegistry,
2539            &MockFormatter,
2540            crate::FreshnessSettings::default(),
2541            PublishTime::now(),
2542        )
2543        .await
2544        .expect("hover should be generated");
2545        let HoverContents::Markup(patched_content) = hover_on_patched.contents else {
2546            panic!("expected markup hover contents");
2547        };
2548        assert!(
2549            !patched_content.value.contains("RUSTSEC-2020-0071"),
2550            "the patched occurrence must not show the other occurrence's advisory: {}",
2551            patched_content.value
2552        );
2553        assert!(patched_content.value.contains("No known vulnerabilities"));
2554
2555        let hover_on_vulnerable = generate_hover(
2556            &parse_result,
2557            Position::new(0, 2),
2558            versions,
2559            &MockRegistry,
2560            &MockFormatter,
2561            crate::FreshnessSettings::default(),
2562            PublishTime::now(),
2563        )
2564        .await
2565        .expect("hover should be generated");
2566        let HoverContents::Markup(vulnerable_content) = hover_on_vulnerable.contents else {
2567            panic!("expected markup hover contents");
2568        };
2569        assert!(vulnerable_content.value.contains("RUSTSEC-2020-0071"));
2570    }
2571
2572    /// #366, revised by the PR-431 review's Critical finding #3: a registry error
2573    /// classified as `PackageNotFound` (e.g. `deps-maven`'s `metadata_urls` rejecting a
2574    /// dot-segment coordinate like `com.example:..`, mirrored here by [`NotFoundRegistry`])
2575    /// used to make hover return `None` entirely via `.ok()?`. That chain was widened to
2576    /// plain `.ok()` because a *transient* fetch failure (off-VPN, an expired token, a
2577    /// DNS-blocked internal host) must degrade to the basic name/requirement/features
2578    /// card instead of vanishing the whole hover response — a real regression once a
2579    /// resolvable source could be a private registry rather than always crates.io. A
2580    /// `PackageNotFound` error takes the same, now-shared path: hover still renders (the
2581    /// basic card, exactly as the non-resolvable-source branch already produces), just
2582    /// with no version section — never "a broken hover section built from an empty
2583    /// version list".
2584    #[tokio::test]
2585    async fn test_generate_hover_renders_basic_card_when_registry_reports_not_found() {
2586        let parse_result = MockParseResult {
2587            deps: vec![dep_at("com.example:..")],
2588            uri: crate::test_util::test_uri("/test/pom.xml"),
2589        };
2590        let cached_versions = HashMap::new();
2591        let resolved_versions = HashMap::new();
2592
2593        let hover = generate_hover(
2594            &parse_result,
2595            Position::new(0, 2),
2596            VersionData::new(&cached_versions, &resolved_versions),
2597            &NotFoundRegistry,
2598            &MockFormatter,
2599            crate::FreshnessSettings::default(),
2600            PublishTime::now(),
2601        )
2602        .await
2603        .expect("hover must still render the basic card on a registry error");
2604
2605        let HoverContents::Markup(content) = hover.contents else {
2606            panic!("expected markup hover contents");
2607        };
2608        assert!(
2609            !content.value.contains("**Latest**"),
2610            "no version data is available on a registry error; got: {}",
2611            content.value
2612        );
2613        assert!(
2614            !content.value.contains("Recent versions"),
2615            "must not render a broken version section from an empty list; got: {}",
2616            content.value
2617        );
2618    }
2619
2620    /// #474: the "Press `Cmd+.` to update version" footer advertises a code action that
2621    /// only exists for a resolvable source with actual version data — a resolvable
2622    /// `Registry` source with a live (even empty) fetch must still show it.
2623    #[tokio::test]
2624    async fn test_generate_hover_footer_shown_for_resolvable_source_with_live_versions() {
2625        let registry = MockRegistryWithVersions {
2626            versions: vec![MockVersionWithAge {
2627                version: "1.2.3".into(),
2628                yanked: false,
2629                published_at: None,
2630            }],
2631        };
2632        let parse_result = freshness_test_parse_result("serde");
2633
2634        let hover = generate_hover(
2635            &parse_result,
2636            Position::new(0, 2),
2637            VersionData::new(&HashMap::new(), &HashMap::new()),
2638            &registry,
2639            &MockFormatter,
2640            crate::freshness::FreshnessSettings::default(),
2641            PublishTime::now(),
2642        )
2643        .await
2644        .expect("hover should be generated for a dependency at the cursor");
2645
2646        let HoverContents::Markup(content) = hover.contents else {
2647            panic!("expected markup hover contents");
2648        };
2649        assert!(
2650            content.value.contains("Press `Cmd+.` to update version"),
2651            "a resolvable source with live version data must show the update footer; got: {}",
2652            content.value
2653        );
2654    }
2655
2656    /// #474: a non-resolvable source (e.g. a local path or a Docker-style URL ref,
2657    /// mirrored here by `DependencySource::Path`) offers no diagnostic, inlay hint, or
2658    /// code action — the footer advertising `Cmd+.` must not render for it, even though a
2659    /// cached `latest` value exists in `versions.cached` (which `resolvable.then(...)`
2660    /// must gate *before* it ever reaches the footer condition).
2661    #[tokio::test]
2662    async fn test_generate_hover_footer_omitted_for_non_resolvable_source() {
2663        use crate::parser::DependencySource;
2664
2665        let uri = crate::test_util::test_uri("/test/workflow.yml");
2666        let parse_result = SingleDepParseResult {
2667            dep: NonRegistryDep(
2668                dep_at("local-action"),
2669                DependencySource::Path {
2670                    path: "./local-action".into(),
2671                },
2672            ),
2673            uri,
2674        };
2675        let cached_versions = {
2676            let mut m = HashMap::new();
2677            m.insert("local-action".into(), PackageVersions::latest_only("9.9.9"));
2678            m
2679        };
2680        let resolved_versions = HashMap::new();
2681
2682        let hover = generate_hover(
2683            &parse_result,
2684            Position::new(0, 2),
2685            VersionData::new(&cached_versions, &resolved_versions),
2686            &MockRegistry,
2687            &MockFormatter,
2688            crate::freshness::FreshnessSettings::default(),
2689            PublishTime::now(),
2690        )
2691        .await
2692        .expect("hover should still be generated for a non-resolvable-source dependency");
2693
2694        let HoverContents::Markup(content) = hover.contents else {
2695            panic!("expected markup hover contents");
2696        };
2697        assert!(
2698            !content.value.contains("Press `Cmd+.` to update version"),
2699            "a non-resolvable source offers no update code action, even with a cached \
2700             latest value present; got: {}",
2701            content.value
2702        );
2703    }
2704
2705    /// #501: while `network.offline` is set, `HttpCache` can still serve warm data and
2706    /// doc-state (`versions.vulnerabilities`/`cached_latest`/deprecation) can still survive
2707    /// the transition (see `test_generate_hover_footer_shown_when_offline_with_warm_cache_versions`),
2708    /// so the footer isn't suppressed on `offline` alone — only when offline AND no such
2709    /// actionable data exists at all. This test is that cold-start case: nothing cached,
2710    /// nothing resolved, and the OSV scan's own offline failure (`Skipped(QueryFailed)`,
2711    /// not `Vulnerable`) counts as "no data" too (#501 C5).
2712    #[tokio::test]
2713    async fn test_generate_hover_footer_omitted_when_offline_with_no_cached_data() {
2714        use crate::osv::{ScanOutcome, SkipReason, VulnerabilityMap};
2715
2716        // Cold process, nothing cached yet (#501's actual repro): the registry fetch fails
2717        // (offline, no warm `HttpCache` entry) and there is no cached/resolved/deprecation
2718        // doc state either, so no `generate_code_actions` producer has anything to act on.
2719        //
2720        // `vulnerabilities` carries a `Skipped(QueryFailed)` entry rather than being empty
2721        // (#501 C5): offline doesn't skip the OSV scan, it lets it run and fail, which is
2722        // exactly what a real offline cold start writes for every dependency — the gate must
2723        // not treat that `Skipped` presence as an actionable `Vulnerable` outcome.
2724        let parse_result = freshness_test_parse_result("serde");
2725        let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
2726        vulns.insert(
2727            "serde".to_string(),
2728            ScanOutcome::Skipped(SkipReason::QueryFailed),
2729        );
2730
2731        let hover = generate_hover(
2732            &parse_result,
2733            Position::new(0, 2),
2734            VersionData::new(&HashMap::new(), &HashMap::new())
2735                .with_offline(true)
2736                .with_vulnerabilities(&vulns),
2737            &ErrorRegistry,
2738            &MockFormatter,
2739            crate::freshness::FreshnessSettings::default(),
2740            PublishTime::now(),
2741        )
2742        .await
2743        .expect("hover should be generated for a dependency at the cursor");
2744
2745        let HoverContents::Markup(content) = hover.contents else {
2746            panic!("expected markup hover contents");
2747        };
2748        assert!(
2749            !content.value.contains("Press `Cmd+.` to update version"),
2750            "no code action can be produced while offline with nothing cached, so the \
2751             footer must not render; got: {}",
2752            content.value
2753        );
2754        assert!(
2755            content
2756                .value
2757                .contains("📴 *Offline: version and vulnerability data not checked*"),
2758            "the existing offline notice must still render; got: {}",
2759            content.value
2760        );
2761    }
2762
2763    /// #501 (impl-critic C1): `HttpCache` deliberately serves warm entries while offline, so
2764    /// a `Cmd+.` REFACTOR "update to X" action is still genuinely available whenever a live
2765    /// version list came back — the footer must not be suppressed just because
2766    /// `versions.offline` is set.
2767    #[tokio::test]
2768    async fn test_generate_hover_footer_shown_when_offline_with_warm_cache_versions() {
2769        let registry = MockRegistryWithVersions {
2770            versions: vec![MockVersionWithAge {
2771                version: "1.2.3".into(),
2772                yanked: false,
2773                published_at: None,
2774            }],
2775        };
2776        let parse_result = freshness_test_parse_result("serde");
2777
2778        let hover = generate_hover(
2779            &parse_result,
2780            Position::new(0, 2),
2781            VersionData::new(&HashMap::new(), &HashMap::new()).with_offline(true),
2782            &registry,
2783            &MockFormatter,
2784            crate::freshness::FreshnessSettings::default(),
2785            PublishTime::now(),
2786        )
2787        .await
2788        .expect("hover should be generated for a dependency at the cursor");
2789
2790        let HoverContents::Markup(content) = hover.contents else {
2791            panic!("expected markup hover contents");
2792        };
2793        assert!(
2794            content.value.contains("Press `Cmd+.` to update version"),
2795            "a warm-cache live version list still offers a real REFACTOR action while \
2796             offline, so the footer must render; got: {}",
2797            content.value
2798        );
2799    }
2800
2801    /// Issue #483 I1: the offline footer must render for a resolvable source, mirroring
2802    /// the `Cmd+.` footer's own `resolvable` gate immediately above it.
2803    #[tokio::test]
2804    async fn test_generate_hover_offline_footer_shown_for_resolvable_source() {
2805        let registry = MockRegistryWithVersions {
2806            versions: vec![MockVersionWithAge {
2807                version: "1.2.3".into(),
2808                yanked: false,
2809                published_at: None,
2810            }],
2811        };
2812        let parse_result = freshness_test_parse_result("serde");
2813        let cached = HashMap::new();
2814        let resolved = HashMap::new();
2815
2816        let hover = generate_hover(
2817            &parse_result,
2818            Position::new(0, 2),
2819            VersionData::new(&cached, &resolved).with_offline(true),
2820            &registry,
2821            &MockFormatter,
2822            crate::freshness::FreshnessSettings::default(),
2823            PublishTime::now(),
2824        )
2825        .await
2826        .expect("hover should be generated for a dependency at the cursor");
2827
2828        let HoverContents::Markup(content) = hover.contents else {
2829            panic!("expected markup hover contents");
2830        };
2831        assert!(
2832            content
2833                .value
2834                .contains("Offline: version and vulnerability data not checked"),
2835            "a resolvable source must show the offline footer when versions.offline is set; \
2836             got: {}",
2837            content.value
2838        );
2839    }
2840
2841    /// Issue #483 I1 (regression guard for the #474/#475 bug class): a non-resolvable
2842    /// source must never render the offline footer — it was never going to be checked
2843    /// regardless of `network.offline`, so claiming otherwise is misleading, exactly like
2844    /// the `Cmd+.` footer this mirrors.
2845    #[tokio::test]
2846    async fn test_generate_hover_offline_footer_omitted_for_non_resolvable_source() {
2847        use crate::parser::DependencySource;
2848
2849        let uri = crate::test_util::test_uri("/test/workflow.yml");
2850        let parse_result = SingleDepParseResult {
2851            dep: NonRegistryDep(
2852                dep_at("local-action"),
2853                DependencySource::Path {
2854                    path: "./local-action".into(),
2855                },
2856            ),
2857            uri,
2858        };
2859        let cached_versions = {
2860            let mut m = HashMap::new();
2861            m.insert("local-action".into(), PackageVersions::latest_only("9.9.9"));
2862            m
2863        };
2864        let resolved_versions = HashMap::new();
2865
2866        let hover = generate_hover(
2867            &parse_result,
2868            Position::new(0, 2),
2869            VersionData::new(&cached_versions, &resolved_versions).with_offline(true),
2870            &MockRegistry,
2871            &MockFormatter,
2872            crate::freshness::FreshnessSettings::default(),
2873            PublishTime::now(),
2874        )
2875        .await
2876        .expect("hover should still be generated for a non-resolvable-source dependency");
2877
2878        let HoverContents::Markup(content) = hover.contents else {
2879            panic!("expected markup hover contents");
2880        };
2881        assert!(
2882            !content.value.contains("Offline:"),
2883            "a non-resolvable source must not show the offline footer, even with \
2884             versions.offline set; got: {}",
2885            content.value
2886        );
2887    }
2888
2889    // --- Supply-chain trust signal (spec 037) ---
2890
2891    async fn deps_dev_mock_client() -> (mockito::ServerGuard, crate::DepsDevClient) {
2892        let server = mockito::Server::new_async().await;
2893        let client =
2894            crate::DepsDevClient::for_test(Arc::new(crate::HttpCache::new()), server.url());
2895        (server, client)
2896    }
2897
2898    /// A single dependency (`express@4.19.2`) whose in-use version resolves via
2899    /// `resolved_versions`, ready to attach to `VersionData::with_trust`.
2900    fn express_fixture() -> (
2901        MockParseResult,
2902        HashMap<crate::PackageName, ConcreteVersion>,
2903    ) {
2904        let parse_result = freshness_test_parse_result("express");
2905        let resolved_versions = HashMap::from([(
2906            crate::PackageName::new("express"),
2907            ConcreteVersion::new("4.19.2"),
2908        )]);
2909        (parse_result, resolved_versions)
2910    }
2911
2912    /// SC-001, pinned to the deterministic path per critic N4: the mock responds
2913    /// instantly, so `generate_hover`'s `DEPS_DEV_WAIT_BUDGET` await reliably
2914    /// completes well within budget rather than depending on a cold-memo race.
2915    #[tokio::test]
2916    async fn test_generate_hover_trust_signal_renders_score_and_verified_provenance() {
2917        let (mut server, deps_dev) = deps_dev_mock_client().await;
2918        let _version = server
2919            .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
2920            .with_status(200)
2921            .with_body(
2922                r#"{"slsaProvenances": [{"verified": true}], "attestations": [], "relatedProjects": [
2923                    {"projectKey": {"id": "github.com/expressjs/express"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
2924                ]}"#,
2925            )
2926            .create_async()
2927            .await;
2928        let _project = server
2929            .mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
2930            .with_status(200)
2931            .with_body(r#"{"scorecard": {"overallScore": 8.5}}"#)
2932            .create_async()
2933            .await;
2934        let deps_dev = Arc::new(deps_dev);
2935
2936        let (parse_result, resolved_versions) = express_fixture();
2937        let registry = MockRegistryWithVersions { versions: vec![] };
2938
2939        let hover = generate_hover(
2940            &parse_result,
2941            Position::new(0, 2),
2942            VersionData::new(&HashMap::new(), &resolved_versions)
2943                .with_ecosystem(crate::EcosystemId::Npm)
2944                .with_trust(&deps_dev),
2945            &registry,
2946            &MockFormatter,
2947            crate::freshness::FreshnessSettings::default(),
2948            PublishTime::now(),
2949        )
2950        .await
2951        .expect("hover should be generated");
2952
2953        let HoverContents::Markup(content) = hover.contents else {
2954            panic!("expected markup hover contents");
2955        };
2956        let line = content
2957            .value
2958            .lines()
2959            .find(|l| l.contains("Supply chain"))
2960            .unwrap_or_else(|| panic!("expected a Supply chain line, got: {}", content.value));
2961        insta::assert_snapshot!(line, @"🔐 **Supply chain**: OpenSSF Scorecard `8.5`/10 · Provenance: verified");
2962    }
2963
2964    #[tokio::test]
2965    async fn test_generate_hover_trust_signal_unverified_provenance() {
2966        let (mut server, deps_dev) = deps_dev_mock_client().await;
2967        let _version = server
2968            .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
2969            .with_status(200)
2970            .with_body(
2971                r#"{"slsaProvenances": [{"verified": false}], "attestations": [], "relatedProjects": []}"#,
2972            )
2973            .create_async()
2974            .await;
2975        let deps_dev = Arc::new(deps_dev);
2976
2977        let (parse_result, resolved_versions) = express_fixture();
2978        let registry = MockRegistryWithVersions { versions: vec![] };
2979
2980        let hover = generate_hover(
2981            &parse_result,
2982            Position::new(0, 2),
2983            VersionData::new(&HashMap::new(), &resolved_versions)
2984                .with_ecosystem(crate::EcosystemId::Npm)
2985                .with_trust(&deps_dev),
2986            &registry,
2987            &MockFormatter,
2988            crate::freshness::FreshnessSettings::default(),
2989            PublishTime::now(),
2990        )
2991        .await
2992        .expect("hover should be generated");
2993
2994        let HoverContents::Markup(content) = hover.contents else {
2995            panic!("expected markup hover contents");
2996        };
2997        assert!(
2998            content
2999                .value
3000                .contains("Provenance: attested but unverified"),
3001            "got: {}",
3002            content.value
3003        );
3004    }
3005
3006    #[tokio::test]
3007    async fn test_generate_hover_trust_signal_none_provenance_and_self_reported_marker() {
3008        let (mut server, deps_dev) = deps_dev_mock_client().await;
3009        let _version = server
3010            .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
3011            .with_status(200)
3012            .with_body(
3013                r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
3014                    {"projectKey": {"id": "github.com/expressjs/express"}, "relationType": "SOURCE_REPO", "relationProvenance": "UNVERIFIED_METADATA"}
3015                ]}"#,
3016            )
3017            .create_async()
3018            .await;
3019        let _project = server
3020            .mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
3021            .with_status(200)
3022            .with_body(r#"{"scorecard": {"overallScore": 7.2}}"#)
3023            .create_async()
3024            .await;
3025        let deps_dev = Arc::new(deps_dev);
3026
3027        let (parse_result, resolved_versions) = express_fixture();
3028        let registry = MockRegistryWithVersions { versions: vec![] };
3029
3030        let hover = generate_hover(
3031            &parse_result,
3032            Position::new(0, 2),
3033            VersionData::new(&HashMap::new(), &resolved_versions)
3034                .with_ecosystem(crate::EcosystemId::Npm)
3035                .with_trust(&deps_dev),
3036            &registry,
3037            &MockFormatter,
3038            crate::freshness::FreshnessSettings::default(),
3039            PublishTime::now(),
3040        )
3041        .await
3042        .expect("hover should be generated");
3043
3044        let HoverContents::Markup(content) = hover.contents else {
3045            panic!("expected markup hover contents");
3046        };
3047        assert!(
3048            content.value.contains("Provenance: none found"),
3049            "got: {}",
3050            content.value
3051        );
3052        assert!(
3053            content.value.contains("*(self-reported repo)*"),
3054            "an UNVERIFIED_METADATA-only relation must be disclosed; got: {}",
3055            content.value
3056        );
3057    }
3058
3059    /// SC-004: every ecosystem `deps_dev_system` does not cover (not just
3060    /// Composer) must issue zero deps.dev requests — checked before any
3061    /// spawn, per plan.md §8's gate.
3062    #[tokio::test]
3063    async fn test_generate_hover_trust_signal_skips_uncovered_ecosystems() {
3064        for ecosystem in [
3065            crate::EcosystemId::Composer,
3066            crate::EcosystemId::Dart,
3067            crate::EcosystemId::Swift,
3068        ] {
3069            let (mut server, deps_dev) = deps_dev_mock_client().await;
3070            let never_called = server
3071                .mock("GET", mockito::Matcher::Regex("^/v3/.*".into()))
3072                .expect(0)
3073                .create_async()
3074                .await;
3075            let deps_dev = Arc::new(deps_dev);
3076
3077            let (parse_result, resolved_versions) = express_fixture();
3078            let registry = MockRegistryWithVersions { versions: vec![] };
3079
3080            let hover = generate_hover(
3081                &parse_result,
3082                Position::new(0, 2),
3083                VersionData::new(&HashMap::new(), &resolved_versions)
3084                    .with_ecosystem(ecosystem)
3085                    .with_trust(&deps_dev),
3086                &registry,
3087                &MockFormatter,
3088                crate::freshness::FreshnessSettings::default(),
3089                PublishTime::now(),
3090            )
3091            .await
3092            .expect("hover should be generated");
3093
3094            let HoverContents::Markup(content) = hover.contents else {
3095                panic!("expected markup hover contents");
3096            };
3097            assert!(
3098                !content.value.contains("Supply chain"),
3099                "{ecosystem:?}: got: {}",
3100                content.value
3101            );
3102            never_called.assert_async().await;
3103        }
3104    }
3105
3106    /// Regression for security M2 / critic C2: a private/non-mirror
3107    /// `AlternateRegistry` source must never reach deps.dev, even though it
3108    /// resolves against this ecosystem's own registry (`resolvable` alone
3109    /// is the wrong, too-permissive gate — see
3110    /// `MockWidenedResolveFormatter`'s docs).
3111    #[tokio::test]
3112    async fn test_generate_hover_trust_signal_skips_private_registry_source() {
3113        let (mut server, deps_dev) = deps_dev_mock_client().await;
3114        let never_called = server
3115            .mock("GET", mockito::Matcher::Regex("^/v3/.*".into()))
3116            .expect(0)
3117            .create_async()
3118            .await;
3119        let deps_dev = Arc::new(deps_dev);
3120
3121        let parse_result = SingleDepParseResult {
3122            dep: NonRegistryDep(
3123                dep_at("internal-pkg"),
3124                crate::parser::DependencySource::AlternateRegistry {
3125                    index: "https://index.mycorp.internal".to_string(),
3126                    mirrors_crates_io: false,
3127                },
3128            ),
3129            uri: crate::test_util::test_uri("/test/Cargo.toml"),
3130        };
3131        let resolved_versions = HashMap::from([(
3132            crate::PackageName::new("internal-pkg"),
3133            ConcreteVersion::new("1.0.0"),
3134        )]);
3135        let registry = MockRegistryWithVersions { versions: vec![] };
3136
3137        let hover = generate_hover(
3138            &parse_result,
3139            Position::new(0, 2),
3140            VersionData::new(&HashMap::new(), &resolved_versions)
3141                .with_ecosystem(crate::EcosystemId::Cargo)
3142                .with_trust(&deps_dev),
3143            &registry,
3144            &MockWidenedResolveFormatter,
3145            crate::freshness::FreshnessSettings::default(),
3146            PublishTime::now(),
3147        )
3148        .await
3149        .expect("hover should be generated");
3150
3151        let HoverContents::Markup(content) = hover.contents else {
3152            panic!("expected markup hover contents");
3153        };
3154        assert!(
3155            !content.value.contains("Supply chain"),
3156            "a private registry's package name/version must never reach deps.dev; got: {}",
3157            content.value
3158        );
3159        never_called.assert_async().await;
3160    }
3161
3162    /// Regression for review C4 / critic C4: `versions.offline` must be
3163    /// checked in the same gate as every other network-gated hover section
3164    /// — offline must not spawn a task or write a negative memo entry.
3165    #[tokio::test]
3166    async fn test_generate_hover_trust_signal_skips_when_offline() {
3167        let (mut server, deps_dev) = deps_dev_mock_client().await;
3168        let never_called = server
3169            .mock("GET", mockito::Matcher::Regex("^/v3/.*".into()))
3170            .expect(0)
3171            .create_async()
3172            .await;
3173        let deps_dev = Arc::new(deps_dev);
3174
3175        let (parse_result, resolved_versions) = express_fixture();
3176        let registry = MockRegistryWithVersions { versions: vec![] };
3177
3178        let hover = generate_hover(
3179            &parse_result,
3180            Position::new(0, 2),
3181            VersionData::new(&HashMap::new(), &resolved_versions)
3182                .with_ecosystem(crate::EcosystemId::Npm)
3183                .with_trust(&deps_dev)
3184                .with_offline(true),
3185            &registry,
3186            &MockFormatter,
3187            crate::freshness::FreshnessSettings::default(),
3188            PublishTime::now(),
3189        )
3190        .await
3191        .expect("hover should be generated");
3192
3193        let HoverContents::Markup(content) = hover.contents else {
3194            panic!("expected markup hover contents");
3195        };
3196        assert!(
3197            !content.value.contains("Supply chain"),
3198            "got: {}",
3199            content.value
3200        );
3201        never_called.assert_async().await;
3202    }
3203
3204    /// SC-003: a deps.dev outage must leave every other hover section
3205    /// byte-identical to a hover generated with no trust client at all.
3206    #[tokio::test]
3207    async fn test_generate_hover_trust_signal_failure_leaves_other_content_unchanged() {
3208        let (mut server, deps_dev) = deps_dev_mock_client().await;
3209        let _version = server
3210            .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
3211            .with_status(500)
3212            .create_async()
3213            .await;
3214        let deps_dev = Arc::new(deps_dev);
3215
3216        let (parse_result, resolved_versions) = express_fixture();
3217        let registry = MockRegistryWithVersions {
3218            versions: vec![MockVersionWithAge {
3219                version: "4.19.2".into(),
3220                yanked: false,
3221                published_at: None,
3222            }],
3223        };
3224
3225        let with_failing_trust = generate_hover(
3226            &parse_result,
3227            Position::new(0, 2),
3228            VersionData::new(&HashMap::new(), &resolved_versions)
3229                .with_ecosystem(crate::EcosystemId::Npm)
3230                .with_trust(&deps_dev),
3231            &registry,
3232            &MockFormatter,
3233            crate::freshness::FreshnessSettings::default(),
3234            PublishTime::now(),
3235        )
3236        .await
3237        .expect("hover should be generated");
3238
3239        let without_trust = generate_hover(
3240            &parse_result,
3241            Position::new(0, 2),
3242            VersionData::new(&HashMap::new(), &resolved_versions)
3243                .with_ecosystem(crate::EcosystemId::Npm),
3244            &registry,
3245            &MockFormatter,
3246            crate::freshness::FreshnessSettings::default(),
3247            PublishTime::now(),
3248        )
3249        .await
3250        .expect("hover should be generated");
3251
3252        let HoverContents::Markup(a) = with_failing_trust.contents else {
3253            panic!("expected markup hover contents");
3254        };
3255        let HoverContents::Markup(b) = without_trust.contents else {
3256            panic!("expected markup hover contents");
3257        };
3258        assert!(!a.value.contains("Supply chain"), "got: {}", a.value);
3259        assert_eq!(a.value, b.value);
3260    }
3261
3262    /// Exercises `generate_hover`'s own real `tokio::time::timeout(DEPS_DEV_WAIT_BUDGET,
3263    /// handle)` wrap directly — not a smaller artificial stand-in (review's flagged test
3264    /// gap: the only prior test of the underlying mechanism drove
3265    /// `DepsDevClient::trust_signal` with its own 5ms timeout, never through
3266    /// `generate_hover` at all).
3267    ///
3268    /// Both deps.dev calls are delayed just under the internal `DEPS_DEV_CALL_TIMEOUT`
3269    /// (400ms each) but together exceed the real `DEPS_DEV_WAIT_BUDGET` (700ms) — the only
3270    /// way to reach the hover-level timeout without either call tripping its own shorter
3271    /// per-call cap first. `flavor = "multi_thread"` so the blocking `std::thread::sleep`
3272    /// inside the mock handler runs on a different worker thread than the test's own
3273    /// timer, matching
3274    /// `deps_dev::tests::trust_signal_survives_dropped_join_handle_and_warms_memo`'s
3275    /// technique.
3276    ///
3277    /// Deliberately does **not** also assert that a later hover picks up the memo the
3278    /// background task warms — two prior attempts at that (a tight 50ms poll, then a
3279    /// spaced 5×1s retry) both passed locally but failed consistently on CI's Linux
3280    /// runners (stable and beta; macOS and Windows were unaffected), each time burning
3281    /// its *entire* budget rather than merely running late. That symmetry between two
3282    /// very different waiting strategies points at the environment, not the margin, and
3283    /// nothing here can distinguish "the background task is still running, slowly" from
3284    /// "it already failed and negative-cached the result" from inside this same racing
3285    /// test — see the sibling test below for a deterministic replacement that proves the
3286    /// warm-memo path without racing this timeout.
3287    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
3288    async fn test_generate_hover_trust_signal_over_real_wait_budget_omits_section() {
3289        const CALL_DELAY: std::time::Duration = std::time::Duration::from_millis(375);
3290
3291        let (mut server, deps_dev) = deps_dev_mock_client().await;
3292        let _version = server
3293            .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
3294            .with_status(200)
3295            .with_body_from_request(move |_req| {
3296                std::thread::sleep(CALL_DELAY);
3297                r#"{"slsaProvenances": [{"verified": true}], "attestations": [], "relatedProjects": [
3298                    {"projectKey": {"id": "github.com/expressjs/express"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
3299                ]}"#
3300                .as_bytes()
3301                .to_vec()
3302            })
3303            .create_async()
3304            .await;
3305        let _project = server
3306            .mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
3307            .with_status(200)
3308            .with_body_from_request(move |_req| {
3309                std::thread::sleep(CALL_DELAY);
3310                r#"{"scorecard": {"overallScore": 8.5}}"#.as_bytes().to_vec()
3311            })
3312            .create_async()
3313            .await;
3314        let deps_dev = Arc::new(deps_dev);
3315
3316        let (parse_result, resolved_versions) = express_fixture();
3317        let registry = MockRegistryWithVersions { versions: vec![] };
3318
3319        let first = generate_hover(
3320            &parse_result,
3321            Position::new(0, 2),
3322            VersionData::new(&HashMap::new(), &resolved_versions)
3323                .with_ecosystem(crate::EcosystemId::Npm)
3324                .with_trust(&deps_dev),
3325            &registry,
3326            &MockFormatter,
3327            crate::freshness::FreshnessSettings::default(),
3328            PublishTime::now(),
3329        )
3330        .await
3331        .expect("hover should be generated");
3332        let HoverContents::Markup(first_content) = first.contents else {
3333            panic!("expected markup hover contents");
3334        };
3335        assert!(
3336            !first_content.value.contains("Supply chain"),
3337            "the real ~750ms two-call sequence must exceed the 700ms wait budget on the \
3338             first hover; got: {}",
3339            first_content.value
3340        );
3341        // The detached task spawned above keeps running past this point by design
3342        // (that's the whole point of spawn-and-warm) — deliberately not awaited or
3343        // polled for here; see this function's doc comment.
3344    }
3345
3346    /// The warm-memo half of the spawn-and-warm design (plan.md §12), proved
3347    /// deterministically rather than by racing the timeout test above: a memo entry
3348    /// populated by a direct, un-delayed `DepsDevClient::trust_signal` call (no timing
3349    /// involved — it is simply `.await`ed to completion) is read by a subsequent
3350    /// `generate_hover` call through the exact same `VersionData::with_trust` /
3351    /// `push_trust_signal_hover_section` path the timeout test above exercises. Together
3352    /// the two tests cover `generate_hover`'s real integration with `DepsDevClient` on
3353    /// both the "too slow, omit" and "already warm, render" branches, without either one
3354    /// depending on winning a race against the ambient test suite's scheduling.
3355    #[tokio::test]
3356    async fn test_generate_hover_renders_trust_signal_from_a_warm_memo() {
3357        let (mut server, deps_dev) = deps_dev_mock_client().await;
3358        let _version = server
3359            .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
3360            .with_status(200)
3361            .with_body(
3362                r#"{"slsaProvenances": [{"verified": true}], "attestations": [], "relatedProjects": [
3363                    {"projectKey": {"id": "github.com/expressjs/express"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
3364                ]}"#,
3365            )
3366            .create_async()
3367            .await;
3368        let _project = server
3369            .mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
3370            .with_status(200)
3371            .with_body(r#"{"scorecard": {"overallScore": 8.5}}"#)
3372            .create_async()
3373            .await;
3374
3375        let warmed = deps_dev
3376            .trust_signal("npm", "express", "4.19.2")
3377            .await
3378            .expect("the direct, un-delayed call must warm the memo deterministically");
3379        assert!(warmed.scorecard.is_some(), "fixture includes a scorecard");
3380
3381        let deps_dev = Arc::new(deps_dev);
3382        let (parse_result, resolved_versions) = express_fixture();
3383        let registry = MockRegistryWithVersions { versions: vec![] };
3384
3385        let hover = generate_hover(
3386            &parse_result,
3387            Position::new(0, 2),
3388            VersionData::new(&HashMap::new(), &resolved_versions)
3389                .with_ecosystem(crate::EcosystemId::Npm)
3390                .with_trust(&deps_dev),
3391            &registry,
3392            &MockFormatter,
3393            crate::freshness::FreshnessSettings::default(),
3394            PublishTime::now(),
3395        )
3396        .await
3397        .expect("hover should be generated");
3398        let HoverContents::Markup(content) = hover.contents else {
3399            panic!("expected markup hover contents");
3400        };
3401        assert!(
3402            content.value.contains("Supply chain"),
3403            "a hover reading an already-warm memo must render the trust signal \
3404             immediately; got: {}",
3405            content.value
3406        );
3407    }
3408}