use std::sync::Arc;
use std::time::Duration;
use tower_lsp_server::ls_types::{Hover, HoverContents, MarkupContent, MarkupKind, Position};
use crate::deps_dev::deps_dev_system;
use crate::licenses::resolve_license_entries_for_display;
use crate::osv::ScanOutcome;
use crate::{
ConcreteVersion, Dependency, DependencySource, Deprecation, LicenseSource, ParseResult,
ProvenanceStatus, PublishTime, Registry, SupplyChainTrustSignal, Version, VersionReq,
format_relative_age, is_within_cooldown,
};
use super::{
EcosystemFormatter, HOVER_RECENT_VERSIONS, VersionData, escape_markdown, in_use_version,
markdown_code_span, position_in_range, resolve_in_use_version,
};
use crate::github::normalize_tag;
const DEPS_DEV_WAIT_BUDGET: Duration = Duration::from_millis(700);
fn version_age_suffix(version: &dyn Version, now: PublishTime) -> String {
version
.published_at()
.map(|published| format!(" — {}", format_relative_age(published.age_secs_from(now))))
.unwrap_or_default()
}
const HOVER_FALLBACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
pub const CMD_DOT_FOOTER: &str = "\n---\n⌨️ **Press `Cmd+.` to update version**";
pub async fn generate_hover<R: Registry + ?Sized>(
parse_result: &dyn ParseResult,
position: Position,
versions: VersionData<'_>,
registry: &R,
formatter: &dyn EcosystemFormatter,
freshness: crate::freshness::FreshnessSettings,
now: PublishTime,
) -> Option<Hover> {
let dep = parse_result.dependencies().into_iter().find(|d| {
let on_name = !d.name_range_is_synthetic() && position_in_range(position, d.name_range());
let on_version = d
.version_range()
.is_some_and(|r| position_in_range(position, r));
on_name || on_version
})?;
let dep_source = dep.source();
let resolvable = formatter.can_resolve_source(&dep_source);
let normalized_name = formatter.normalize_package_name(dep.name());
let trust_handle = spawn_trust_signal_fetch(
dep,
&dep_source,
&versions,
formatter,
normalized_name.as_str(),
);
let available_versions = if resolvable {
registry
.get_versions_from(dep.name(), &dep_source, freshness)
.await
.ok()
} else {
None
};
let url = (!formatter.suppress_package_url(&dep_source))
.then(|| formatter.package_url(dep.name()))
.filter(|u| !u.is_empty());
let mut markdown = String::with_capacity(512);
push_header_hover_section(&mut markdown, dep, url.as_deref());
let resolved: Option<&str> = if formatter.manifest_requirement_is_resolved_version(dep) {
dep.version_requirement().map(VersionReq::as_str)
} else {
in_use_version::resolve_occurrence_version(
dep,
normalized_name.as_str(),
versions.resolved,
versions.resolved_version_candidates,
formatter,
)
.map(ConcreteVersion::as_str)
};
push_current_or_requirement_hover_section(&mut markdown, dep, resolved);
push_markers_hover_section(&mut markdown, dep);
let wildcard_req = crate::existence_wildcard_req();
let live_latest_idx = available_versions
.as_ref()
.and_then(|v| registry.select_latest_matching(v, &wildcard_req));
let list_fallback_latest = if available_versions.as_ref().is_some_and(|v| !v.is_empty())
&& live_latest_idx.is_none()
{
match tokio::time::timeout(
HOVER_FALLBACK_TIMEOUT,
registry.get_latest_matching_from(dep.name(), &dep_source, &wildcard_req, None),
)
.await
{
Ok(Ok(found)) => {
tracing::debug!(package = %dep.name(), found = found.is_some(), "hover latest fallback (get_latest_matching) resolved");
found
}
Ok(Err(error)) => {
tracing::warn!(package = %dep.name(), %error, "hover latest fallback (get_latest_matching) failed");
None
}
Err(_) => {
tracing::warn!(
package = %dep.name(),
timeout_secs = HOVER_FALLBACK_TIMEOUT.as_secs(),
"hover latest fallback (get_latest_matching) timed out"
);
None
}
}
} else {
None
};
let cached_latest = resolvable
.then(|| {
versions
.cached
.get(normalized_name.as_str())
.or_else(|| versions.cached.get(dep.name()))
})
.flatten();
let latest_line: Option<(&str, Option<PublishTime>)> = match &available_versions {
Some(v) if !v.is_empty() => live_latest_idx
.and_then(|idx| v.get(idx))
.map(|live| (live.version_string().as_str(), live.published_at()))
.or_else(|| {
list_fallback_latest
.as_deref()
.map(|live| (live.version_string().as_str(), live.published_at()))
}),
_ => cached_latest.map(|v| (v.latest.as_str(), v.published_at)),
};
push_latest_hover_section(&mut markdown, latest_line, freshness, now);
let vuln_key = versions.ecosystem.and_then(|ecosystem| {
crate::osv::vulnerability_keys(
parse_result,
versions.resolved,
versions.resolved_version_candidates,
formatter,
ecosystem,
)
.remove(&dep.name_range())
});
let vuln_outcome = versions.vulnerabilities.and_then(|m| {
vuln_key
.as_deref()
.and_then(|key| m.get(key))
.or_else(|| m.get(&normalized_name))
.or_else(|| m.get(dep.name().as_str()))
});
let deprecation = versions
.outcomes
.and_then(|o| o.deprecation(&normalized_name));
push_deprecation_hover_section(&mut markdown, formatter, deprecation);
push_vulnerability_hover_section(&mut markdown, vuln_outcome);
let trust_signal = match trust_handle {
Some(handle) => match tokio::time::timeout(DEPS_DEV_WAIT_BUDGET, handle).await {
Ok(Ok(signal)) => signal,
Ok(Err(_)) | Err(_) => None,
},
None => None,
};
push_trust_signal_hover_section(&mut markdown, trust_signal.as_ref());
let in_use_version_str: Option<String> = versions.ecosystem.and_then(|ecosystem| {
resolve_in_use_version(
dep,
normalized_name.as_str(),
versions.resolved,
versions.resolved_version_candidates,
formatter,
ecosystem,
)
});
let resolved_key: Option<&str> = in_use_version_str.as_deref().or(resolved);
let license_source = versions.license_source.unwrap_or_default();
let resolved_license: Vec<String> = resolved_key
.and_then(|r| {
available_versions.as_ref().and_then(|versions| {
versions
.iter()
.find(|v| normalize_tag(v.version_string().as_str()) == normalize_tag(r))
})
})
.map(|v| v.license().to_vec())
.filter(|l| !l.is_empty())
.or_else(|| {
trust_signal
.as_ref()
.map(|s| s.licenses.clone())
.filter(|l| !l.is_empty())
})
.or_else(|| {
versions
.license_prefetch
.and_then(|m| m.get(dep.name()))
.filter(|raw| !raw.is_empty())
.map(|raw| resolve_license_entries_for_display(license_source, raw))
})
.unwrap_or_default();
let latest_license: Option<Vec<String>> = (!resolved_license.is_empty())
.then(|| {
latest_line.map(|(latest_ver, _)| {
if resolved_key == Some(latest_ver) {
resolved_license.clone()
} else {
live_latest_idx
.and_then(|idx| available_versions.as_ref().and_then(|v| v.get(idx)))
.map(|v| v.license().to_vec())
.filter(|l| !l.is_empty())
.unwrap_or_default()
}
})
})
.flatten();
let license_is_detected = license_source == LicenseSource::DetectedSpdx;
push_license_hover_section(
&mut markdown,
&resolved_license,
latest_license.as_deref(),
license_is_detected,
);
if let Some(available_versions) = available_versions.as_ref().filter(|v| !v.is_empty()) {
push_recent_versions_hover_section(
&mut markdown,
available_versions,
live_latest_idx,
freshness,
now,
formatter,
);
}
push_cmd_dot_footer_hover_section(
&mut markdown,
resolvable,
available_versions.as_deref(),
cached_latest,
vuln_outcome,
deprecation,
versions.offline,
);
push_offline_footer_hover_section(&mut markdown, resolvable, versions.offline);
Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: markdown,
}),
range: Some(dep.name_range()),
})
}
fn spawn_trust_signal_fetch(
dep: &dyn Dependency,
dep_source: &DependencySource,
versions: &VersionData<'_>,
formatter: &dyn EcosystemFormatter,
normalized_name: &str,
) -> Option<tokio::task::JoinHandle<Option<SupplyChainTrustSignal>>> {
versions.trust.and_then(|client| {
let ecosystem = versions.ecosystem?;
let system = deps_dev_system(ecosystem)?;
if versions.offline || !formatter.source_is_public_registry_content(dep_source) {
return None;
}
let version = resolve_in_use_version(
dep,
normalized_name,
versions.resolved,
versions.resolved_version_candidates,
formatter,
ecosystem,
)?;
let client = Arc::clone(client);
let name = dep.name().to_string();
Some(tokio::spawn(async move {
client.trust_signal(system, &name, &version).await
}))
})
}
fn push_header_hover_section(markdown: &mut String, dep: &dyn Dependency, url: Option<&str>) {
use std::fmt::Write as _;
let _ = match url {
Some(url) => write!(
markdown,
"# [{}]({})\n\n",
escape_markdown(dep.name().as_str()),
url
),
None => write!(markdown, "# {}\n\n", escape_markdown(dep.name().as_str())),
};
}
fn push_current_or_requirement_hover_section(
markdown: &mut String,
dep: &dyn Dependency,
resolved: Option<&str>,
) {
use std::fmt::Write as _;
if let Some(resolved_ver) = resolved {
let _ = write!(
markdown,
"**Current**: {}\n\n",
markdown_code_span(resolved_ver)
);
} else if let Some(version_req) = dep.version_requirement() {
let _ = write!(
markdown,
"**Requirement**: {}\n\n",
markdown_code_span(version_req.as_str())
);
}
}
fn push_markers_hover_section(markdown: &mut String, dep: &dyn Dependency) {
use std::fmt::Write as _;
if let Some(marker_expr) = dep.markers() {
let _ = write!(
markdown,
"**Active when**: {}\n\n",
markdown_code_span(marker_expr)
);
}
}
fn push_latest_hover_section(
markdown: &mut String,
latest_line: Option<(&str, Option<PublishTime>)>,
freshness: crate::freshness::FreshnessSettings,
now: PublishTime,
) {
use std::fmt::Write as _;
let Some((latest_ver, raw_published_at)) = latest_line else {
return;
};
let published_at = freshness.enabled.then_some(raw_published_at).flatten();
let age_secs = published_at.map(|p| p.age_secs_from(now));
let _ = write!(markdown, "**Latest**: {}", markdown_code_span(latest_ver));
if let Some(age_secs) = age_secs {
let _ = write!(markdown, " *(published {})*", format_relative_age(age_secs));
}
markdown.push_str("\n\n");
if age_secs.is_some_and(|age| is_within_cooldown(age, freshness.cooldown_secs)) {
markdown.push_str(
"> ⏳ **Recently published** — this release is still within the cooldown window.\n\
> It may still be yanked or superseded; consider verifying before upgrading.\n\n",
);
}
}
fn push_recent_versions_hover_section(
markdown: &mut String,
available_versions: &[Box<dyn Version>],
live_latest_idx: Option<usize>,
freshness: crate::freshness::FreshnessSettings,
now: PublishTime,
formatter: &dyn EcosystemFormatter,
) {
use std::fmt::Write as _;
if available_versions.is_empty() {
return;
}
let mut entries: Vec<(usize, &Box<dyn Version>)> = available_versions
.iter()
.enumerate()
.take(HOVER_RECENT_VERSIONS)
.collect();
if let Some(idx) = live_latest_idx
&& idx >= HOVER_RECENT_VERSIONS
&& let Some(pick) = available_versions.get(idx)
{
entries.truncate(HOVER_RECENT_VERSIONS - 1);
entries.push((idx, pick));
}
markdown.push_str("**Recent versions**:\n");
for (i, version) in entries {
let version_span = markdown_code_span(version.version_string().as_str());
let age_suffix = if freshness.enabled {
version_age_suffix(version.as_ref(), now)
} else {
String::new()
};
if Some(i) == live_latest_idx {
if version.removal_status().is_flagged() {
let _ = writeln!(
markdown,
"- {version_span} *(latest)* {}{age_suffix}",
formatter.yanked_label()
);
} else {
let _ = writeln!(markdown, "- {version_span} *(latest)*{age_suffix}");
}
} else if version.removal_status().is_flagged() {
let _ = writeln!(
markdown,
"- {} {}{}",
version_span,
formatter.yanked_label(),
age_suffix
);
} else {
let _ = writeln!(markdown, "- {version_span}{age_suffix}");
}
}
}
fn push_cmd_dot_footer_hover_section(
markdown: &mut String,
resolvable: bool,
available_versions: Option<&[Box<dyn Version>]>,
cached_latest: Option<&super::PackageVersions>,
vuln_outcome: Option<&ScanOutcome>,
deprecation: Option<&Deprecation>,
offline: bool,
) {
let has_offline_actionable_data = available_versions.is_some_and(|v| !v.is_empty())
|| cached_latest.is_some()
|| matches!(vuln_outcome, Some(ScanOutcome::Vulnerable(_)))
|| deprecation.is_some();
let live_fetch_definitively_empty = available_versions.is_some_and(<[_]>::is_empty);
let footer_actionable =
has_offline_actionable_data || (!live_fetch_definitively_empty && !offline);
if resolvable && footer_actionable {
markdown.push_str(CMD_DOT_FOOTER);
}
}
fn push_offline_footer_hover_section(markdown: &mut String, resolvable: bool, offline: bool) {
if offline && resolvable {
markdown.push_str("\n---\n📴 *Offline: version and vulnerability data not checked*");
}
}
const fn severity_label(severity: crate::osv::VulnSeverity) -> &'static str {
match severity {
crate::osv::VulnSeverity::Critical => "critical",
crate::osv::VulnSeverity::High => "high",
crate::osv::VulnSeverity::Medium => "medium",
crate::osv::VulnSeverity::Low => "low",
crate::osv::VulnSeverity::Unknown => "unknown severity",
crate::osv::VulnSeverity::Malicious => "confirmed malicious package",
}
}
fn push_deprecation_hover_section(
markdown: &mut String,
formatter: &dyn EcosystemFormatter,
deprecation: Option<&Deprecation>,
) {
use std::fmt::Write as _;
let Some(deprecation) = deprecation else {
return;
};
markdown.push_str("### Deprecated\n\n");
let _ = writeln!(markdown, "{}\n", formatter.deprecated_message());
if let Some(reason) = deprecation.reason.as_deref().filter(|r| !r.is_empty()) {
let _ = writeln!(markdown, "{}\n", escape_markdown(reason));
}
if let Some(replacement) = deprecation.replacement.as_deref().filter(|r| !r.is_empty()) {
let _ = writeln!(
markdown,
"Suggested replacement: {}\n",
markdown_code_span(replacement)
);
}
}
fn push_vulnerability_hover_section(markdown: &mut String, outcome: Option<&ScanOutcome>) {
use std::fmt::Write;
match outcome {
Some(ScanOutcome::Vulnerable(dv)) => {
markdown.push_str("### Security advisories\n\n");
for advisory in dv.advisories.items() {
let _ = writeln!(
markdown,
"- **[{}]({})** — {}",
escape_markdown(&advisory.id),
advisory.url,
severity_label(advisory.severity)
);
let _ = writeln!(
markdown,
" {}",
escape_markdown(
advisory
.summary
.as_deref()
.unwrap_or("(no summary provided)")
)
);
let mut details = Vec::with_capacity(2);
if let Some(fixed) = advisory.fixed_versions.last() {
details.push(format!("Fixed in: {}", markdown_code_span(fixed)));
}
if !advisory.aliases.is_empty() {
details.push(format!(
"Aliases: {}",
escape_markdown(&advisory.aliases.join(", "))
));
}
if !details.is_empty() {
let _ = writeln!(markdown, " {}", details.join(" \u{b7} "));
}
}
let remaining = dv.advisories.remaining();
if remaining > 0 {
let _ = writeln!(markdown, "- *(+{remaining} more advisories)*");
}
if let crate::osv::UpgradeStatus::CandidateVulnerable { version, .. } =
&dv.upgrade_status
{
let _ = writeln!(
markdown,
"\n\u{26a0}\u{fe0f} Latest version {} is also affected.",
markdown_code_span(version)
);
}
markdown.push('\n');
}
Some(ScanOutcome::Clean) => {
markdown.push_str("**No known vulnerabilities** (OSV.dev)\n\n");
}
Some(ScanOutcome::Skipped(_)) | None => {}
}
}
fn push_trust_signal_hover_section(markdown: &mut String, signal: Option<&SupplyChainTrustSignal>) {
use std::fmt::Write;
let Some(signal) = signal else {
return;
};
if signal.scorecard.is_none() && signal.provenance.is_none() {
return;
}
let mut parts: Vec<String> = Vec::with_capacity(2);
if let Some(scorecard) = &signal.scorecard {
let mut part = format!(
"OpenSSF Scorecard {}/10",
markdown_code_span(&format!("{:.1}", scorecard.overall_score))
);
if scorecard.self_reported {
part.push_str(" *(self-reported repo)*");
}
parts.push(part);
}
if let Some(provenance) = signal.provenance {
let label = match provenance {
ProvenanceStatus::Verified => "verified",
ProvenanceStatus::Unverified => "attested but unverified",
ProvenanceStatus::None => "none found",
};
parts.push(format!("Provenance: {label}"));
}
let _ = writeln!(
markdown,
"\u{1f510} **Supply chain**: {}",
parts.join(" \u{b7} ")
);
markdown.push('\n');
}
const MAX_LICENSE_ENTRIES_RENDERED: usize = 8;
const MAX_LICENSE_ID_CHARS: usize = 128;
fn format_license_list(licenses: &[String]) -> String {
let shown = licenses.len().min(MAX_LICENSE_ENTRIES_RENDERED);
#[allow(clippy::indexing_slicing)]
let mut rendered: Vec<String> = licenses[..shown]
.iter()
.map(|l| markdown_code_span(&super::truncate_for_diagnostic(l, MAX_LICENSE_ID_CHARS)))
.collect();
let remaining = licenses.len() - shown;
if remaining > 0 {
rendered.push(format!("(+{remaining} more)"));
}
rendered.join(", ")
}
fn license_sets_differ(a: &[String], b: &[String]) -> bool {
let a_set: std::collections::BTreeSet<String> = a.iter().map(|l| l.to_lowercase()).collect();
let b_set: std::collections::BTreeSet<String> = b.iter().map(|l| l.to_lowercase()).collect();
a_set != b_set
}
fn push_license_hover_section(
markdown: &mut String,
resolved_license: &[String],
latest_license: Option<&[String]>,
detected: bool,
) {
use std::fmt::Write;
if resolved_license.is_empty() {
return;
}
let _ = write!(
markdown,
"**License{}**: {}",
if detected { " (detected)" } else { "" },
format_license_list(resolved_license)
);
match latest_license {
None => {}
Some([]) => {
markdown.push_str(" *(latest version license unavailable)*");
}
Some(latest) if license_sets_differ(resolved_license, latest) => {
let _ = write!(
markdown,
"\n\n\u{26a0}\u{fe0f} **License changed**: {} \u{2192} {}",
format_license_list(resolved_license),
format_license_list(latest)
);
}
Some(_) => {}
}
markdown.push_str("\n\n");
}
#[cfg(test)]
#[allow(clippy::cast_possible_truncation)]
mod tests {
use super::*;
use crate::RemovalStatus;
use crate::lsp_helpers::test_support::*;
use crate::lsp_helpers::*;
use std::collections::HashMap;
use std::sync::Arc;
#[test]
fn license_sets_differ_is_order_insensitive() {
let a = vec!["MIT".to_string(), "Apache-2.0".to_string()];
let b = vec!["Apache-2.0".to_string(), "MIT".to_string()];
assert!(!license_sets_differ(&a, &b));
}
#[test]
fn license_sets_differ_detects_real_change() {
let a = vec!["MIT".to_string()];
let b = vec!["GPL-3.0".to_string()];
assert!(license_sets_differ(&a, &b));
}
#[test]
fn license_sets_differ_is_case_insensitive() {
let a = vec!["MIT".to_string()];
let b = vec!["mit".to_string()];
assert!(!license_sets_differ(&a, &b));
}
#[test]
fn format_license_list_caps_entries_and_labels_the_remainder() {
let licenses: Vec<String> = (0..12).map(|i| format!("LICENSE-{i}")).collect();
let rendered = format_license_list(&licenses);
assert!(rendered.contains("(+4 more)"), "got: {rendered}");
assert!(rendered.contains("`LICENSE-0`"));
assert!(
!rendered.contains("LICENSE-11"),
"the 12th entry must not render; got: {rendered}"
);
}
#[test]
fn format_license_list_truncates_an_overlong_identifier() {
let long_id = "A".repeat(500);
let rendered = format_license_list(std::slice::from_ref(&long_id));
assert!(
rendered.len() < long_id.len(),
"an untrusted, excessively long license id must be truncated; got len {}",
rendered.len()
);
}
#[test]
fn push_license_hover_section_renders_nothing_when_resolved_unknown() {
let mut markdown = String::new();
push_license_hover_section(&mut markdown, &[], Some(&["MIT".to_string()]), false);
assert!(markdown.is_empty());
}
#[test]
fn push_license_hover_section_renders_nothing_extra_when_no_latest_version_exists() {
let mut markdown = String::new();
push_license_hover_section(&mut markdown, &["MIT".to_string()], None, false);
assert!(markdown.contains("**License**: `MIT`"));
assert!(!markdown.contains("unavailable"));
assert!(!markdown.contains("License changed"));
}
#[test]
fn push_license_hover_section_renders_resolved_only_when_latest_unavailable() {
let mut markdown = String::new();
push_license_hover_section(&mut markdown, &["MIT".to_string()], Some(&[]), false);
assert!(markdown.contains("**License**: `MIT`"));
assert!(markdown.contains("latest version license unavailable"));
assert!(!markdown.contains("License changed"));
}
#[test]
fn push_license_hover_section_flags_change_when_licenses_differ() {
let mut markdown = String::new();
push_license_hover_section(
&mut markdown,
&["MIT".to_string()],
Some(&["Apache-2.0".to_string()]),
false,
);
assert!(markdown.contains("**License**: `MIT`"));
assert!(markdown.contains("License changed"));
assert!(markdown.contains("`MIT` \u{2192} `Apache-2.0`"));
}
#[test]
fn push_license_hover_section_detected_flag_adds_qualifier() {
let mut markdown = String::new();
push_license_hover_section(&mut markdown, &["MIT".to_string()], None, true);
assert!(markdown.contains("**License (detected)**: `MIT`"));
}
#[test]
fn push_license_hover_section_change_line_is_a_separate_paragraph() {
let mut markdown = String::new();
push_license_hover_section(
&mut markdown,
&["MIT".to_string()],
Some(&["Apache-2.0".to_string()]),
false,
);
assert!(
markdown.contains("`MIT`\n\n\u{26a0}\u{fe0f} **License changed**"),
"expected a blank line (paragraph break) before the License changed line; got: {markdown:?}"
);
}
#[test]
fn push_license_hover_section_no_flag_when_licenses_equal() {
let mut markdown = String::new();
push_license_hover_section(
&mut markdown,
&["MIT".to_string()],
Some(&["MIT".to_string()]),
false,
);
assert!(markdown.contains("**License**: `MIT`"));
assert!(!markdown.contains("License changed"));
assert!(!markdown.contains("unavailable"));
}
#[tokio::test]
async fn test_generate_hover_renders_license_changed_from_native_version_list() {
use std::collections::HashMap;
let registry = MockRegistryWithLicensedVersions {
versions: vec![
MockVersionWithLicense {
version: "2.0.0".into(),
license: vec!["Apache-2.0".to_string()],
},
MockVersionWithLicense {
version: "1.0.0".into(),
license: vec!["MIT".to_string()],
},
],
};
let parse_result = freshness_test_parse_result("example");
let mut resolved_versions = HashMap::new();
resolved_versions.insert("example".into(), "1.0.0".into());
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &resolved_versions),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("**License**: `MIT`"),
"got: {}",
content.value
);
assert!(
content.value.contains("License changed"),
"resolved (1.0.0/MIT) and latest (2.0.0/Apache-2.0) licenses differ; got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_does_not_match_a_synthetic_range_dependency_at_position_zero() {
use std::collections::HashMap;
let parse_result = MockMixedParseResult {
deps: vec![
Box::new(MockSyntheticRangeDep {
name: "synthetic-pkg".into(),
}),
Box::new(MockDep {
name: "real-pkg".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(3, 10), Position::new(3, 20)),
name_range: Range::new(Position::new(3, 0), Position::new(3, 8)),
}),
],
uri: crate::test_util::test_uri("/test/pubspec.yaml"),
};
let hover = generate_hover(
&parse_result,
Position::new(0, 0),
VersionData::new(&HashMap::new(), &HashMap::new()),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await;
assert!(
hover.is_none(),
"position (0,0) must not match the synthetic-range dependency's \
Range::default() sentinel"
);
}
#[tokio::test]
async fn test_generate_hover_up_to_date_dependency_shows_no_spurious_unavailable_note() {
use std::collections::HashMap;
let registry = MockRegistryWithLicensedVersions {
versions: vec![MockVersionWithLicense {
version: "1.0.0".into(),
license: vec!["MIT".to_string()],
}],
};
let parse_result = freshness_test_parse_result("example");
let mut resolved_versions = HashMap::new();
resolved_versions.insert("example".into(), "1.0.0".into());
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &resolved_versions),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(content.value.contains("**License**: `MIT`"));
assert!(
!content.value.contains("unavailable"),
"the resolved version is the latest version, so its already-known license \
must be reused rather than reported unavailable; got: {}",
content.value
);
assert!(
!content.value.contains("License changed"),
"resolved and latest are the same version, so no change flag should fire; got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_native_list_license_found_via_concrete_pin_fallback() {
use std::collections::HashMap;
let registry = MockRegistryWithLicensedVersions {
versions: vec![MockVersionWithLicense {
version: "1.0.0".into(),
license: vec!["MIT".to_string()],
}],
};
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "example".into(),
version_req: "=1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 7)),
}],
uri: crate::test_util::test_uri("/test/composer.json"),
};
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new())
.with_ecosystem(crate::EcosystemId::Composer),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
!content.value.contains("**Current**"),
"no lock-file resolution exists, so no Current line should render; got: {}",
content.value
);
assert!(
content.value.contains("**License**: `MIT`"),
"the concrete-pin fallback (=1.0.0) should still resolve a license even \
without a lock-file match; got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_dart_license_from_prefetch_is_labeled_detected() {
use std::collections::HashMap;
let parse_result = freshness_test_parse_result("example");
let mut resolved_versions = HashMap::new();
resolved_versions.insert("example".into(), "1.0.0".into());
let mut licenses = HashMap::new();
licenses.insert(
crate::PackageName::new("example"),
vec!["BSD-3-Clause".to_string()],
);
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &resolved_versions)
.with_ecosystem(crate::EcosystemId::Dart)
.with_license_source(crate::LicenseSource::DetectedSpdx)
.with_license_prefetch(&licenses),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content
.value
.contains("**License (detected)**: `BSD-3-Clause`"),
"got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_swift_license_from_prefetch_is_labeled_detected() {
use std::collections::HashMap;
let parse_result = freshness_test_parse_result("example");
let mut resolved_versions = HashMap::new();
resolved_versions.insert("example".into(), "1.0.0".into());
let mut licenses = HashMap::new();
licenses.insert(
crate::PackageName::new("example"),
vec!["Apache-2.0".to_string()],
);
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &resolved_versions)
.with_ecosystem(crate::EcosystemId::Swift)
.with_license_source(crate::LicenseSource::DetectedSpdx)
.with_license_prefetch(&licenses),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content
.value
.contains("**License (detected)**: `Apache-2.0`"),
"got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_gradle_license_from_prefetch_is_not_labeled_detected() {
use std::collections::HashMap;
let parse_result = freshness_test_parse_result("example");
let mut resolved_versions = HashMap::new();
resolved_versions.insert("example".into(), "1.0.0".into());
let mut licenses = HashMap::new();
licenses.insert(
crate::PackageName::new("example"),
vec!["Apache-2.0".to_string()],
);
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &resolved_versions)
.with_ecosystem(crate::EcosystemId::Gradle)
.with_license_source(crate::LicenseSource::PomFreeText)
.with_license_prefetch(&licenses),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("**License**: `Apache-2.0`"),
"got: {}",
content.value
);
assert!(!content.value.contains("(detected)"));
}
#[tokio::test]
async fn test_generate_hover_gradle_pom_free_text_license_is_normalized() {
use std::collections::HashMap;
let parse_result = freshness_test_parse_result("example");
let mut resolved_versions = HashMap::new();
resolved_versions.insert("example".into(), "1.0.0".into());
let mut licenses = HashMap::new();
licenses.insert(
crate::PackageName::new("example"),
vec!["The Apache Software License, Version 2.0".to_string()],
);
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &resolved_versions)
.with_ecosystem(crate::EcosystemId::Gradle)
.with_license_source(crate::LicenseSource::PomFreeText)
.with_license_prefetch(&licenses),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("**License**: `Apache-2.0`"),
"expected the normalized SPDX id, not raw POM text, got: {}",
content.value
);
assert!(!content.value.contains("The Apache Software License"));
}
#[tokio::test]
async fn test_generate_hover_gradle_unrecognized_pom_license_falls_back_to_raw_text() {
use std::collections::HashMap;
let parse_result = freshness_test_parse_result("example");
let mut resolved_versions = HashMap::new();
resolved_versions.insert("example".into(), "1.0.0".into());
let mut licenses = HashMap::new();
licenses.insert(
crate::PackageName::new("example"),
vec!["Some Bespoke Corporate License".to_string()],
);
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &resolved_versions)
.with_ecosystem(crate::EcosystemId::Gradle)
.with_license_source(crate::LicenseSource::PomFreeText)
.with_license_prefetch(&licenses),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content
.value
.contains("**License**: `Some Bespoke Corporate License`"),
"expected the raw POM text as a fallback, got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_gradle_gpl_family_renders_single_canonical_id() {
use std::collections::HashMap;
let parse_result = freshness_test_parse_result("example");
let mut resolved_versions = HashMap::new();
resolved_versions.insert("example".into(), "1.0.0".into());
let mut licenses = HashMap::new();
licenses.insert(
crate::PackageName::new("example"),
vec!["GNU General Public License v3".to_string()],
);
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &resolved_versions)
.with_ecosystem(crate::EcosystemId::Gradle)
.with_license_source(crate::LicenseSource::PomFreeText)
.with_license_prefetch(&licenses),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("**License**: `GPL-3.0`"),
"expected exactly one canonical id, got: {}",
content.value
);
assert!(
!content.value.contains("GPL-3.0-only") && !content.value.contains("GPL-3.0-or-later"),
"must not render the full policy-matching synonym slice in hover, got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_gradle_disjunctive_license_renders_both_ids() {
use std::collections::HashMap;
let parse_result = freshness_test_parse_result("example");
let mut resolved_versions = HashMap::new();
resolved_versions.insert("example".into(), "1.0.0".into());
let mut licenses = HashMap::new();
licenses.insert(
crate::PackageName::new("example"),
vec!["CDDL + GPLv2 with classpath exception".to_string()],
);
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &resolved_versions)
.with_ecosystem(crate::EcosystemId::Gradle)
.with_license_source(crate::LicenseSource::PomFreeText)
.with_license_prefetch(&licenses),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("CDDL-1.1")
&& content.value.contains("GPL-2.0-with-classpath-exception"),
"expected both disjunctive ids, got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_deps_dev_no_lockfile_exact_pin_matching_latest_reuses_license() {
let (mut server, deps_dev) = deps_dev_mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body(
r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [], "licenses": ["MIT"]}"#,
)
.create_async()
.await;
let deps_dev = Arc::new(deps_dev);
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "express".into(),
version_req: "=4.19.2".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 7)),
}],
uri: crate::test_util::test_uri("/test/package.json"),
};
let registry = MockRegistryWithVersions {
versions: vec![MockVersionWithAge {
version: "4.19.2".into(),
yanked: false,
published_at: None,
}],
};
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new())
.with_ecosystem(crate::EcosystemId::Npm)
.with_trust(&deps_dev),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
!content.value.contains("**Current**"),
"no lock-file resolution exists, so no Current line should render; got: {}",
content.value
);
assert!(
content.value.contains("**License**: `MIT`"),
"got: {}",
content.value
);
assert!(
!content.value.contains("unavailable"),
"the exact pin (4.19.2) equals the only/live-latest version, so the \
already-known license must be reused, not reported unavailable; got: {}",
content.value
);
assert!(!content.value.contains("License changed"));
}
#[test]
fn severity_label_for_malicious_is_distinct_from_unknown_and_every_graded_label() {
let malicious = severity_label(crate::osv::VulnSeverity::Malicious);
assert_ne!(malicious, "unknown severity");
for graded in [
crate::osv::VulnSeverity::Critical,
crate::osv::VulnSeverity::High,
crate::osv::VulnSeverity::Medium,
crate::osv::VulnSeverity::Low,
] {
assert_ne!(malicious, severity_label(graded));
}
}
#[tokio::test]
async fn test_generate_hover_recent_versions_shows_age_when_known() {
use std::collections::HashMap;
let registry = MockRegistryWithVersions {
versions: vec![MockVersionWithAge {
version: "1.2.3".into(),
yanked: false,
published_at: Some(PublishTime::from_unix_secs(
PublishTime::now().as_unix_secs() - 2 * 24 * 60 * 60,
)),
}],
};
let parse_result = freshness_test_parse_result("serde");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("- `1.2.3` *(latest)* — 2 days ago"),
"got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_recent_versions_omits_age_when_unknown() {
use std::collections::HashMap;
let registry = MockRegistryWithVersions {
versions: vec![MockVersionWithAge {
version: "1.2.3".into(),
yanked: false,
published_at: None,
}],
};
let parse_result = freshness_test_parse_result("serde");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(content.value.contains("- `1.2.3` *(latest)*\n"));
assert!(!content.value.contains("ago"));
}
#[tokio::test]
async fn test_generate_hover_latest_marker_skips_prerelease_at_raw_top() {
use std::collections::HashMap;
let registry = MockRegistryWithVersions {
versions: vec![
MockVersionWithAge {
version: "13.0.5-beta1".into(),
yanked: false,
published_at: None,
},
MockVersionWithAge {
version: "13.0.4".into(),
yanked: false,
published_at: None,
},
],
};
let parse_result = freshness_test_parse_result("Newtonsoft.Json");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("**Latest**: `13.0.4`"),
"got: {}",
content.value
);
assert!(
content.value.contains("- `13.0.4` *(latest)*"),
"the stable version, not the raw-top pre-release, should carry the marker; got: {}",
content.value
);
assert!(
!content.value.contains("13.0.5-beta1` *(latest)*"),
"the pre-release must not be tagged latest; got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_latest_marker_bumped_in_when_stable_outside_top_n() {
use std::collections::HashMap;
let mut versions: Vec<MockVersionWithAge> = (0..=HOVER_RECENT_VERSIONS)
.map(|i| MockVersionWithAge {
version: format!("2.0.0-alpha{i}").into(),
yanked: false,
published_at: None,
})
.collect();
versions.push(MockVersionWithAge {
version: "1.9.0".into(),
yanked: false,
published_at: None,
});
let registry = MockRegistryWithVersions { versions };
let parse_result = freshness_test_parse_result("example");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("**Latest**: `1.9.0`"),
"got: {}",
content.value
);
assert!(
content.value.contains("- `1.9.0` *(latest)*"),
"the stable pick must be bumped into the capped list instead of omitted; got: {}",
content.value
);
assert_eq!(
content.value.matches("*(latest)*").count(),
1,
"exactly one entry should carry the marker; got: {}",
content.value
);
assert_eq!(
content
.value
.lines()
.filter(|l| l.starts_with("- `"))
.count(),
HOVER_RECENT_VERSIONS,
"the list must stay capped at HOVER_RECENT_VERSIONS entries even after the bump-in; got: {}",
content.value
);
assert!(
!content.value.contains("2.0.0-alpha7"),
"the displaced 8th natural entry must not remain in the capped list; got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_latest_marker_at_last_window_slot_not_bumped() {
use std::collections::HashMap;
let mut versions: Vec<MockVersionWithAge> = (0..HOVER_RECENT_VERSIONS - 1)
.map(|i| MockVersionWithAge {
version: format!("2.0.0-alpha{i}").into(),
yanked: false,
published_at: None,
})
.collect();
versions.push(MockVersionWithAge {
version: "1.9.0".into(),
yanked: false,
published_at: None,
});
assert_eq!(versions.len(), HOVER_RECENT_VERSIONS);
let registry = MockRegistryWithVersions { versions };
let parse_result = freshness_test_parse_result("example");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert_eq!(
content.value.matches("- `1.9.0` *(latest)*").count(),
1,
"the in-window pick must render exactly once, not duplicated by a bump-in; got: {}",
content.value
);
assert_eq!(
content
.value
.lines()
.filter(|l| l.starts_with("- `"))
.count(),
HOVER_RECENT_VERSIONS,
"got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_latest_marker_one_past_window_triggers_bump() {
use std::collections::HashMap;
let mut versions: Vec<MockVersionWithAge> = (0..HOVER_RECENT_VERSIONS)
.map(|i| MockVersionWithAge {
version: format!("2.0.0-alpha{i}").into(),
yanked: false,
published_at: None,
})
.collect();
versions.push(MockVersionWithAge {
version: "1.9.0".into(),
yanked: false,
published_at: None,
});
assert_eq!(versions.len(), HOVER_RECENT_VERSIONS + 1);
let registry = MockRegistryWithVersions { versions };
let parse_result = freshness_test_parse_result("example");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("- `1.9.0` *(latest)*"),
"the pick at index HOVER_RECENT_VERSIONS must be bumped in; got: {}",
content.value
);
assert!(
!content
.value
.contains(&format!("2.0.0-alpha{}", HOVER_RECENT_VERSIONS - 1)),
"the displaced last natural entry must not remain in the capped list; got: {}",
content.value
);
assert_eq!(
content
.value
.lines()
.filter(|l| l.starts_with("- `"))
.count(),
HOVER_RECENT_VERSIONS,
"got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_latest_marker_flagged_pick_bumped_in_keeps_yanked_label() {
use crate::RemovalStatus;
use std::collections::HashMap;
let mut versions: Vec<MockVersionWithStatus> = (0..HOVER_RECENT_VERSIONS)
.map(|i| MockVersionWithStatus {
version: format!("5.0.{i}").into(),
status: RemovalStatus::Yanked,
})
.collect();
versions.push(MockVersionWithStatus {
version: "5.0.99".into(),
status: RemovalStatus::AdvisoryDeprecated,
});
let registry = MockRegistryPreferringUnflagged { versions };
let parse_result = freshness_test_parse_result("example");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("- `5.0.99` *(latest)* *(yanked)*"),
"the bumped-in flagged pick must carry both the latest marker and its flag label; got: {}",
content.value
);
assert!(
!content.value.contains("5.0.7`"),
"the displaced last natural entry must not remain in the capped list; got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_latest_marker_all_prerelease_degrades_gracefully() {
use std::collections::HashMap;
let registry = MockRegistryWithVersions {
versions: vec![
MockVersionWithAge {
version: "2.0.0-beta2".into(),
yanked: false,
published_at: None,
},
MockVersionWithAge {
version: "2.0.0-beta1".into(),
yanked: false,
published_at: None,
},
MockVersionWithAge {
version: "1.9.0-alpha1".into(),
yanked: false,
published_at: None,
},
],
};
let parse_result = freshness_test_parse_result("example");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor, not panic");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
!content.value.contains("**Latest**:"),
"no stable version exists, so the header should be omitted rather than picking a pre-release; got: {}",
content.value
);
assert!(
!content.value.contains("*(latest)*"),
"no stable version exists, so no entry in the list should be marked latest; got: {}",
content.value
);
assert!(
content.value.contains("2.0.0-beta2"),
"the raw version list should still render even without a latest marker; got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_latest_marker_all_prerelease_live_list_ignores_stale_cache() {
use std::collections::HashMap;
let registry = MockRegistryWithVersions {
versions: vec![MockVersionWithAge {
version: "2.0.0-beta2".into(),
yanked: false,
published_at: None,
}],
};
let parse_result = freshness_test_parse_result("example");
let mut cached_versions = HashMap::new();
cached_versions.insert("example".into(), PackageVersions::latest_only("1.5.0"));
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor, not panic");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
!content.value.contains("**Latest**:"),
"a live fetch with no stable entry must not fall back to a stale cached version \
that isn't in the live list; got: {}",
content.value
);
assert!(
!content.value.contains("1.5.0"),
"the stale cached version must not leak into the response at all; got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_latest_falls_back_to_get_latest_matching_when_list_pick_fails() {
use std::collections::HashMap;
let registry = MockRegistryListFailsLatestFallbackSucceeds {
versions: vec![MockVersionWithAge {
version: "v0.0.0-20230101000000-abcdef123456".into(),
yanked: false,
published_at: None,
}],
fallback_latest: MockVersionWithAge {
version: "v1.2.3".into(),
yanked: false,
published_at: None,
},
list_pick_index: None,
get_latest_matching_calls: std::sync::atomic::AtomicUsize::new(0),
};
let parse_result = freshness_test_parse_result("example.com/mod");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("**Latest**: `v1.2.3`"),
"the list-based pick failed, so hover must fall back to get_latest_matching's \
result instead of omitting the Latest line; got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_does_not_call_fallback_when_list_pick_succeeds() {
use std::collections::HashMap;
use std::sync::atomic::Ordering;
let registry = MockRegistryListFailsLatestFallbackSucceeds {
versions: vec![MockVersionWithAge {
version: "v1.2.3".into(),
yanked: false,
published_at: None,
}],
fallback_latest: MockVersionWithAge {
version: "v9.9.9".into(),
yanked: false,
published_at: None,
},
list_pick_index: Some(0),
get_latest_matching_calls: std::sync::atomic::AtomicUsize::new(0),
};
let parse_result = freshness_test_parse_result("example.com/mod");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("**Latest**: `v1.2.3`"),
"expected the list-based pick's own version, not the fallback's; got: {}",
content.value
);
assert_eq!(
registry.get_latest_matching_calls.load(Ordering::Relaxed),
0,
"get_latest_matching must not be called when the list-based pick already succeeded"
);
}
#[tokio::test]
async fn test_generate_hover_renders_basic_card_when_fetch_fails() {
use std::collections::HashMap;
let parse_result = freshness_test_parse_result("serde");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
&ErrorRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover must still render on a fetch failure, not disappear entirely");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("serde"),
"basic card must still render the package name; got: {}",
content.value
);
assert!(
!content.value.contains("**Latest**"),
"no version data is available on a fetch failure; got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_go_prefers_manifest_requirement_over_stale_resolved_version() {
use std::collections::HashMap;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "example.com/mod".into(),
version_req: "v0.8.1".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 16)),
}],
uri: crate::test_util::test_uri("/test/go.mod"),
};
let mut resolved_versions = HashMap::new();
resolved_versions.insert("example.com/mod".into(), "v0.9.1".into());
let cached_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
&MockRegistry,
&MockGoFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("**Current**: `v0.8.1`"),
"expected hover to show go.mod's pinned version, got: {}",
content.value
);
assert!(
!content.value.contains("v0.9.1"),
"hover must not surface the stale go.sum version: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_non_go_formatter_uses_resolved_lockfile_version() {
use std::collections::HashMap;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut resolved_versions = HashMap::new();
resolved_versions.insert("serde".into(), "1.2.0".into());
let cached_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("**Current**: `1.2.0`"),
"expected hover to show the resolved lockfile version, got: {}",
content.value
);
assert!(!content.value.contains("**Current**: `1.0.0`"));
}
#[tokio::test]
async fn test_generate_hover_recent_versions_preserves_yanked_marker_with_age() {
use std::collections::HashMap;
let registry = MockRegistryWithVersions {
versions: vec![
MockVersionWithAge {
version: "1.2.3".into(),
yanked: false,
published_at: None,
},
MockVersionWithAge {
version: "1.2.1".into(),
yanked: true,
published_at: Some(PublishTime::from_unix_secs(
PublishTime::now().as_unix_secs() - 5 * 30 * 24 * 60 * 60,
)),
},
],
};
let parse_result = freshness_test_parse_result("serde");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content
.value
.contains("- `1.2.1` *(yanked)* — 5 months ago"),
"got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_recent_versions_respects_freshness_disabled() {
use std::collections::HashMap;
let registry = MockRegistryWithVersions {
versions: vec![MockVersionWithAge {
version: "1.2.3".into(),
yanked: false,
published_at: Some(PublishTime::from_unix_secs(
PublishTime::now().as_unix_secs() - 2 * 24 * 60 * 60,
)),
}],
};
let parse_result = freshness_test_parse_result("serde");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings {
enabled: false,
cooldown_secs: crate::freshness::DEFAULT_COOLDOWN_SECS,
},
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(content.value.contains("- `1.2.3` *(latest)*\n"));
assert!(!content.value.contains("ago"));
}
#[tokio::test]
async fn test_generate_hover_latest_line_shows_age_and_cooldown_callout_when_within_cooldown() {
use std::collections::HashMap;
let parse_result = freshness_test_parse_result("serde");
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".into(),
available: Arc::from(vec!["2.0.0".into()]),
yanked: Arc::from(Vec::new()),
published_at: Some(PublishTime::from_unix_secs(
PublishTime::now().as_unix_secs() - 60 * 60,
)),
},
);
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content
.value
.contains("**Latest**: `2.0.0` *(published 1 hour ago)*"),
"got: {}",
content.value
);
assert!(
content.value.contains(
"> ⏳ **Recently published** — this release is still within the cooldown window."
),
"got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_latest_line_no_callout_when_outside_cooldown() {
use std::collections::HashMap;
let parse_result = freshness_test_parse_result("serde");
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".into(),
available: Arc::from(vec!["2.0.0".into()]),
yanked: Arc::from(Vec::new()),
published_at: Some(PublishTime::from_unix_secs(
PublishTime::now().as_unix_secs() - 10 * 24 * 60 * 60,
)),
},
);
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content
.value
.contains("**Latest**: `2.0.0` *(published 1 week ago)*"),
"got: {}",
content.value
);
assert!(!content.value.contains("Recently published"));
}
#[tokio::test]
async fn test_generate_hover_latest_line_omits_age_when_published_at_unknown() {
use std::collections::HashMap;
let parse_result = freshness_test_parse_result("serde");
let mut cached_versions = HashMap::new();
cached_versions.insert("serde".into(), PackageVersions::latest_only("2.0.0"));
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(content.value.contains("**Latest**: `2.0.0`\n\n"));
assert!(!content.value.contains("published"));
assert!(!content.value.contains("Recently published"));
}
#[tokio::test]
async fn test_generate_hover_latest_line_respects_freshness_disabled() {
use std::collections::HashMap;
let parse_result = freshness_test_parse_result("serde");
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".into(),
available: Arc::from(vec!["2.0.0".into()]),
yanked: Arc::from(Vec::new()),
published_at: Some(PublishTime::from_unix_secs(
PublishTime::now().as_unix_secs() - 60 * 60,
)),
},
);
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings {
enabled: false,
cooldown_secs: crate::freshness::DEFAULT_COOLDOWN_SECS,
},
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(content.value.contains("**Latest**: `2.0.0`\n\n"));
assert!(!content.value.contains("published"));
assert!(!content.value.contains("Recently published"));
}
#[tokio::test]
async fn test_generate_hover_latest_line_cooldown_boundary_is_exclusive() {
use std::collections::HashMap;
const COOLDOWN_SECS: u64 = 100;
let now = PublishTime::from_unix_secs(10_000);
let published_at_at_boundary =
PublishTime::from_unix_secs(10_000 - COOLDOWN_SECS.cast_signed());
let parse_result = freshness_test_parse_result("serde");
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".into(),
available: Arc::from(vec!["2.0.0".into()]),
yanked: Arc::from(Vec::new()),
published_at: Some(published_at_at_boundary),
},
);
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings {
enabled: true,
cooldown_secs: COOLDOWN_SECS,
},
now,
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
!content.value.contains("Recently published"),
"age exactly equal to cooldown_secs must not be within cooldown, got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_latest_line_cooldown_boundary_one_second_inside_shows_callout() {
use std::collections::HashMap;
const COOLDOWN_SECS: u64 = 100;
let now = PublishTime::from_unix_secs(10_000);
let published_at_just_inside =
PublishTime::from_unix_secs(10_000 - (COOLDOWN_SECS.cast_signed() - 1));
let parse_result = freshness_test_parse_result("serde");
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".into(),
available: Arc::from(vec!["2.0.0".into()]),
yanked: Arc::from(Vec::new()),
published_at: Some(published_at_just_inside),
},
);
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings {
enabled: true,
cooldown_secs: COOLDOWN_SECS,
},
now,
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("Recently published"),
"age == cooldown_secs - 1 must be within cooldown, got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_latest_line_prefers_live_fetch_over_stale_ch1_cache() {
use std::collections::HashMap;
let now = PublishTime::now();
let live_latest_published = PublishTime::from_unix_secs(now.as_unix_secs() - 60 * 60);
let registry = MockRegistryWithVersions {
versions: vec![
MockVersionWithAge {
version: "1.0.214".into(),
yanked: false,
published_at: Some(live_latest_published),
},
MockVersionWithAge {
version: "1.0.213".into(),
yanked: false,
published_at: Some(PublishTime::from_unix_secs(
now.as_unix_secs() - 30 * 24 * 60 * 60,
)),
},
],
};
let parse_result = freshness_test_parse_result("serde");
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "1.0.213".into(),
available: Arc::from(vec!["1.0.213".into()]),
yanked: Arc::from(Vec::new()),
published_at: Some(PublishTime::from_unix_secs(
now.as_unix_secs() - 90 * 24 * 60 * 60,
)),
},
);
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
now,
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content
.value
.contains("**Latest**: `1.0.214` *(published 1 hour ago)*"),
"Latest line must reflect the live Ch2 fetch, not the stale Ch1 cache entry \
`1.0.213`, got: {}",
content.value
);
assert!(
!content.value.contains("**Latest**: `1.0.213`"),
"must not render the stale Ch1 version, got: {}",
content.value
);
assert!(
content
.value
.contains("- `1.0.214` *(latest)* — 1 hour ago"),
"the Recent versions list's own *(latest)* entry must agree with the Latest \
line above it, got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_latest_agrees_with_npm_shaped_deprecated_ranking() {
use std::collections::HashMap;
let now = PublishTime::now();
let registry = MockRegistryPreferringUnflagged {
versions: vec![
MockVersionWithStatus {
version: "2.0.0".into(),
status: RemovalStatus::AdvisoryDeprecated,
},
MockVersionWithStatus {
version: "1.9.0".into(),
status: RemovalStatus::Available,
},
],
};
let parse_result = freshness_test_parse_result("pkg");
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
now,
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("**Latest**: `1.9.0`"),
"hover must agree with select_latest_matching's non-deprecated-preferred pick \
(1.9.0), not a naive is_stable() scan that would pick the newer but deprecated \
2.0.0, got: {}",
content.value
);
assert!(
content.value.contains("- `1.9.0` *(latest)*"),
"the Recent versions list's own *(latest)* marker must agree with the Latest \
line above it, got: {}",
content.value
);
assert!(
content.value.contains("- `2.0.0` *(yanked)*"),
"2.0.0 must keep its flagged label even though it isn't the resolved latest, \
got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_latest_keeps_flagged_label_when_resolved_version_is_flagged() {
use std::collections::HashMap;
let now = PublishTime::now();
let registry = MockRegistryPreferringUnflagged {
versions: vec![MockVersionWithStatus {
version: "2.0.0".into(),
status: RemovalStatus::AdvisoryDeprecated,
}],
};
let parse_result = freshness_test_parse_result("pkg");
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
now,
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("**Latest**: `2.0.0`"),
"the only version resolves as latest even though it's flagged, got: {}",
content.value
);
assert!(
content.value.contains("- `2.0.0` *(latest)* *(yanked)*"),
"the resolved latest must keep its flagged label instead of the warning \
silently vanishing behind *(latest)*, got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_latest_resolves_when_all_versions_yanked() {
use std::collections::HashMap;
let now = PublishTime::now();
let registry = MockRegistryPreferringUnflagged {
versions: vec![
MockVersionWithStatus {
version: "2.0.0".into(),
status: RemovalStatus::Yanked,
},
MockVersionWithStatus {
version: "1.9.0".into(),
status: RemovalStatus::Yanked,
},
],
};
let parse_result = freshness_test_parse_result("pkg");
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
now,
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("**Latest**: `2.0.0`"),
"an all-yanked package still exists: hover must resolve the newest yanked \
version as latest rather than showing no Latest line, got: {}",
content.value
);
assert!(
content.value.contains("- `2.0.0` *(latest)* *(yanked)*"),
"the resolved latest must keep its yanked label, got: {}",
content.value
);
}
struct NpmLikeFormatter;
impl PackageNaming for NpmLikeFormatter {}
impl PackageRendering for NpmLikeFormatter {
fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
version.to_string()
}
fn package_url(&self, name: &crate::PackageName) -> String {
format!("https://example.com/{name}")
}
}
impl RequirementResolution for NpmLikeFormatter {}
impl DiagnosticMessages for NpmLikeFormatter {
fn yanked_label(&self) -> &'static str {
"*(deprecated)*"
}
}
impl DiagnosticPolicy for NpmLikeFormatter {}
impl SourcePolicy for NpmLikeFormatter {}
impl OsvNaming for NpmLikeFormatter {}
#[tokio::test]
async fn test_generate_hover_deprecated_section_and_per_row_labels_both_render() {
use std::collections::HashMap;
let now = PublishTime::now();
let registry = MockRegistryPreferringUnflagged {
versions: vec![MockVersionWithStatus {
version: "1.0.0".into(),
status: RemovalStatus::AdvisoryDeprecated,
}],
};
let parse_result = freshness_test_parse_result("pkg");
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let outcomes = crate::lsp_helpers::DependencyOutcomes::new().with_deprecation(
"pkg",
crate::Deprecation {
reason: Some("no longer maintained".to_string()),
replacement: None,
},
);
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
®istry,
&NpmLikeFormatter,
crate::freshness::FreshnessSettings::default(),
now,
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("### Deprecated"),
"expected the package-level Deprecated section, got: {}",
content.value
);
assert!(
content.value.contains("no longer maintained"),
"expected the deprecation reason, got: {}",
content.value
);
assert!(
content
.value
.contains("This package is deprecated\n\nno longer maintained"),
"expected the message and reason on separate paragraphs, got: {}",
content.value
);
assert!(
content
.value
.contains("- `1.0.0` *(latest)* *(deprecated)*"),
"expected the pre-existing per-row label to still render alongside the new \
section (deliberately not deduped, S4), got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_surfaces_markers() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let parse_result = MockMarkedParseResult {
dep: MockMarkedDep {
name: "numpy".into(),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
markers: Some("python_full_version >= '3.9'".to_string()),
},
uri: crate::test_util::test_uri("/test/pyproject.toml"),
};
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content
.value
.contains("**Active when**: `python_full_version >= '3.9'`")
);
}
#[tokio::test]
async fn test_generate_hover_omits_active_when_without_markers() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let parse_result = MockMarkedParseResult {
dep: MockMarkedDep {
name: "requests".into(),
name_range: Range::new(Position::new(0, 0), Position::new(0, 8)),
markers: None,
},
uri: crate::test_util::test_uri("/test/pyproject.toml"),
};
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(!content.value.contains("Active when"));
}
#[tokio::test]
async fn test_generate_hover_escapes_malicious_dependency_name() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let malicious_name = "real-pkg](https://legit-looking-typosquat.example/download)[real-pkg";
let parse_result = MockParseResult {
deps: vec![MockDep {
name: malicious_name.into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(
Position::new(0, 0),
Position::new(0, malicious_name.len() as u32),
),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
let header_line = content
.value
.lines()
.next()
.expect("hover markdown has a header line");
let label = header_line
.strip_prefix("# [")
.expect("header starts with link label")
.split("](")
.next()
.expect("header contains label/url separator");
assert_eq!(
label,
r"real\-pkg\]\(https\:\/\/legit\-looking\-typosquat\.example\/download\)\[real\-pkg"
);
}
#[tokio::test]
async fn test_generate_hover_newline_in_name_cannot_forge_new_heading() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let malicious_name = "react\n# [fake](https://evil.example) <https://evil.example>";
let parse_result = MockParseResult {
deps: vec![MockDep {
name: malicious_name.into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(
Position::new(0, 0),
Position::new(0, malicious_name.len() as u32),
),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
let header_line = content
.value
.lines()
.next()
.expect("hover markdown has a header line");
let label = header_line
.strip_prefix("# [")
.expect("header starts with link label")
.split("](")
.next()
.expect("header contains label/url separator");
assert_eq!(label, escape_markdown(malicious_name));
assert!(!label.contains('\n'));
assert!(label.contains(r"\<https"));
}
#[tokio::test]
async fn test_generate_hover_marker_with_parens_renders_unescaped() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let marker = "python_version >= \"3.8\" and (sys_platform == \"linux\")";
let parse_result = MockMarkedParseResult {
dep: MockMarkedDep {
name: "numpy".into(),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
markers: Some(marker.to_string()),
},
uri: crate::test_util::test_uri("/test/pyproject.toml"),
};
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content
.value
.contains(&format!("**Active when**: `{marker}`"))
);
}
#[tokio::test]
async fn test_generate_hover_registry_sections_suppressed_for_non_registry_sources() {
use crate::parser::DependencySource;
let registry = MockRegistryWithVersions {
versions: vec![MockVersionWithAge {
version: "9.9.9".into(),
yanked: false,
published_at: None,
}],
};
let cached_versions = {
let mut m = HashMap::new();
m.insert("dep".into(), PackageVersions::latest_only("9.9.9"));
m
};
let resolved_versions = HashMap::new();
let uri = crate::test_util::test_uri("/test/Cargo.toml");
let parse_result = SingleDepParseResult {
dep: NonRegistryDep(
dep_at("dep"),
DependencySource::CustomRegistry {
url: "my-corp".into(),
},
),
uri: uri.clone(),
};
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should still be generated for a non-resolvable-source dependency");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(!content.value.contains("**Latest**"));
assert!(!content.value.contains("**Recent versions**"));
assert!(content.value.contains("**Requirement**"));
let registry_parse_result = SingleDepParseResult {
dep: dep_at("dep"),
uri,
};
let hover = generate_hover(
®istry_parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(content.value.contains("**Latest**"));
assert!(content.value.contains("**Recent versions**"));
}
#[tokio::test]
async fn test_generate_hover_clean_outcome_states_no_known_vulnerabilities() {
use crate::osv::{ScanOutcome, VulnerabilityMap};
let parse_result = MockParseResult {
deps: vec![dep_at("clean-pkg")],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert("clean-pkg".to_string(), ScanOutcome::Clean);
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&MockRegistry,
&MockFormatter,
crate::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(content.value.contains("No known vulnerabilities"));
}
#[tokio::test]
async fn test_generate_hover_skipped_outcome_says_nothing_about_vulnerabilities() {
use crate::osv::{ScanOutcome, SkipReason, VulnerabilityMap};
let parse_result = MockParseResult {
deps: vec![dep_at("path-pkg")],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
"path-pkg".to_string(),
ScanOutcome::Skipped(SkipReason::NonRegistrySource),
);
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&MockRegistry,
&MockFormatter,
crate::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(!content.value.contains("Security advisories"));
assert!(!content.value.contains("No known vulnerabilities"));
}
#[tokio::test]
async fn test_generate_hover_vulnerable_outcome_shows_advisories_and_more_count() {
use crate::osv::{
Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity,
VulnerabilityMap,
};
let parse_result = MockParseResult {
deps: vec![dep_at("bad-pkg")],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
"bad-pkg".to_string(),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories: Capped::new(
vec![sample_advisory("RUSTSEC-2020-0071", VulnSeverity::Critical)],
3,
),
fix_target_status: UpgradeStatus::NotChecked,
upgrade_status: UpgradeStatus::CandidateVulnerable {
version: "2.0.0".into(),
advisory_ids: Capped::new(vec!["RUSTSEC-2020-0071".to_string()], 1),
},
}),
);
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&MockRegistry,
&MockFormatter,
crate::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(content.value.contains("Security advisories"));
assert!(content.value.contains("RUSTSEC-2020-0071"));
assert!(content.value.contains("Fixed in"));
assert!(
content.value.contains("1.5.0"),
"must show highest fixed version"
);
assert!(content.value.contains("+2 more advisories"));
assert!(content.value.contains("also affected"));
}
#[tokio::test]
async fn test_generate_hover_malicious_advisory_never_renders_unknown_severity() {
use crate::osv::{
Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity,
VulnerabilityMap,
};
let parse_result = MockParseResult {
deps: vec![dep_at("bad-pkg")],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
"bad-pkg".to_string(),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories: Capped::new(
vec![sample_advisory("MAL-2025-47141", VulnSeverity::Malicious)],
1,
),
fix_target_status: UpgradeStatus::NotChecked,
upgrade_status: UpgradeStatus::NotChecked,
}),
);
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&MockRegistry,
&MockFormatter,
crate::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(content.value.contains("MAL-2025-47141"));
assert!(content.value.contains("confirmed malicious package"));
assert!(!content.value.contains("unknown severity"));
}
#[tokio::test]
async fn test_generate_hover_vulnerability_not_shared_across_duplicate_name_occurrences() {
use crate::osv::{
Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity,
VulnerabilityMap,
};
let vulnerable_dep = MockDep {
name: "pkg".into(),
version_req: "=1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
};
let patched_dep = MockDep {
name: "pkg".into(),
version_req: "=2.0.0".into(),
version_range: Range::new(Position::new(3, 10), Position::new(3, 20)),
name_range: Range::new(Position::new(3, 0), Position::new(3, 5)),
};
let parse_result = MockParseResult {
deps: vec![vulnerable_dep, patched_dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let keys = crate::osv::vulnerability_keys(
&parse_result,
&resolved_versions,
None,
&MockFormatter,
crate::EcosystemId::Cargo,
);
let deps = parse_result.dependencies();
let vulnerable_key = keys.get(&deps[0].name_range()).unwrap().clone();
let patched_key = keys.get(&deps[1].name_range()).unwrap().clone();
assert_ne!(vulnerable_key, patched_key);
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
vulnerable_key,
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories: Capped::new(
vec![sample_advisory("RUSTSEC-2020-0071", VulnSeverity::Critical)],
1,
),
fix_target_status: UpgradeStatus::NotChecked,
upgrade_status: UpgradeStatus::NotChecked,
}),
);
vulns.insert(patched_key, ScanOutcome::Clean);
let versions = VersionData::new(&cached_versions, &resolved_versions)
.with_vulnerabilities(&vulns)
.with_ecosystem(crate::EcosystemId::Cargo);
let hover_on_patched = generate_hover(
&parse_result,
Position::new(3, 2),
versions,
&MockRegistry,
&MockFormatter,
crate::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(patched_content) = hover_on_patched.contents else {
panic!("expected markup hover contents");
};
assert!(
!patched_content.value.contains("RUSTSEC-2020-0071"),
"the patched occurrence must not show the other occurrence's advisory: {}",
patched_content.value
);
assert!(patched_content.value.contains("No known vulnerabilities"));
let hover_on_vulnerable = generate_hover(
&parse_result,
Position::new(0, 2),
versions,
&MockRegistry,
&MockFormatter,
crate::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(vulnerable_content) = hover_on_vulnerable.contents else {
panic!("expected markup hover contents");
};
assert!(vulnerable_content.value.contains("RUSTSEC-2020-0071"));
}
#[tokio::test]
async fn test_generate_hover_attributed_via_resolved_version_candidates() {
use crate::osv::{
Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity,
VulnerabilityMap,
};
let current_major = MockDep {
name: "serde".into(),
version_req: "1.0".into(),
version_range: Range::new(Position::new(0, 8), Position::new(0, 13)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
};
let renamed_old_major = MockDep {
name: "serde".into(),
version_req: "0.9".into(),
version_range: Range::new(Position::new(1, 8), Position::new(1, 13)),
name_range: Range::new(Position::new(1, 0), Position::new(1, 9)),
};
let parse_result = MockParseResult {
deps: vec![current_major, renamed_old_major],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let mut resolved_versions = HashMap::new();
resolved_versions.insert("serde".into(), ConcreteVersion::from("1.0.219"));
let mut resolved_version_candidates = HashMap::new();
resolved_version_candidates.insert(
"serde".into(),
vec![
ConcreteVersion::from("0.9.15"),
ConcreteVersion::from("1.0.219"),
],
);
let keys = crate::osv::vulnerability_keys(
&parse_result,
&resolved_versions,
Some(&resolved_version_candidates),
&MockFormatter,
crate::EcosystemId::Cargo,
);
let deps = parse_result.dependencies();
let current_key = keys.get(&deps[0].name_range()).unwrap().clone();
let renamed_key = keys.get(&deps[1].name_range()).unwrap().clone();
assert_ne!(current_key, renamed_key);
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
current_key,
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories: Capped::new(
vec![sample_advisory("RUSTSEC-2020-0071", VulnSeverity::Critical)],
1,
),
fix_target_status: UpgradeStatus::NotChecked,
upgrade_status: UpgradeStatus::NotChecked,
}),
);
vulns.insert(renamed_key, ScanOutcome::Clean);
let versions = VersionData::new(&cached_versions, &resolved_versions)
.with_resolved_version_candidates(&resolved_version_candidates)
.with_vulnerabilities(&vulns)
.with_ecosystem(crate::EcosystemId::Cargo);
let hover_on_renamed = generate_hover(
&parse_result,
Position::new(1, 2),
versions,
&MockRegistry,
&MockFormatter,
crate::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(renamed_content) = hover_on_renamed.contents else {
panic!("expected markup hover contents");
};
assert!(
renamed_content.value.contains("0.9.15"),
"renamed occurrence must show its own resolved version, not the collapsed 1.0.219: {}",
renamed_content.value
);
assert!(
!renamed_content.value.contains("RUSTSEC-2020-0071"),
"the renamed (0.9) occurrence must not show the other occurrence's advisory: {}",
renamed_content.value
);
assert!(renamed_content.value.contains("No known vulnerabilities"));
let hover_on_current = generate_hover(
&parse_result,
Position::new(0, 2),
versions,
&MockRegistry,
&MockFormatter,
crate::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(current_content) = hover_on_current.contents else {
panic!("expected markup hover contents");
};
assert!(current_content.value.contains("1.0.219"));
assert!(current_content.value.contains("RUSTSEC-2020-0071"));
}
#[tokio::test]
async fn test_generate_hover_renders_basic_card_when_registry_reports_not_found() {
let parse_result = MockParseResult {
deps: vec![dep_at("com.example:..")],
uri: crate::test_util::test_uri("/test/pom.xml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
&NotFoundRegistry,
&MockFormatter,
crate::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover must still render the basic card on a registry error");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
!content.value.contains("**Latest**"),
"no version data is available on a registry error; got: {}",
content.value
);
assert!(
!content.value.contains("Recent versions"),
"must not render a broken version section from an empty list; got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_footer_shown_for_resolvable_source_with_live_versions() {
let registry = MockRegistryWithVersions {
versions: vec![MockVersionWithAge {
version: "1.2.3".into(),
yanked: false,
published_at: None,
}],
};
let parse_result = freshness_test_parse_result("serde");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("Press `Cmd+.` to update version"),
"a resolvable source with live version data must show the update footer; got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_footer_omitted_for_non_resolvable_source() {
use crate::parser::DependencySource;
let uri = crate::test_util::test_uri("/test/workflow.yml");
let parse_result = SingleDepParseResult {
dep: NonRegistryDep(
dep_at("local-action"),
DependencySource::Path {
path: "./local-action".into(),
},
),
uri,
};
let cached_versions = {
let mut m = HashMap::new();
m.insert("local-action".into(), PackageVersions::latest_only("9.9.9"));
m
};
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should still be generated for a non-resolvable-source dependency");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
!content.value.contains("Press `Cmd+.` to update version"),
"a non-resolvable source offers no update code action, even with a cached \
latest value present; got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_footer_omitted_when_offline_with_no_cached_data() {
use crate::osv::{ScanOutcome, SkipReason, VulnerabilityMap};
let parse_result = freshness_test_parse_result("serde");
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
"serde".to_string(),
ScanOutcome::Skipped(SkipReason::QueryFailed),
);
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new())
.with_offline(true)
.with_vulnerabilities(&vulns),
&ErrorRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
!content.value.contains("Press `Cmd+.` to update version"),
"no code action can be produced while offline with nothing cached, so the \
footer must not render; got: {}",
content.value
);
assert!(
content
.value
.contains("📴 *Offline: version and vulnerability data not checked*"),
"the existing offline notice must still render; got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_footer_shown_when_offline_with_warm_cache_versions() {
let registry = MockRegistryWithVersions {
versions: vec![MockVersionWithAge {
version: "1.2.3".into(),
yanked: false,
published_at: None,
}],
};
let parse_result = freshness_test_parse_result("serde");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()).with_offline(true),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("Press `Cmd+.` to update version"),
"a warm-cache live version list still offers a real REFACTOR action while \
offline, so the footer must render; got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_offline_footer_shown_for_resolvable_source() {
let registry = MockRegistryWithVersions {
versions: vec![MockVersionWithAge {
version: "1.2.3".into(),
yanked: false,
published_at: None,
}],
};
let parse_result = freshness_test_parse_result("serde");
let cached = HashMap::new();
let resolved = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached, &resolved).with_offline(true),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content
.value
.contains("Offline: version and vulnerability data not checked"),
"a resolvable source must show the offline footer when versions.offline is set; \
got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_offline_footer_omitted_for_non_resolvable_source() {
use crate::parser::DependencySource;
let uri = crate::test_util::test_uri("/test/workflow.yml");
let parse_result = SingleDepParseResult {
dep: NonRegistryDep(
dep_at("local-action"),
DependencySource::Path {
path: "./local-action".into(),
},
),
uri,
};
let cached_versions = {
let mut m = HashMap::new();
m.insert("local-action".into(), PackageVersions::latest_only("9.9.9"));
m
};
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions).with_offline(true),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should still be generated for a non-resolvable-source dependency");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
!content.value.contains("Offline:"),
"a non-resolvable source must not show the offline footer, even with \
versions.offline set; got: {}",
content.value
);
}
async fn deps_dev_mock_client() -> (mockito::ServerGuard, crate::DepsDevClient) {
let server = mockito::Server::new_async().await;
let client =
crate::DepsDevClient::for_test(Arc::new(crate::HttpCache::new()), server.url());
(server, client)
}
fn express_fixture() -> (
MockParseResult,
HashMap<crate::PackageName, ConcreteVersion>,
) {
let parse_result = freshness_test_parse_result("express");
let resolved_versions = HashMap::from([(
crate::PackageName::new("express"),
ConcreteVersion::new("4.19.2"),
)]);
(parse_result, resolved_versions)
}
#[tokio::test]
async fn test_generate_hover_trust_signal_renders_score_and_verified_provenance() {
let (mut server, deps_dev) = deps_dev_mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body(
r#"{"slsaProvenances": [{"verified": true}], "attestations": [], "relatedProjects": [
{"projectKey": {"id": "github.com/expressjs/express"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
]}"#,
)
.create_async()
.await;
let _project = server
.mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
.with_status(200)
.with_body(r#"{"scorecard": {"overallScore": 8.5}}"#)
.create_async()
.await;
let deps_dev = Arc::new(deps_dev);
let (parse_result, resolved_versions) = express_fixture();
let registry = MockRegistryWithVersions { versions: vec![] };
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &resolved_versions)
.with_ecosystem(crate::EcosystemId::Npm)
.with_trust(&deps_dev),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
let line = content
.value
.lines()
.find(|l| l.contains("Supply chain"))
.unwrap_or_else(|| panic!("expected a Supply chain line, got: {}", content.value));
insta::assert_snapshot!(line, @"🔐 **Supply chain**: OpenSSF Scorecard `8.5`/10 · Provenance: verified");
}
#[tokio::test]
async fn test_generate_hover_deps_dev_license_renders_license_line() {
let (mut server, deps_dev) = deps_dev_mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body(
r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [], "licenses": ["MIT"]}"#,
)
.create_async()
.await;
let deps_dev = Arc::new(deps_dev);
let (parse_result, resolved_versions) = express_fixture();
let registry = MockRegistryWithVersions { versions: vec![] };
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &resolved_versions)
.with_ecosystem(crate::EcosystemId::Npm)
.with_trust(&deps_dev),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("**License**: `MIT`"),
"expected the deps.dev-sourced license to render; got: {}",
content.value
);
assert!(
!content.value.contains("unavailable"),
"no latest version exists to compare against; got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_trust_signal_unverified_provenance() {
let (mut server, deps_dev) = deps_dev_mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body(
r#"{"slsaProvenances": [{"verified": false}], "attestations": [], "relatedProjects": []}"#,
)
.create_async()
.await;
let deps_dev = Arc::new(deps_dev);
let (parse_result, resolved_versions) = express_fixture();
let registry = MockRegistryWithVersions { versions: vec![] };
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &resolved_versions)
.with_ecosystem(crate::EcosystemId::Npm)
.with_trust(&deps_dev),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content
.value
.contains("Provenance: attested but unverified"),
"got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_trust_signal_none_provenance_and_self_reported_marker() {
let (mut server, deps_dev) = deps_dev_mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body(
r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
{"projectKey": {"id": "github.com/expressjs/express"}, "relationType": "SOURCE_REPO", "relationProvenance": "UNVERIFIED_METADATA"}
]}"#,
)
.create_async()
.await;
let _project = server
.mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
.with_status(200)
.with_body(r#"{"scorecard": {"overallScore": 7.2}}"#)
.create_async()
.await;
let deps_dev = Arc::new(deps_dev);
let (parse_result, resolved_versions) = express_fixture();
let registry = MockRegistryWithVersions { versions: vec![] };
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &resolved_versions)
.with_ecosystem(crate::EcosystemId::Npm)
.with_trust(&deps_dev),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("Provenance: none found"),
"got: {}",
content.value
);
assert!(
content.value.contains("*(self-reported repo)*"),
"an UNVERIFIED_METADATA-only relation must be disclosed; got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_trust_signal_skips_uncovered_ecosystems() {
for ecosystem in [
crate::EcosystemId::Composer,
crate::EcosystemId::Dart,
crate::EcosystemId::Swift,
] {
let (mut server, deps_dev) = deps_dev_mock_client().await;
let never_called = server
.mock("GET", mockito::Matcher::Regex("^/v3/.*".into()))
.expect(0)
.create_async()
.await;
let deps_dev = Arc::new(deps_dev);
let (parse_result, resolved_versions) = express_fixture();
let registry = MockRegistryWithVersions { versions: vec![] };
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &resolved_versions)
.with_ecosystem(ecosystem)
.with_trust(&deps_dev),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
!content.value.contains("Supply chain"),
"{ecosystem:?}: got: {}",
content.value
);
never_called.assert_async().await;
}
}
#[tokio::test]
async fn test_generate_hover_trust_signal_skips_private_registry_source() {
let (mut server, deps_dev) = deps_dev_mock_client().await;
let never_called = server
.mock("GET", mockito::Matcher::Regex("^/v3/.*".into()))
.expect(0)
.create_async()
.await;
let deps_dev = Arc::new(deps_dev);
let parse_result = SingleDepParseResult {
dep: NonRegistryDep(
dep_at("internal-pkg"),
crate::parser::DependencySource::AlternateRegistry {
index: "https://index.mycorp.internal".to_string(),
mirrors_crates_io: false,
},
),
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let resolved_versions = HashMap::from([(
crate::PackageName::new("internal-pkg"),
ConcreteVersion::new("1.0.0"),
)]);
let registry = MockRegistryWithVersions { versions: vec![] };
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &resolved_versions)
.with_ecosystem(crate::EcosystemId::Cargo)
.with_trust(&deps_dev),
®istry,
&MockWidenedResolveFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
!content.value.contains("Supply chain"),
"a private registry's package name/version must never reach deps.dev; got: {}",
content.value
);
never_called.assert_async().await;
}
#[tokio::test]
async fn test_generate_hover_trust_signal_skips_when_offline() {
let (mut server, deps_dev) = deps_dev_mock_client().await;
let never_called = server
.mock("GET", mockito::Matcher::Regex("^/v3/.*".into()))
.expect(0)
.create_async()
.await;
let deps_dev = Arc::new(deps_dev);
let (parse_result, resolved_versions) = express_fixture();
let registry = MockRegistryWithVersions { versions: vec![] };
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &resolved_versions)
.with_ecosystem(crate::EcosystemId::Npm)
.with_trust(&deps_dev)
.with_offline(true),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
!content.value.contains("Supply chain"),
"got: {}",
content.value
);
never_called.assert_async().await;
}
#[tokio::test]
async fn test_generate_hover_trust_signal_failure_leaves_other_content_unchanged() {
let (mut server, deps_dev) = deps_dev_mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(500)
.create_async()
.await;
let deps_dev = Arc::new(deps_dev);
let (parse_result, resolved_versions) = express_fixture();
let registry = MockRegistryWithVersions {
versions: vec![MockVersionWithAge {
version: "4.19.2".into(),
yanked: false,
published_at: None,
}],
};
let with_failing_trust = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &resolved_versions)
.with_ecosystem(crate::EcosystemId::Npm)
.with_trust(&deps_dev),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let without_trust = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &resolved_versions)
.with_ecosystem(crate::EcosystemId::Npm),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(a) = with_failing_trust.contents else {
panic!("expected markup hover contents");
};
let HoverContents::Markup(b) = without_trust.contents else {
panic!("expected markup hover contents");
};
assert!(!a.value.contains("Supply chain"), "got: {}", a.value);
assert_eq!(a.value, b.value);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_generate_hover_trust_signal_over_real_wait_budget_omits_section() {
const CALL_DELAY: std::time::Duration = std::time::Duration::from_millis(375);
let (mut server, deps_dev) = deps_dev_mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body_from_request(move |_req| {
std::thread::sleep(CALL_DELAY);
r#"{"slsaProvenances": [{"verified": true}], "attestations": [], "relatedProjects": [
{"projectKey": {"id": "github.com/expressjs/express"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
]}"#
.as_bytes()
.to_vec()
})
.create_async()
.await;
let _project = server
.mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
.with_status(200)
.with_body_from_request(move |_req| {
std::thread::sleep(CALL_DELAY);
r#"{"scorecard": {"overallScore": 8.5}}"#.as_bytes().to_vec()
})
.create_async()
.await;
let deps_dev = Arc::new(deps_dev);
let (parse_result, resolved_versions) = express_fixture();
let registry = MockRegistryWithVersions { versions: vec![] };
let first = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &resolved_versions)
.with_ecosystem(crate::EcosystemId::Npm)
.with_trust(&deps_dev),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(first_content) = first.contents else {
panic!("expected markup hover contents");
};
assert!(
!first_content.value.contains("Supply chain"),
"the real ~750ms two-call sequence must exceed the 700ms wait budget on the \
first hover; got: {}",
first_content.value
);
}
#[tokio::test]
async fn test_generate_hover_renders_trust_signal_from_a_warm_memo() {
let (mut server, deps_dev) = deps_dev_mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body(
r#"{"slsaProvenances": [{"verified": true}], "attestations": [], "relatedProjects": [
{"projectKey": {"id": "github.com/expressjs/express"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
]}"#,
)
.create_async()
.await;
let _project = server
.mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
.with_status(200)
.with_body(r#"{"scorecard": {"overallScore": 8.5}}"#)
.create_async()
.await;
let warmed = deps_dev
.trust_signal("npm", "express", "4.19.2")
.await
.expect("the direct, un-delayed call must warm the memo deterministically");
assert!(warmed.scorecard.is_some(), "fixture includes a scorecard");
let deps_dev = Arc::new(deps_dev);
let (parse_result, resolved_versions) = express_fixture();
let registry = MockRegistryWithVersions { versions: vec![] };
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &resolved_versions)
.with_ecosystem(crate::EcosystemId::Npm)
.with_trust(&deps_dev),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("Supply chain"),
"a hover reading an already-warm memo must render the trust signal \
immediately; got: {}",
content.value
);
}
}