use tower_lsp_server::ls_types::{
CodeDescription, Diagnostic, DiagnosticSeverity, NumberOrString, Uri,
};
use crate::osv::{ADVISORY_DISPLAY_CAP, ScanOutcome, diagnostic_severity_for};
use crate::{
Dependency, ParseResult, PublishTime, Registry, VersionReq, format_relative_age,
is_within_cooldown,
};
use super::{EcosystemFormatter, RequirementMatcher, RequirementStatus, VersionData};
pub const UNSATISFIABLE_DIAGNOSTIC_CODE: &str = "unsatisfiable-requirement";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DiagnosticSeverities {
pub outdated: DiagnosticSeverity,
pub unknown: DiagnosticSeverity,
pub yanked: DiagnosticSeverity,
pub unsatisfiable: DiagnosticSeverity,
}
impl Default for DiagnosticSeverities {
fn default() -> Self {
Self {
outdated: DiagnosticSeverity::HINT,
unknown: DiagnosticSeverity::WARNING,
yanked: DiagnosticSeverity::WARNING,
unsatisfiable: DiagnosticSeverity::WARNING,
}
}
}
pub fn compile_requirement_unless<M>(
requirement: &str,
is_undecidable: impl FnOnce(&str) -> bool,
matcher: impl FnOnce(String) -> M,
) -> Option<Box<dyn RequirementMatcher>>
where
M: RequirementMatcher + 'static,
{
if is_undecidable(requirement) {
return None;
}
Some(Box::new(matcher(requirement.to_string())))
}
const MAX_REQUIREMENT_LEN: usize = 256;
pub fn requirement_is_unsatisfiable(
formatter: &dyn EcosystemFormatter,
requirement: &VersionReq,
available: &[String],
) -> bool {
if available.is_empty() || requirement.as_str().trim().is_empty() {
return false;
}
if requirement.as_str().len() > MAX_REQUIREMENT_LEN {
return false;
}
if formatter.requirement_is_unresolved(requirement) {
return false;
}
if formatter.requirement_is_undecidable_given_available(requirement, available) {
return false;
}
let Some(matcher) = formatter.compile_requirement(requirement) else {
return false;
};
let mut saw_decided_false = false;
for candidate in available {
match matcher.matches(candidate) {
Some(true) => return false,
Some(false) => saw_decided_false = true,
None => {}
}
}
saw_decided_false
}
fn semver_prerelease_base(version: &str) -> Option<&str> {
let core = version.split('+').next().unwrap_or(version);
core.find('-').map(|dash| &core[..dash])
}
fn requirement_names_prerelease(requirement: &str) -> bool {
let bytes = requirement.as_bytes();
bytes.iter().enumerate().any(|(i, &b)| {
b == b'-'
&& i > 0
&& i + 1 < bytes.len()
&& !bytes[i - 1].is_ascii_whitespace()
&& !bytes[i + 1].is_ascii_whitespace()
})
}
fn matching_prerelease_would_satisfy(
formatter: &dyn EcosystemFormatter,
requirement: &VersionReq,
available: &[String],
yanked: &[String],
) -> Option<String> {
if !formatter.strict_semver_prerelease_exclusion() {
return None;
}
if requirement_names_prerelease(requirement.as_str()) {
return None;
}
let matcher = formatter.compile_requirement(requirement)?;
available.iter().find_map(|candidate| {
let base = semver_prerelease_base(candidate)?;
(!yanked.iter().any(|y| y == candidate) && matcher.matches(base) == Some(true))
.then(|| candidate.clone())
})
}
fn requirement_matches_only_yanked(
formatter: &dyn EcosystemFormatter,
requirement: &VersionReq,
available: &[String],
yanked: &[String],
) -> bool {
if available.is_empty() || yanked.is_empty() || requirement.as_str().trim().is_empty() {
return false;
}
if requirement.as_str().len() > MAX_REQUIREMENT_LEN {
return false;
}
if formatter.requirement_is_unresolved(requirement) {
return false;
}
let Some(matcher) = formatter.compile_requirement(requirement) else {
return false;
};
let mut saw_match = false;
let mut saw_undecided = false;
for candidate in available {
match matcher.matches(candidate) {
Some(true) => {
saw_match = true;
if !yanked.iter().any(|y| y == candidate) {
return false;
}
}
Some(false) => {}
None => saw_undecided = true,
}
}
saw_match && !saw_undecided
}
pub fn generate_diagnostics_from_cache(
parse_result: &dyn ParseResult,
versions: VersionData<'_>,
formatter: &dyn EcosystemFormatter,
freshness: crate::freshness::FreshnessSettings,
severities: DiagnosticSeverities,
now: PublishTime,
) -> Vec<Diagnostic> {
let deps = parse_result.dependencies();
let mut diagnostics = Vec::with_capacity(deps.len());
let vuln_keys = versions.ecosystem.map(|ecosystem| {
crate::osv::vulnerability_keys(parse_result, versions.resolved, formatter, ecosystem)
});
for dep in deps {
let normalized_name = formatter.normalize_package_name(dep.name());
if let Some(vulnerabilities) = versions.vulnerabilities
&& let Some(ScanOutcome::Vulnerable(dv)) = vuln_keys
.as_ref()
.and_then(|keys| keys.get(&dep.name_range()))
.and_then(|key| vulnerabilities.get(key))
.or_else(|| vulnerabilities.get(&normalized_name))
.or_else(|| vulnerabilities.get(dep.name().as_str()))
{
push_vulnerability_diagnostics(&mut diagnostics, dep, dv);
}
let yanked_diagnostic_pushed = if let Some(yanked_version) =
versions.yanked.and_then(|y| y.get(&normalized_name))
&& versions.ecosystem.is_none_or(|ecosystem| {
super::in_use_version(
dep,
&normalized_name,
versions.resolved,
formatter,
ecosystem,
)
.as_deref()
== Some(yanked_version.as_str())
}) {
diagnostics.push(Diagnostic {
range: dep.version_range().unwrap_or_else(|| dep.name_range()),
severity: Some(severities.yanked),
message: format!("{} ({})", formatter.yanked_message(), yanked_version),
source: Some("deps-lsp".into()),
..Default::default()
});
true
} else {
false
};
let package_versions = versions
.cached
.get(normalized_name.as_str())
.or_else(|| versions.cached.get(dep.name()));
let Some(package_versions) = package_versions else {
let in_lockfile = versions.resolved.contains_key(normalized_name.as_str())
|| versions.resolved.contains_key(dep.name());
if !in_lockfile {
let fetch_failed = dep.source().is_version_resolvable()
&& versions
.fetch_failed
.is_some_and(|f| f.contains(normalized_name.as_str()));
let message = match formatter.validate_package_name(dep.name().as_str()) {
Err(reason) => Some(format!("Invalid package name '{}': {reason}", dep.name())),
Ok(()) if fetch_failed => Some(format!(
"Registry lookup failed for '{}'; package status could not be determined",
dep.name()
)),
Ok(()) if dep.source().is_version_resolvable() => {
Some(format!("Unknown package '{}'", dep.name()))
}
Ok(()) => None,
};
if let Some(message) = message {
diagnostics.push(Diagnostic {
range: dep.name_range(),
severity: Some(severities.unknown),
message,
source: Some("deps-lsp".into()),
..Default::default()
});
}
}
continue;
};
let latest = package_versions.latest.as_str();
let Some(version_range) = dep.version_range() else {
continue;
};
let unsatisfiable = dep.source().is_version_resolvable()
&& dep.version_requirement().is_some_and(|version_req| {
requirement_is_unsatisfiable(formatter, version_req, &package_versions.available)
});
if unsatisfiable {
let req_str = dep.version_requirement().map_or("", |r| r.as_str());
let mut message = format!(
"No published version satisfies requirement '{req_str}'; latest is {latest}"
);
if let Some(prerelease) = dep.version_requirement().and_then(|version_req| {
matching_prerelease_would_satisfy(
formatter,
version_req,
&package_versions.available,
&package_versions.yanked,
)
}) {
use std::fmt::Write as _;
let _ = write!(
message,
" (a pre-release, {prerelease}, is excluded by SemVer's default \
pre-release-matching rules; require it explicitly to use it)"
);
}
diagnostics.push(Diagnostic {
range: version_range,
severity: Some(severities.unsatisfiable),
message,
source: Some("deps-lsp".into()),
code: Some(NumberOrString::String(UNSATISFIABLE_DIAGNOSTIC_CODE.into())),
..Default::default()
});
continue;
}
let yanked_only = !yanked_diagnostic_pushed
&& dep.source().is_version_resolvable()
&& dep.version_requirement().is_some_and(|version_req| {
formatter.yanked_diagnostic_applies_to(version_req)
&& requirement_matches_only_yanked(
formatter,
version_req,
&package_versions.available,
&package_versions.yanked,
)
});
if yanked_only {
diagnostics.push(Diagnostic {
range: version_range,
severity: Some(severities.yanked),
message: format!("{}; latest is {latest}", formatter.yanked_message()),
source: Some("deps-lsp".into()),
..Default::default()
});
continue;
}
let status = match dep.version_requirement() {
Some(version_req) if dep.source().is_version_resolvable() => {
formatter.requirement_status(version_req, latest)
}
_ => RequirementStatus::Unresolved,
};
if status == RequirementStatus::Outdated {
let published_at = freshness
.enabled
.then_some(package_versions.published_at)
.flatten();
let message = match published_at {
Some(published_at)
if is_within_cooldown(
published_at.age_secs_from(now),
freshness.cooldown_secs,
) =>
{
format!(
"Newer version available: {latest} (published {} — still within the release cooldown window)",
format_relative_age(published_at.age_secs_from(now))
)
}
_ => format!("Newer version available: {latest}"),
};
diagnostics.push(Diagnostic {
range: version_range,
severity: Some(severities.outdated),
message,
source: Some("deps-lsp".into()),
..Default::default()
});
}
}
diagnostics
}
fn push_vulnerability_diagnostics(
diagnostics: &mut Vec<Diagnostic>,
dep: &dyn Dependency,
dv: &crate::osv::DependencyVulnerabilities,
) {
let range = dep.version_range().unwrap_or_else(|| dep.name_range());
let shown = dv.advisories.iter().take(ADVISORY_DISPLAY_CAP);
let mut shown_count = 0usize;
for advisory in shown {
shown_count += 1;
let code_description = advisory
.url
.parse::<Uri>()
.ok()
.map(|href| CodeDescription { href });
diagnostics.push(Diagnostic {
range,
severity: Some(diagnostic_severity_for(advisory.severity)),
message: format!(
"{}: {}",
advisory.id,
advisory
.summary
.as_deref()
.unwrap_or("(no summary provided)")
),
code: Some(NumberOrString::String(advisory.id.clone())),
code_description,
source: Some("deps-lsp".into()),
..Default::default()
});
}
let remaining = dv.total_known.saturating_sub(shown_count);
if remaining > 0 {
diagnostics.push(Diagnostic {
range,
severity: Some(DiagnosticSeverity::INFORMATION),
message: format!("+{remaining} more advisories"),
source: Some("deps-lsp".into()),
..Default::default()
});
}
}
pub async fn generate_diagnostics<R: Registry + ?Sized>(
parse_result: &dyn ParseResult,
registry: &R,
formatter: &dyn EcosystemFormatter,
_freshness: crate::freshness::FreshnessSettings,
severities: DiagnosticSeverities,
) -> Vec<Diagnostic> {
let deps = parse_result.dependencies();
let mut diagnostics = Vec::with_capacity(deps.len());
for dep in deps {
let versions = match registry.get_versions(dep.name()).await {
Ok(v) => v,
Err(e) => {
let message = match formatter.validate_package_name(dep.name().as_str()) {
Err(reason) => format!("Invalid package name '{}': {reason}", dep.name()),
Ok(()) if e.is_not_found() => format!("Unknown package '{}'", dep.name()),
Ok(()) => format!(
"Registry lookup failed for '{}'; package status could not be determined",
dep.name()
),
};
diagnostics.push(Diagnostic {
range: dep.name_range(),
severity: Some(severities.unknown),
message,
source: Some("deps-lsp".into()),
..Default::default()
});
continue;
}
};
let Some(version_req) = dep.version_requirement() else {
continue;
};
let Some(version_range) = dep.version_range() else {
continue;
};
let matching = registry
.get_latest_matching(dep.name(), version_req)
.await
.ok()
.flatten();
if matching.is_some() {
let latest = crate::registry::find_latest_stable(&versions);
if let Some(latest) = latest
&& formatter.requirement_status(version_req, latest.version_string())
== RequirementStatus::Outdated
{
diagnostics.push(Diagnostic {
range: version_range,
severity: Some(severities.outdated),
message: format!("Newer version available: {}", latest.version_string()),
source: Some("deps-lsp".into()),
..Default::default()
});
}
}
}
diagnostics
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lsp_helpers::test_support::*;
use crate::lsp_helpers::*;
use crate::{PackageName, VersionReq};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
#[test]
fn test_generate_diagnostics_from_cache_unknown_package() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let formatter = MockFormatter;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "unknown-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, 11)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::WARNING));
assert!(diagnostics[0].message.contains("Unknown package"));
assert!(diagnostics[0].message.contains("unknown-pkg"));
}
#[test]
fn test_generate_diagnostics_from_cache_fetch_failed_not_reported_as_unknown() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let formatter = MockFormatter;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "flaky-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, 11)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let fetch_failed = HashSet::from(["flaky-pkg".to_string()]);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_fetch_failed(&fetch_failed),
&formatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(!diagnostics[0].message.contains("Unknown package"));
assert!(diagnostics[0].message.contains("Registry lookup failed"));
assert!(diagnostics[0].message.contains("flaky-pkg"));
}
#[test]
fn test_generate_diagnostics_from_cache_fetch_failed_does_not_mask_invalid_name() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let formatter = RejectingFormatter;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "bad 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, 11)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let fetch_failed = HashSet::from(["bad name".to_string()]);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_fetch_failed(&fetch_failed),
&formatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].message.contains("Invalid package name"));
}
#[test]
fn test_generate_diagnostics_from_cache_invalid_package_name() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let formatter = RejectingFormatter;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "bad-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, 7)),
}],
uri: crate::test_util::test_uri("/test/package.json"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
crate::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::WARNING));
assert!(diagnostics[0].message.starts_with("Invalid package name"));
assert!(!diagnostics[0].message.contains("Unknown package"));
}
#[tokio::test]
async fn test_generate_diagnostics_invalid_package_name() {
use tower_lsp_server::ls_types::{Position, Range};
let formatter = RejectingFormatter;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "bad-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, 7)),
}],
uri: crate::test_util::test_uri("/test/package.json"),
};
let diagnostics = generate_diagnostics(
&parse_result,
&ErrorRegistry,
&formatter,
crate::FreshnessSettings::default(),
DiagnosticSeverities::default(),
)
.await;
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::WARNING));
assert!(diagnostics[0].message.starts_with("Invalid package name"));
assert!(!diagnostics[0].message.contains("Unknown package"));
}
#[tokio::test]
async fn test_generate_diagnostics_unknown_uses_configured_severity() {
use tower_lsp_server::ls_types::{Position, Range};
let formatter = MockFormatter;
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 severities = DiagnosticSeverities {
unknown: DiagnosticSeverity::ERROR,
..DiagnosticSeverities::default()
};
let diagnostics = generate_diagnostics(
&parse_result,
&NotFoundRegistry,
&formatter,
crate::FreshnessSettings::default(),
severities,
)
.await;
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].message.starts_with("Unknown package"));
assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[tokio::test]
async fn test_generate_diagnostics_registry_error_not_reported_as_unknown() {
use tower_lsp_server::ls_types::{Position, Range};
let formatter = MockFormatter;
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 diagnostics = generate_diagnostics(
&parse_result,
&ErrorRegistry,
&formatter,
crate::FreshnessSettings::default(),
DiagnosticSeverities::default(),
)
.await;
assert_eq!(diagnostics.len(), 1);
assert!(!diagnostics[0].message.contains("Unknown package"));
assert!(diagnostics[0].message.contains("Registry lookup failed"));
}
#[tokio::test]
async fn test_generate_diagnostics_outdated_uses_configured_severity() {
use tower_lsp_server::ls_types::{Position, Range};
let formatter = MockFormatter;
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 severities = DiagnosticSeverities {
outdated: DiagnosticSeverity::ERROR,
..DiagnosticSeverities::default()
};
let diagnostics = generate_diagnostics(
&parse_result,
&OutdatedRegistry,
&formatter,
crate::FreshnessSettings::default(),
severities,
)
.await;
let outdated_diag = diagnostics
.iter()
.find(|d| d.message.starts_with("Newer version available"))
.expect("expected an outdated diagnostic");
assert_eq!(outdated_diag.severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn test_generate_diagnostics_from_cache_outdated_version() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let formatter = MockFormatter;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.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 cached_versions = HashMap::new();
cached_versions.insert("serde".into(), PackageVersions::latest_only("2.0.0"));
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::HINT));
assert!(diagnostics[0].message.contains("Newer version available"));
assert!(diagnostics[0].message.contains("2.0.0"));
}
#[test]
fn test_generate_diagnostics_from_cache_outdated_within_cooldown_appends_context() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let formatter = MockFormatter;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.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 cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".to_string(),
available: Arc::from(vec!["2.0.0".to_string()]),
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 diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::HINT));
assert_eq!(
diagnostics[0].message,
"Newer version available: 2.0.0 (published 1 hour ago — still within the release cooldown window)"
);
assert!(!diagnostics[0].message.contains("ago ago"));
}
#[test]
fn test_generate_diagnostics_from_cache_outdated_outside_cooldown_plain_message() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let formatter = MockFormatter;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.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 cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".to_string(),
available: Arc::from(vec!["2.0.0".to_string()]),
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 diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].message, "Newer version available: 2.0.0");
}
#[test]
fn test_generate_diagnostics_from_cache_outdated_freshness_disabled_plain_message() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let formatter = MockFormatter;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.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 cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".to_string(),
available: Arc::from(vec!["2.0.0".to_string()]),
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 diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
crate::freshness::FreshnessSettings {
enabled: false,
..crate::freshness::FreshnessSettings::default()
},
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].message, "Newer version available: 2.0.0");
}
#[test]
fn test_generate_diagnostics_from_cache_outdated_cooldown_boundary_is_exclusive() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
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 formatter = MockFormatter;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.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 cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".to_string(),
available: Arc::from(vec!["2.0.0".to_string()]),
yanked: Arc::from(Vec::new()),
published_at: Some(published_at_at_boundary),
},
);
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
crate::freshness::FreshnessSettings {
enabled: true,
cooldown_secs: COOLDOWN_SECS,
},
DiagnosticSeverities::default(),
now,
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].message, "Newer version available: 2.0.0",
"age exactly equal to cooldown_secs must not be within cooldown"
);
}
#[test]
fn test_generate_diagnostics_from_cache_outdated_cooldown_boundary_one_second_inside() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
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 formatter = MockFormatter;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.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 cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".to_string(),
available: Arc::from(vec!["2.0.0".to_string()]),
yanked: Arc::from(Vec::new()),
published_at: Some(published_at_just_inside),
},
);
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
crate::freshness::FreshnessSettings {
enabled: true,
cooldown_secs: COOLDOWN_SECS,
},
DiagnosticSeverities::default(),
now,
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].message,
"Newer version available: 2.0.0 (published 1 minute ago — still within the release cooldown window)",
"age == cooldown_secs - 1 must be within cooldown"
);
}
#[test]
fn test_generate_diagnostics_from_cache_up_to_date() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let formatter = MockFormatter;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "^1.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 cached_versions = HashMap::new();
cached_versions.insert("serde".into(), PackageVersions::latest_only("1.0.214"));
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics.is_empty(),
"Expected no diagnostics for up-to-date dependency"
);
}
#[test]
fn test_generate_diagnostics_from_cache_multiple_deps() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let formatter = MockFormatter;
let parse_result = MockParseResult {
deps: vec![
MockDep {
name: "serde".into(),
version_req: "^1.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)),
},
MockDep {
name: "tokio".into(),
version_req: "1.0".into(),
version_range: Range::new(Position::new(1, 10), Position::new(1, 20)),
name_range: Range::new(Position::new(1, 0), Position::new(1, 5)),
},
MockDep {
name: "unknown".into(),
version_req: "1.0".into(),
version_range: Range::new(Position::new(2, 10), Position::new(2, 20)),
name_range: Range::new(Position::new(2, 0), Position::new(2, 7)),
},
],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert("serde".into(), PackageVersions::latest_only("1.0.214"));
cached_versions.insert("tokio".into(), PackageVersions::latest_only("2.0.0"));
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 2);
let has_outdated = diagnostics
.iter()
.any(|d| d.message.contains("Newer version"));
let has_unknown = diagnostics
.iter()
.any(|d| d.message.contains("Unknown package"));
assert!(has_outdated, "Expected outdated version diagnostic");
assert!(has_unknown, "Expected unknown package diagnostic");
}
#[test]
fn test_generate_diagnostics_from_cache_unresolved_emits_no_diagnostic() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let formatter = MockUnresolvedFormatter;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "spring-boot-starter".into(),
version_req: "$missing".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/libs.versions.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"spring-boot-starter".into(),
PackageVersions::latest_only("3.2.0"),
);
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics.is_empty(),
"Expected no diagnostics for an unresolved requirement, got: {diagnostics:?}"
);
}
#[test]
fn test_generate_diagnostics_from_cache_yanked_uses_configured_severity() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let formatter = MockFormatter;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.0.5".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 cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut yanked = HashMap::new();
yanked.insert("serde".to_string(), "1.0.5".to_string());
let severities = DiagnosticSeverities {
yanked: DiagnosticSeverity::ERROR,
..DiagnosticSeverities::default()
};
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_yanked(&yanked),
&formatter,
crate::freshness::FreshnessSettings::default(),
severities,
PublishTime::now(),
);
let yanked_diag = diagnostics
.iter()
.find(|d| d.message.starts_with(formatter.yanked_message()))
.expect("expected a yanked diagnostic");
assert_eq!(yanked_diag.severity, Some(DiagnosticSeverity::ERROR));
assert!(yanked_diag.message.contains("1.0.5"));
}
#[test]
fn test_generate_diagnostics_from_cache_yanked_default_severity_unchanged() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let formatter = MockFormatter;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.0.5".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 cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut yanked = HashMap::new();
yanked.insert("serde".to_string(), "1.0.5".to_string());
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_yanked(&yanked),
&formatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let yanked_diag = diagnostics
.iter()
.find(|d| d.message.starts_with(formatter.yanked_message()))
.expect("expected a yanked diagnostic");
assert_eq!(yanked_diag.severity, Some(DiagnosticSeverity::WARNING));
}
#[test]
fn test_generate_diagnostics_from_cache_no_yanked_map_emits_no_yanked_diagnostic() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let formatter = MockFormatter;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.0.5".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 cached_versions = HashMap::new();
cached_versions.insert("serde".into(), PackageVersions::latest_only("1.0.5"));
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
!diagnostics
.iter()
.any(|d| d.message.starts_with(formatter.yanked_message())),
"Expected no yanked diagnostic when `yanked` is None, got: {diagnostics:?}"
);
}
#[test]
fn test_generate_diagnostics_from_cache_yanked_and_outdated_both_emitted() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let formatter = MockFormatter;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.0.5".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 cached_versions = HashMap::new();
cached_versions.insert("serde".into(), PackageVersions::latest_only("2.0.0"));
let resolved_versions = HashMap::new();
let mut yanked = HashMap::new();
yanked.insert("serde".to_string(), "1.0.5".to_string());
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_yanked(&yanked),
&formatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(
diagnostics.len(),
2,
"expected both diagnostics: {diagnostics:?}"
);
assert!(
diagnostics
.iter()
.any(|d| d.message.starts_with(formatter.yanked_message()))
);
assert!(
diagnostics
.iter()
.any(|d| d.message.contains("Newer version available"))
);
}
#[test]
fn test_generate_diagnostics_from_cache_yanked_no_version_range_uses_name_range() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let formatter = MockFormatter;
let name_range = Range::new(Position::new(0, 0), Position::new(0, 5));
let parse_result = MockMarkedParseResult {
dep: MockMarkedDep {
name: "serde".into(),
name_range,
markers: None,
},
uri: crate::test_util::test_uri("/test/pyproject.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut yanked = HashMap::new();
yanked.insert("serde".to_string(), "1.0.5".to_string());
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_yanked(&yanked),
&formatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let yanked_diag = diagnostics
.iter()
.find(|d| d.message.starts_with(formatter.yanked_message()))
.expect("expected a yanked diagnostic even without a version_range");
assert_eq!(yanked_diag.range, name_range);
}
#[test]
fn test_generate_diagnostics_from_cache_yanked_normalized_name_keying() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
struct MockLowercaseFormatter;
impl EcosystemFormatter for MockLowercaseFormatter {
fn format_version_for_text_edit(&self, version: &str) -> String {
version.to_string()
}
fn package_url(&self, name: &PackageName) -> String {
format!("https://example.com/{name}")
}
fn normalize_package_name(&self, name: &PackageName) -> String {
name.to_string().to_lowercase()
}
}
let formatter = MockLowercaseFormatter;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "Newtonsoft.Json".into(),
version_req: "13.0.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, 5)),
}],
uri: crate::test_util::test_uri("/test/project.csproj"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut yanked = HashMap::new();
yanked.insert("newtonsoft.json".to_string(), "13.0.1".to_string());
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_yanked(&yanked),
&formatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.any(|d| d.message.starts_with(formatter.yanked_message())),
"expected normalized-name lookup to resolve the yanked entry, got: {diagnostics:?}"
);
}
#[test]
fn test_generate_diagnostics_from_cache_yanked_not_shared_across_duplicate_name_occurrences() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let formatter = MockFormatter;
let parse_result = MockParseResult {
deps: vec![
MockDep {
name: "time".into(),
version_req: "=0.1.43".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)),
},
MockDep {
name: "time".into(),
version_req: "=0.1.44".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)),
},
],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut yanked = HashMap::new();
yanked.insert("time".to_string(), "0.1.43".to_string());
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_yanked(&yanked)
.with_ecosystem(crate::EcosystemId::Cargo),
&formatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let yanked_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.message.starts_with(formatter.yanked_message()))
.collect();
assert_eq!(
yanked_diags.len(),
1,
"exactly one occurrence must get the yanked diagnostic, got: {diagnostics:?}"
);
assert_eq!(
yanked_diags[0].range.start.line, 0,
"must land on the yanked occurrence's own line"
);
assert!(yanked_diags[0].message.contains("0.1.43"));
}
#[test]
fn test_generate_diagnostics_from_cache_yanked_no_ecosystem_keeps_pre_394_behavior() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let formatter = MockFormatter;
let parse_result = MockParseResult {
deps: vec![
MockDep {
name: "time".into(),
version_req: "=0.1.43".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)),
},
MockDep {
name: "time".into(),
version_req: "=0.1.44".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)),
},
],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut yanked = HashMap::new();
yanked.insert("time".to_string(), "0.1.43".to_string());
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_yanked(&yanked),
&formatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let yanked_diags = diagnostics
.iter()
.filter(|d| d.message.starts_with(formatter.yanked_message()))
.count();
assert_eq!(
yanked_diags, 2,
"no `ecosystem` set: both occurrences share the finding as before #394"
);
}
#[test]
fn test_generate_diagnostics_unsatisfiable_enriched_with_matching_prerelease() {
let cached_versions = {
let mut m = HashMap::new();
m.insert(
"dep".into(),
PackageVersions {
latest: "1.5.0".to_string(),
available: Arc::from(vec![
"2.0.0-rc.1".to_string(),
"1.5.0".to_string(),
"1.4.0".to_string(),
]),
yanked: Arc::from(Vec::<String>::new()),
published_at: None,
},
);
m
};
let resolved_versions = HashMap::new();
let uri = crate::test_util::test_uri("/test/Cargo.toml");
let mut dependency = dep_at("dep");
dependency.version_req = VersionReq::new("^2.0.0");
let parse_result = SingleDepParseResult {
dep: dependency,
uri,
};
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&StrictSemverFormatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let message = diagnostics
.iter()
.find(|d| d.message.contains("No published version satisfies"))
.map(|d| d.message.as_str())
.expect("unsatisfiable WARNING must fire");
assert!(
message.contains("2.0.0-rc.1") && message.contains("pre-release"),
"message must mention the matching pre-release, got: {message}"
);
}
#[test]
fn test_generate_diagnostics_unsatisfiable_no_enrichment_without_matching_prerelease() {
let cached_versions = {
let mut m = HashMap::new();
m.insert(
"dep".into(),
PackageVersions {
latest: "1.5.0".to_string(),
available: Arc::from(vec!["1.5.0".to_string(), "1.4.0".to_string()]),
yanked: Arc::from(Vec::<String>::new()),
published_at: None,
},
);
m
};
let resolved_versions = HashMap::new();
let uri = crate::test_util::test_uri("/test/Cargo.toml");
let mut dependency = dep_at("dep");
dependency.version_req = VersionReq::new("^2.0.0");
let parse_result = SingleDepParseResult {
dep: dependency,
uri,
};
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&StrictSemverFormatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let message = diagnostics
.iter()
.find(|d| d.message.contains("No published version satisfies"))
.map(|d| d.message.as_str())
.expect("unsatisfiable WARNING must fire");
assert!(
!message.contains("pre-release"),
"message must not mention a pre-release when none satisfies, got: {message}"
);
}
#[test]
fn test_generate_diagnostics_unsatisfiable_skipped_for_non_registry_sources() {
use crate::parser::DependencySource;
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");
for source in [
DependencySource::Path {
path: "../local".into(),
},
DependencySource::Git {
url: "https://example.com/repo.git".into(),
rev: None,
},
DependencySource::Url {
url: "https://example.com/pkg.tar.gz".into(),
},
DependencySource::Sdk {
sdk: "flutter".into(),
},
DependencySource::Workspace,
DependencySource::CustomRegistry {
url: "my-corp".into(),
},
] {
let parse_result = SingleDepParseResult {
dep: NonRegistryDep(dep_at("dep"), source.clone()),
uri: uri.clone(),
};
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&ExactMatchFormatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.all(|d| !d.message.contains("No published version satisfies")),
"source {source:?} must never produce the unsatisfiable-requirement WARNING"
);
}
let registry_parse_result = SingleDepParseResult {
dep: dep_at("dep"),
uri,
};
let diagnostics = generate_diagnostics_from_cache(
®istry_parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&ExactMatchFormatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.any(|d| d.message.contains("No published version satisfies")),
"control case: a Registry-source dependency must still produce the WARNING"
);
}
#[test]
fn test_generate_diagnostics_unknown_package_skipped_for_non_registry_sources() {
use crate::parser::DependencySource;
let cached_versions = HashMap::new();
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 diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.all(|d| !d.message.contains("Unknown package")),
"a CustomRegistry-sourced dependency must never produce the \"Unknown package\" WARNING"
);
let registry_parse_result = SingleDepParseResult {
dep: dep_at("dep"),
uri,
};
let diagnostics = generate_diagnostics_from_cache(
®istry_parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.any(|d| d.message.contains("Unknown package")),
"control case: a Registry-source dependency must still produce the WARNING"
);
}
#[test]
fn test_generate_diagnostics_invalid_name_still_reported_for_non_registry_sources() {
use crate::parser::DependencySource;
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let uri = crate::test_util::test_uri("/test/package.json");
let parse_result = SingleDepParseResult {
dep: NonRegistryDep(
dep_at("dep"),
DependencySource::CustomRegistry {
url: "my-corp".into(),
},
),
uri,
};
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&RejectingFormatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].message.starts_with("Invalid package name"));
}
#[test]
fn test_generate_diagnostics_outdated_skipped_for_non_registry_sources() {
use crate::parser::DependencySource;
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 diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.all(|d| !d.message.contains("Newer version available")),
"a CustomRegistry-sourced dependency must never produce the \"Outdated\" WARNING"
);
let registry_parse_result = SingleDepParseResult {
dep: dep_at("dep"),
uri,
};
let diagnostics = generate_diagnostics_from_cache(
®istry_parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.any(|d| d.message.contains("Newer version available")),
"control case: a Registry-source dependency must still produce the WARNING"
);
}
#[test]
fn test_generate_diagnostics_vulnerable_dependency_emits_advisory_diagnostic_even_without_registry_data()
{
use crate::osv::{
DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity, VulnerabilityMap,
};
let formatter = MockFormatter;
let parse_result = MockParseResult {
deps: vec![dep_at("vulnerable-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(
"vulnerable-pkg".to_string(),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories: vec![sample_advisory("RUSTSEC-2020-0071", VulnSeverity::High)],
total_known: 1,
upgrade_status: UpgradeStatus::NotChecked,
}),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&formatter,
crate::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let vuln_diag = diagnostics
.iter()
.find(|d| d.message.contains("RUSTSEC-2020-0071"))
.expect("vulnerability diagnostic must be emitted even without registry data");
assert_eq!(vuln_diag.severity, Some(DiagnosticSeverity::WARNING));
assert_eq!(
vuln_diag.code,
Some(NumberOrString::String("RUSTSEC-2020-0071".to_string()))
);
}
#[test]
fn test_generate_diagnostics_advisory_cap_emits_more_count_from_total_known() {
use crate::osv::{
DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity, VulnerabilityMap,
};
let formatter = MockFormatter;
let parse_result = MockParseResult {
deps: vec![dep_at("noisy-pkg")],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let advisories: Vec<_> = (0..ADVISORY_DISPLAY_CAP)
.map(|i| sample_advisory(&format!("ADV-{i}"), VulnSeverity::Low))
.collect();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
"noisy-pkg".to_string(),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories,
total_known: 40,
upgrade_status: UpgradeStatus::NotChecked,
}),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&formatter,
crate::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let more_diag = diagnostics
.iter()
.find(|d| d.message.contains("more advisories"))
.expect("expected a trailing +N more advisories diagnostic");
assert!(
more_diag.message.contains("+35"),
"got: {}",
more_diag.message
);
}
#[test]
fn test_generate_diagnostics_vulnerability_not_shared_across_duplicate_name_occurrences() {
use crate::osv::{
DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity, VulnerabilityMap,
vulnerability_keys,
};
use tower_lsp_server::ls_types::{Position, Range};
let formatter = MockFormatter;
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 = vulnerability_keys(
&parse_result,
&resolved_versions,
&formatter,
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,
"differently-versioned occurrences of one name must get distinct keys"
);
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
vulnerable_key,
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories: vec![sample_advisory("RUSTSEC-2020-0071", VulnSeverity::High)],
total_known: 1,
upgrade_status: UpgradeStatus::NotChecked,
}),
);
vulns.insert(patched_key, ScanOutcome::Clean);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_vulnerabilities(&vulns)
.with_ecosystem(crate::EcosystemId::Cargo),
&formatter,
crate::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let advisory_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.message.contains("RUSTSEC-2020-0071"))
.collect();
assert_eq!(
advisory_diags.len(),
1,
"exactly one occurrence must get the advisory diagnostic, got: {diagnostics:?}"
);
assert_eq!(
advisory_diags[0].range.start.line, 0,
"must land on the vulnerable occurrence's own line, not the patched one"
);
}
#[test]
fn test_generate_diagnostics_skipped_outcome_emits_no_vulnerability_diagnostic() {
use crate::osv::{ScanOutcome, SkipReason, VulnerabilityMap};
let formatter = MockFormatter;
let parse_result = MockParseResult {
deps: vec![dep_at("git-pkg")],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert("git-pkg".into(), PackageVersions::latest_only("1.0.0"));
let resolved_versions = HashMap::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
"git-pkg".to_string(),
ScanOutcome::Skipped(SkipReason::NonRegistrySource),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&formatter,
crate::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics.iter().all(|d| d.code.is_none()),
"a Skipped outcome must never render an advisory diagnostic"
);
}
mod requirement_is_unsatisfiable_tests {
use super::*;
type Decide = Arc<dyn Fn(&str) -> Option<bool> + Send + Sync>;
struct ClosureMatcher(Decide);
impl RequirementMatcher for ClosureMatcher {
fn matches(&self, version: &str) -> Option<bool> {
(self.0)(version)
}
}
struct TableFormatter {
decide: Decide,
}
impl TableFormatter {
fn new(decide: impl Fn(&str) -> Option<bool> + Send + Sync + 'static) -> Self {
Self {
decide: Arc::new(decide),
}
}
}
impl EcosystemFormatter for TableFormatter {
fn format_version_for_text_edit(&self, version: &str) -> String {
version.to_string()
}
fn package_url(&self, name: &PackageName) -> String {
name.to_string()
}
fn requirement_is_unresolved(&self, requirement: &VersionReq) -> bool {
requirement.as_str() == "unresolved"
}
fn compile_requirement(
&self,
requirement: &VersionReq,
) -> Option<Box<dyn RequirementMatcher>> {
if requirement.as_str() != "modelled" {
return None;
}
Some(Box::new(ClosureMatcher(Arc::clone(&self.decide)))
as Box<dyn RequirementMatcher>)
}
}
fn versions(strs: &[&str]) -> Vec<String> {
strs.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn test_empty_available_list_is_false() {
let formatter = TableFormatter::new(|_v| Some(true));
assert!(!requirement_is_unsatisfiable(
&formatter,
&VersionReq::new("modelled"),
&[],
));
}
#[test]
fn test_empty_requirement_string_is_false() {
let formatter = TableFormatter::new(|_v| Some(false));
assert!(!requirement_is_unsatisfiable(
&formatter,
&VersionReq::new(""),
&versions(&["1.0.0"]),
));
}
#[test]
fn test_oversized_requirement_is_false_without_compiling() {
let formatter =
TableFormatter::new(|_v| panic!("must not compile/scan an oversized requirement"));
let oversized = "1".repeat(MAX_REQUIREMENT_LEN + 1);
assert!(!requirement_is_unsatisfiable(
&formatter,
&VersionReq::new(oversized),
&versions(&["1.0.0"]),
));
}
#[test]
fn test_unresolved_requirement_is_false() {
let formatter = TableFormatter::new(|_v| Some(false));
assert!(!requirement_is_unsatisfiable(
&formatter,
&VersionReq::new("unresolved"),
&versions(&["1.0.0"]),
));
}
#[test]
fn test_compile_requirement_none_is_false() {
let formatter = TableFormatter::new(|_v| Some(false));
assert!(!requirement_is_unsatisfiable(
&formatter,
&VersionReq::new("not-modelled"),
&versions(&["1.0.0"]),
));
}
#[test]
fn test_all_candidates_decided_false_is_true() {
let formatter = TableFormatter::new(|_v| Some(false));
assert!(requirement_is_unsatisfiable(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.0.0", "2.0.0", "3.0.0"]),
));
}
#[test]
fn test_one_match_among_many_non_matches_is_false() {
let formatter = TableFormatter::new(|v| Some(v == "2.0.0"));
assert!(!requirement_is_unsatisfiable(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.0.0", "2.0.0", "3.0.0"]),
));
}
#[test]
fn test_all_candidates_unparseable_is_false() {
let formatter = TableFormatter::new(|_v| None);
assert!(!requirement_is_unsatisfiable(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.0.0", "2.0.0"]),
));
}
#[test]
fn test_one_unparseable_candidate_among_false_is_still_true() {
let formatter = TableFormatter::new(|v| if v == "junk" { None } else { Some(false) });
assert!(requirement_is_unsatisfiable(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.0.0", "junk", "2.0.0"]),
));
}
#[test]
fn test_match_on_yanked_only_candidate_is_false() {
let formatter = TableFormatter::new(|v| Some(v == "1.0.0-yanked"));
assert!(!requirement_is_unsatisfiable(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.0.0-yanked"]),
));
}
#[test]
fn test_match_on_prerelease_only_candidate_is_false() {
let formatter = TableFormatter::new(|v| Some(v == "2.0.0-beta.1"));
assert!(!requirement_is_unsatisfiable(
&formatter,
&VersionReq::new("modelled"),
&versions(&["2.0.0-beta.1"]),
));
}
#[test]
fn test_scan_short_circuits_on_first_match() {
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let calls_clone = Arc::clone(&calls);
let formatter = TableFormatter::new(move |v| {
calls_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Some(v == "1.0.0")
});
let result = requirement_is_unsatisfiable(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.0.0", "0.9.0", "0.8.0"]),
);
assert!(!result);
assert_eq!(
calls.load(std::sync::atomic::Ordering::SeqCst),
1,
"must stop scanning at the first Some(true)"
);
}
}
mod matching_prerelease_would_satisfy_tests {
use super::*;
struct NonStrictFormatter;
impl EcosystemFormatter for NonStrictFormatter {
fn format_version_for_text_edit(&self, version: &str) -> String {
version.to_string()
}
fn package_url(&self, name: &PackageName) -> String {
name.to_string()
}
fn compile_requirement(
&self,
requirement: &VersionReq,
) -> Option<Box<dyn RequirementMatcher>> {
requirement
.as_str()
.parse::<semver::VersionReq>()
.ok()
.map(|req| Box::new(RealSemverMatcher(req)) as Box<dyn RequirementMatcher>)
}
}
fn versions(strs: &[&str]) -> Vec<String> {
strs.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn test_semver_prerelease_base() {
assert_eq!(semver_prerelease_base("2.0.0-rc.1"), Some("2.0.0"));
assert_eq!(semver_prerelease_base("2.0.0-rc.1+build.5"), Some("2.0.0"));
assert_eq!(semver_prerelease_base("2.0.0"), None);
assert_eq!(semver_prerelease_base("2.0.0+build.5"), None);
}
#[test]
fn test_requirement_names_prerelease() {
assert!(requirement_names_prerelease("2.0.0-rc.5"));
assert!(requirement_names_prerelease("^2.0.0-rc.5"));
assert!(requirement_names_prerelease("~2.0.0-rc.5"));
assert!(requirement_names_prerelease(">=2.0.0-rc.5"));
assert!(requirement_names_prerelease("=2.0.0-rc.5"));
assert!(!requirement_names_prerelease("^2.0.0"));
assert!(!requirement_names_prerelease(">=1.0.0, <2.0.0"));
assert!(!requirement_names_prerelease("1.2.3 - 2.3.4"));
}
#[test]
fn test_no_prerelease_available_returns_none() {
assert_eq!(
matching_prerelease_would_satisfy(
&StrictSemverFormatter,
&VersionReq::new("^2.0.0"),
&versions(&["1.5.0", "1.4.0"]),
&[],
),
None
);
}
#[test]
fn test_matching_prerelease_is_found() {
assert_eq!(
matching_prerelease_would_satisfy(
&StrictSemverFormatter,
&VersionReq::new("^2.0.0"),
&versions(&["2.0.0-rc.1", "1.5.0"]),
&[],
),
Some("2.0.0-rc.1".to_string())
);
}
#[test]
fn test_returns_newest_matching_prerelease_first() {
assert_eq!(
matching_prerelease_would_satisfy(
&StrictSemverFormatter,
&VersionReq::new("^2.0.0"),
&versions(&["2.0.0-rc.2", "2.0.0-rc.1", "1.5.0"]),
&[],
),
Some("2.0.0-rc.2".to_string())
);
}
#[test]
fn test_non_matching_prerelease_returns_none() {
assert_eq!(
matching_prerelease_would_satisfy(
&StrictSemverFormatter,
&VersionReq::new("^2.0.0"),
&versions(&["3.0.0-rc.1", "1.5.0"]),
&[],
),
None
);
}
#[test]
fn test_exact_prerelease_pin_does_not_misfire_on_unrelated_prerelease() {
assert_eq!(
matching_prerelease_would_satisfy(
&StrictSemverFormatter,
&VersionReq::new("=2.0.0-rc.5"),
&versions(&["2.0.0-rc.1", "1.5.0"]),
&[],
),
None
);
}
#[test]
fn test_caret_requirement_naming_prerelease_returns_none() {
assert_eq!(
matching_prerelease_would_satisfy(
&StrictSemverFormatter,
&VersionReq::new("^2.0.0-rc.5"),
&versions(&["2.0.0-rc.1", "1.5.0"]),
&[],
),
None
);
}
#[test]
fn test_tilde_requirement_naming_prerelease_returns_none() {
assert_eq!(
matching_prerelease_would_satisfy(
&StrictSemverFormatter,
&VersionReq::new("~2.0.0-rc.5"),
&versions(&["2.0.0-rc.1", "1.5.0"]),
&[],
),
None
);
}
#[test]
fn test_gte_requirement_naming_prerelease_returns_none() {
assert_eq!(
matching_prerelease_would_satisfy(
&StrictSemverFormatter,
&VersionReq::new(">=2.0.0-rc.5"),
&versions(&["2.0.0-rc.1", "1.5.0"]),
&[],
),
None
);
}
#[test]
fn test_yanked_matching_prerelease_is_skipped() {
assert_eq!(
matching_prerelease_would_satisfy(
&StrictSemverFormatter,
&VersionReq::new("^2.0.0"),
&versions(&["2.0.0-rc.1", "1.5.0"]),
&versions(&["2.0.0-rc.1"]),
),
None
);
}
#[test]
fn test_yanked_matching_prerelease_falls_back_to_non_yanked() {
assert_eq!(
matching_prerelease_would_satisfy(
&StrictSemverFormatter,
&VersionReq::new("^2.0.0"),
&versions(&["2.0.0-rc.2", "2.0.0-rc.1", "1.5.0"]),
&versions(&["2.0.0-rc.2"]),
),
Some("2.0.0-rc.1".to_string())
);
}
#[test]
fn test_non_opted_in_ecosystem_returns_none() {
assert_eq!(
matching_prerelease_would_satisfy(
&NonStrictFormatter,
&VersionReq::new("^2.0.0"),
&versions(&["2.0.0-rc.1", "1.5.0"]),
&[],
),
None
);
}
}
mod requirement_matches_only_yanked_tests {
use super::*;
type Decide = Arc<dyn Fn(&str) -> Option<bool> + Send + Sync>;
struct ClosureMatcher(Decide);
impl RequirementMatcher for ClosureMatcher {
fn matches(&self, version: &str) -> Option<bool> {
(self.0)(version)
}
}
struct TableFormatter {
decide: Decide,
}
impl TableFormatter {
fn new(decide: impl Fn(&str) -> Option<bool> + Send + Sync + 'static) -> Self {
Self {
decide: Arc::new(decide),
}
}
}
impl EcosystemFormatter for TableFormatter {
fn format_version_for_text_edit(&self, version: &str) -> String {
version.to_string()
}
fn package_url(&self, name: &PackageName) -> String {
name.to_string()
}
fn requirement_is_unresolved(&self, requirement: &VersionReq) -> bool {
requirement.as_str() == "unresolved"
}
fn compile_requirement(
&self,
requirement: &VersionReq,
) -> Option<Box<dyn RequirementMatcher>> {
if requirement.as_str() != "modelled" {
return None;
}
Some(Box::new(ClosureMatcher(Arc::clone(&self.decide)))
as Box<dyn RequirementMatcher>)
}
}
fn versions(strs: &[&str]) -> Vec<String> {
strs.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn test_yanked_only_match_is_true() {
let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
assert!(requirement_matches_only_yanked(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.2.1"]),
&versions(&["1.2.1"]),
));
}
#[test]
fn test_no_match_is_false() {
let formatter = TableFormatter::new(|_v| Some(false));
assert!(!requirement_matches_only_yanked(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.2.1"]),
&versions(&["1.2.1"]),
));
}
#[test]
fn test_match_on_non_yanked_alongside_yanked_is_false() {
let formatter = TableFormatter::new(|v| Some(v == "1.0.0" || v == "1.0.1"));
assert!(!requirement_matches_only_yanked(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.0.1", "1.0.0"]),
&versions(&["1.0.0"]),
));
}
#[test]
fn test_scan_continues_past_a_yanked_match_to_find_a_non_yanked_alternative() {
let formatter = TableFormatter::new(|v| Some(v == "1.0.0" || v == "1.0.1"));
assert!(!requirement_matches_only_yanked(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.0.0", "1.0.1"]),
&versions(&["1.0.0"]),
));
}
#[test]
fn test_empty_yanked_list_is_false_without_compiling() {
let formatter =
TableFormatter::new(|_v| panic!("must not compile/scan when yanked is empty"));
assert!(!requirement_matches_only_yanked(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.0.0"]),
&[],
));
}
#[test]
fn test_empty_available_list_is_false() {
let formatter = TableFormatter::new(|_v| Some(true));
assert!(!requirement_matches_only_yanked(
&formatter,
&VersionReq::new("modelled"),
&[],
&versions(&["1.0.0"]),
));
}
#[test]
fn test_unresolved_requirement_is_false() {
let formatter = TableFormatter::new(|_v| Some(true));
assert!(!requirement_matches_only_yanked(
&formatter,
&VersionReq::new("unresolved"),
&versions(&["1.0.0"]),
&versions(&["1.0.0"]),
));
}
#[test]
fn test_compile_requirement_none_is_false() {
let formatter = TableFormatter::new(|_v| Some(true));
assert!(!requirement_matches_only_yanked(
&formatter,
&VersionReq::new("not-modelled"),
&versions(&["1.0.0"]),
&versions(&["1.0.0"]),
));
}
#[test]
fn test_generate_diagnostics_from_cache_yanked_only_match_fires_yanked_diagnostic() {
let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "modelled".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 cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".to_string(),
available: Arc::from(vec!["2.0.0".to_string(), "1.2.1".to_string()]),
yanked: Arc::from(vec!["1.2.1".to_string()]),
published_at: None,
},
);
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1, "expected exactly one diagnostic");
assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::WARNING));
assert_eq!(
diagnostics[0].message,
format!("{}; latest is 2.0.0", formatter.yanked_message())
);
}
#[test]
fn test_generate_diagnostics_from_cache_yanked_dedup_in_use_and_requirement_only_match() {
let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "modelled".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 cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".to_string(),
available: Arc::from(vec!["2.0.0".to_string(), "1.2.1".to_string()]),
yanked: Arc::from(vec!["1.2.1".to_string()]),
published_at: None,
},
);
let resolved_versions = HashMap::new();
let mut in_use_yanked = HashMap::new();
in_use_yanked.insert("serde".to_string(), "1.2.1".to_string());
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_yanked(&in_use_yanked),
&formatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let yanked_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.message.starts_with(formatter.yanked_message()))
.collect();
assert_eq!(
yanked_diags.len(),
1,
"expected exactly one yanked diagnostic, got: {diagnostics:?}"
);
assert_eq!(
yanked_diags[0].message,
format!("{} (1.2.1)", formatter.yanked_message())
);
assert_eq!(
diagnostics.len(),
2,
"expected exactly the yanked diagnostic plus the co-emitted outdated \
diagnostic (#263's policy), got: {diagnostics:?}"
);
assert!(
diagnostics
.iter()
.any(|d| d.message == "Newer version available: 2.0.0"),
"expected the co-emitted outdated diagnostic, got: {diagnostics:?}"
);
}
#[test]
fn test_generate_diagnostics_from_cache_yanked_only_match_uses_configured_severity() {
let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "modelled".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 cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "1.2.1".to_string(),
available: Arc::from(vec!["1.2.1".to_string()]),
yanked: Arc::from(vec!["1.2.1".to_string()]),
published_at: None,
},
);
let resolved_versions = HashMap::new();
let severities = DiagnosticSeverities {
yanked: DiagnosticSeverity::ERROR,
..DiagnosticSeverities::default()
};
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
crate::freshness::FreshnessSettings::default(),
severities,
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn test_generate_diagnostics_from_cache_match_with_non_yanked_alternative_skips_yanked_diagnostic()
{
let formatter = TableFormatter::new(|v| Some(v == "1.0.0" || v == "1.0.1"));
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "modelled".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 cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".to_string(),
available: Arc::from(vec![
"2.0.0".to_string(),
"1.0.1".to_string(),
"1.0.0".to_string(),
]),
yanked: Arc::from(vec!["1.0.0".to_string()]),
published_at: None,
},
);
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
!diagnostics
.iter()
.any(|d| d.message.starts_with(formatter.yanked_message())),
"a non-yanked match exists, so no yanked diagnostic should fire, got: {diagnostics:?}"
);
}
#[test]
fn test_undecided_candidate_prevents_true_verdict() {
let formatter = TableFormatter::new(|v| match v {
"1.2.1" => Some(true),
"unparseable" => None,
_ => Some(false),
});
assert!(!requirement_matches_only_yanked(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.2.1", "unparseable"]),
&versions(&["1.2.1"]),
));
}
#[test]
fn test_undecided_candidate_before_match_still_prevents_true_verdict() {
let formatter = TableFormatter::new(|v| match v {
"1.2.1" => Some(true),
"unparseable" => None,
_ => Some(false),
});
assert!(!requirement_matches_only_yanked(
&formatter,
&VersionReq::new("modelled"),
&versions(&["unparseable", "1.2.1"]),
&versions(&["1.2.1"]),
));
}
#[test]
fn test_oversized_requirement_is_false_without_compiling() {
let formatter =
TableFormatter::new(|_v| panic!("must not compile/scan an oversized requirement"));
let oversized = "1".repeat(MAX_REQUIREMENT_LEN + 1);
assert!(!requirement_matches_only_yanked(
&formatter,
&VersionReq::new(oversized),
&versions(&["1.0.0"]),
&versions(&["1.0.0"]),
));
}
#[test]
fn test_yanked_only_match_skipped_for_non_registry_sources() {
let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
let uri = crate::test_util::test_uri("/test/Cargo.toml");
let mut cached_versions = HashMap::new();
cached_versions.insert(
"dep".into(),
PackageVersions {
latest: "2.0.0".to_string(),
available: Arc::from(vec!["2.0.0".to_string(), "1.2.1".to_string()]),
yanked: Arc::from(vec!["1.2.1".to_string()]),
published_at: None,
},
);
let resolved_versions = HashMap::new();
let dep = MockDep {
name: "dep".into(),
version_req: "modelled".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 3)),
};
let parse_result = SingleDepParseResult {
dep: NonRegistryDep(
dep,
crate::parser::DependencySource::Path {
path: "../local".into(),
},
),
uri,
};
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.all(|d| !d.message.starts_with(formatter.yanked_message())),
"a path dependency must never produce the yanked diagnostic, got: {diagnostics:?}"
);
}
#[test]
fn test_yanked_only_match_suppresses_outdated_diagnostic() {
let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "modelled".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 cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".to_string(),
available: Arc::from(vec!["2.0.0".to_string(), "1.2.1".to_string()]),
yanked: Arc::from(vec!["1.2.1".to_string()]),
published_at: None,
},
);
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
!diagnostics
.iter()
.any(|d| d.message.contains("Newer version available")),
"the yanked diagnostic must suppress the outdated hint, not add to it, got: {diagnostics:?}"
);
}
}
}