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
19const DEPS_DEV_WAIT_BUDGET: Duration = Duration::from_millis(700);
28
29fn 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
44const HOVER_FALLBACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
54
55pub 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 let dep_source = dep.source();
90 let resolvable = formatter.can_resolve_source(&dep_source);
91
92 let normalized_name = formatter.normalize_package_name(dep.name());
98
99 let trust_handle = spawn_trust_signal_fetch(
102 dep,
103 &dep_source,
104 &versions,
105 formatter,
106 normalized_name.as_str(),
107 );
108
109 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 let url = (!formatter.suppress_package_url(&dep_source))
141 .then(|| formatter.package_url(dep.name()))
142 .filter(|u| !u.is_empty());
143
144 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 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 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 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 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 push_deprecation_hover_section(&mut markdown, formatter, deprecation);
293 push_vulnerability_hover_section(&mut markdown, vuln_outcome);
294
295 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 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
348fn 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
407fn 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
424fn 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
452fn 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
468fn 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
500fn 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 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
567fn 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
625fn 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
643const 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
654fn 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 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
691fn 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
766fn 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 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 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 ®istry,
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 ®istry,
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 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 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 ®istry,
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 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 ®istry,
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 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 ®istry,
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 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 ®istry,
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 #[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 ®istry,
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 #[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 ®istry,
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 #[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 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 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 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 ®istry,
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 ®istry,
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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 ®istry,
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 #[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 ®istry,
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 #[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 ®istry,
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 #[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 ®istry,
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 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 #[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 ®istry,
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 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 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 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 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 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 ®istry,
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 let registry_parse_result = SingleDepParseResult {
2331 dep: dep_at("dep"),
2332 uri,
2333 };
2334 let hover = generate_hover(
2335 ®istry_parse_result,
2336 Position::new(0, 2),
2337 VersionData::new(&cached_versions, &resolved_versions),
2338 ®istry,
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 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 #[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 #[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 ®istry,
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 #[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 #[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 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 #[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 ®istry,
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 #[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 ®istry,
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 #[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 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 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 #[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 ®istry,
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 ®istry,
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 ®istry,
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 #[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 ®istry,
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 #[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 ®istry,
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 #[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 ®istry,
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 #[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 ®istry,
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 ®istry,
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 #[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 ®istry,
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 }
3345
3346 #[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 ®istry,
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}