1use std::collections::HashMap;
2
3use tower_lsp_server::ls_types::{
4 CodeDescription, Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, Location,
5 NumberOrString, Position, Range, Uri,
6};
7
8use crate::osv::{ScanOutcome, diagnostic_severity_for};
9use crate::{
10 ConcreteVersion, Dependency, Deprecation, FetchFailure, ParseResult, PublishTime,
11 RemovalStatus, VersionReq, format_relative_age, is_within_cooldown,
12};
13
14use super::{
15 EcosystemFormatter, PackageVersions, RequirementMatcher, RequirementStatus, VersionData,
16};
17
18pub const UNSATISFIABLE_DIAGNOSTIC_CODE: &str = "unsatisfiable-requirement";
26
27const MAX_BLOCKED_REGISTRY_MESSAGE_VALUE_CHARS: usize = 128;
36
37pub fn truncate_for_diagnostic(value: &str, max_chars: usize) -> std::borrow::Cow<'_, str> {
55 if value.chars().count() <= max_chars {
56 return std::borrow::Cow::Borrowed(value);
57 }
58 let mut truncated: String = value.chars().take(max_chars).collect();
59 truncated.push('…');
60 std::borrow::Cow::Owned(truncated)
61}
62
63pub const DEPRECATED_DIAGNOSTIC_CODE: &str = "deprecated-package";
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct DiagnosticSeverities {
92 pub outdated: DiagnosticSeverity,
94 pub unknown: DiagnosticSeverity,
96 pub yanked: DiagnosticSeverity,
98 pub unsatisfiable: DiagnosticSeverity,
100 pub deprecated: DiagnosticSeverity,
103 pub mutable_ref_pin: DiagnosticSeverity,
110 pub mutable_ref_pin_enabled: bool,
118}
119
120impl Default for DiagnosticSeverities {
121 fn default() -> Self {
122 Self {
123 outdated: DiagnosticSeverity::HINT,
124 unknown: DiagnosticSeverity::WARNING,
125 yanked: DiagnosticSeverity::WARNING,
126 unsatisfiable: DiagnosticSeverity::WARNING,
127 deprecated: DiagnosticSeverity::WARNING,
128 mutable_ref_pin: DiagnosticSeverity::HINT,
129 mutable_ref_pin_enabled: true,
130 }
131 }
132}
133
134pub fn compile_requirement_unless<M>(
182 requirement: &str,
183 is_undecidable: impl FnOnce(&str) -> bool,
184 matcher: impl FnOnce(String) -> M,
185) -> Option<Box<dyn RequirementMatcher>>
186where
187 M: RequirementMatcher + 'static,
188{
189 if is_undecidable(requirement) {
190 return None;
191 }
192 Some(Box::new(matcher(requirement.to_string())))
193}
194
195const MAX_REQUIREMENT_LEN: usize = 256;
207
208pub fn requirement_is_unsatisfiable(
288 formatter: &dyn EcosystemFormatter,
289 requirement: &VersionReq,
290 available: &[ConcreteVersion],
291) -> bool {
292 if available.is_empty() || requirement.as_str().trim().is_empty() {
293 return false;
294 }
295 if requirement.as_str().len() > MAX_REQUIREMENT_LEN {
296 return false;
297 }
298 if formatter.requirement_is_unresolved(requirement) {
299 return false;
300 }
301 if formatter.requirement_is_undecidable_given_available(requirement, available) {
302 return false;
303 }
304 let Some(matcher) = formatter.compile_requirement(requirement) else {
305 return false;
306 };
307
308 let mut saw_decided_false = false;
309 for candidate in available {
310 match matcher.matches(candidate) {
311 Some(true) => return false,
312 Some(false) => saw_decided_false = true,
313 None => {}
314 }
315 }
316 saw_decided_false
317}
318
319fn semver_prerelease_base(version: &str) -> Option<&str> {
329 let core = version.split('+').next().unwrap_or(version);
330 core.find('-').map(|dash| &core[..dash])
331}
332
333fn requirement_names_prerelease(requirement: &str) -> bool {
343 let bytes = requirement.as_bytes();
344 bytes.iter().enumerate().any(|(i, &b)| {
345 b == b'-'
346 && i > 0
347 && i + 1 < bytes.len()
348 && !bytes[i - 1].is_ascii_whitespace()
349 && !bytes[i + 1].is_ascii_whitespace()
350 })
351}
352
353fn matching_prerelease_would_satisfy(
366 formatter: &dyn EcosystemFormatter,
367 requirement: &VersionReq,
368 available: &[ConcreteVersion],
369 yanked: &[(ConcreteVersion, RemovalStatus)],
370) -> Option<String> {
371 if !formatter.strict_semver_prerelease_exclusion() {
372 return None;
373 }
374 if requirement_names_prerelease(requirement.as_str()) {
375 return None;
376 }
377 let matcher = formatter.compile_requirement(requirement)?;
378 available.iter().find_map(|candidate| {
379 let base = semver_prerelease_base(candidate.as_str())?;
380 (!yanked.iter().any(|(y, _)| y == candidate)
381 && matcher.matches(&ConcreteVersion::new(base)) == Some(true))
382 .then(|| candidate.to_string())
383 })
384}
385
386fn status_for_version(
395 yanked: &[(ConcreteVersion, RemovalStatus)],
396 candidate: &ConcreteVersion,
397) -> Option<RemovalStatus> {
398 let mut found: Option<RemovalStatus> = None;
399 for (y, status) in yanked {
400 if y == candidate {
401 if *status == RemovalStatus::Yanked {
402 return Some(RemovalStatus::Yanked);
403 }
404 found = Some(*status);
405 }
406 }
407 found
408}
409
410fn requirement_matches_only_yanked(
440 formatter: &dyn EcosystemFormatter,
441 requirement: &VersionReq,
442 available: &[ConcreteVersion],
443 yanked: &[(ConcreteVersion, RemovalStatus)],
444) -> Option<RemovalStatus> {
445 if available.is_empty() || yanked.is_empty() || requirement.as_str().trim().is_empty() {
446 return None;
447 }
448 if requirement.as_str().len() > MAX_REQUIREMENT_LEN {
449 return None;
450 }
451 if formatter.requirement_is_unresolved(requirement) {
452 return None;
453 }
454 let matcher = formatter.compile_requirement(requirement)?;
455
456 let mut saw_match = false;
457 let mut saw_undecided = false;
458 let mut aggregate: Option<RemovalStatus> = None;
459 for candidate in available {
460 match matcher.matches(candidate) {
461 Some(true) => {
462 saw_match = true;
463 let status = status_for_version(yanked, candidate)?;
464 if aggregate != Some(RemovalStatus::Yanked) {
465 aggregate = Some(status);
466 }
467 }
468 Some(false) => {}
469 None => saw_undecided = true,
470 }
471 }
472 if saw_match && !saw_undecided {
473 aggregate
474 } else {
475 None
476 }
477}
478
479pub fn generate_diagnostics_from_cache(
502 parse_result: &dyn ParseResult,
503 versions: VersionData<'_>,
504 formatter: &dyn EcosystemFormatter,
505 uri: &Uri,
506 freshness: crate::freshness::FreshnessSettings,
507 severities: DiagnosticSeverities,
508 now: PublishTime,
509) -> Vec<Diagnostic> {
510 let deps = parse_result.dependencies();
511 let mut diagnostics = Vec::with_capacity(deps.len());
512 let mut fetch_failed: Vec<FetchFailureEntry> = Vec::new();
519
520 offline_notice(&mut diagnostics, versions, &deps);
521 blocked_registry_diagnostics(&mut diagnostics, parse_result);
522
523 let vuln_keys = versions.ecosystem.map(|ecosystem| {
529 crate::osv::vulnerability_keys(parse_result, versions.resolved, formatter, ecosystem)
530 });
531
532 for dep in deps {
533 let normalized_name = formatter.normalize_package_name(dep.name());
534 let ctx = RuleContext {
535 dep,
536 normalized_name: &normalized_name,
537 versions,
538 formatter,
539 severities,
540 freshness,
541 now,
542 };
543
544 apply_vulnerability_rule(&mut diagnostics, &ctx, vuln_keys.as_ref());
549 let deprecation_found = apply_deprecation_rule(&mut diagnostics, &ctx);
550 let in_use_yanked_emitted =
551 apply_in_use_yanked_rule(&mut diagnostics, &ctx, deprecation_found);
552
553 let Some(package_versions) = ctx.cached_versions() else {
554 apply_unknown_package_rule(&mut diagnostics, &mut fetch_failed, &ctx);
555 continue;
556 };
557
558 let Some(version_range) = dep.version_range() else {
560 continue;
561 };
562 let resolved = ResolvedData {
563 package_versions,
564 version_range,
565 };
566
567 if apply_unsatisfiable_rule(&mut diagnostics, &ctx, &resolved) == RuleFlow::Stop {
568 continue;
569 }
570 if apply_yanked_only_rule(
571 &mut diagnostics,
572 &ctx,
573 &resolved,
574 YankedOnlyPrior {
575 deprecation_found,
576 in_use_yanked_emitted,
577 },
578 ) == RuleFlow::Stop
579 {
580 continue;
581 }
582 apply_outdated_rule(&mut diagnostics, &ctx, &resolved);
583 }
584
585 push_collapsed_fetch_failures(&mut diagnostics, fetch_failed, uri);
586 diagnostics
587}
588
589struct RuleContext<'a> {
596 dep: &'a dyn Dependency,
597 normalized_name: &'a str,
600 versions: VersionData<'a>,
601 formatter: &'a dyn EcosystemFormatter,
602 severities: DiagnosticSeverities,
603 freshness: crate::freshness::FreshnessSettings,
607 now: PublishTime,
610}
611
612impl<'a> RuleContext<'a> {
613 fn cached_versions(&self) -> Option<&'a PackageVersions> {
617 self.versions
618 .cached
619 .get(self.normalized_name)
620 .or_else(|| self.versions.cached.get(self.dep.name()))
621 }
622}
623
624struct ResolvedData<'a> {
630 package_versions: &'a PackageVersions,
631 version_range: Range,
632}
633
634#[derive(Debug, Clone, Copy, PartialEq, Eq)]
645enum RuleFlow {
646 Continue,
648 Stop,
650}
651
652struct FetchFailureEntry {
659 name: String,
660 diagnostic: Diagnostic,
661 failure: Option<FetchFailure>,
662}
663
664struct YankedOnlyPrior {
677 deprecation_found: bool,
678 in_use_yanked_emitted: bool,
679}
680
681fn offline_notice(
699 diagnostics: &mut Vec<Diagnostic>,
700 versions: VersionData<'_>,
701 deps: &[&dyn Dependency],
702) {
703 if versions.offline && !deps.is_empty() {
704 diagnostics.push(Diagnostic {
705 range: Range {
706 start: Position::new(0, 0),
707 end: Position::new(0, 0),
708 },
709 severity: Some(DiagnosticSeverity::INFORMATION),
710 message: "deps-lsp is offline (network.offline): dependency and vulnerability \
711 data reflects only what was already cached, not the current registry state"
712 .to_string(),
713 source: Some("deps-lsp".into()),
714 ..Default::default()
715 });
716 }
717}
718
719fn blocked_registry_diagnostics(diagnostics: &mut Vec<Diagnostic>, parse_result: &dyn ParseResult) {
731 for (range, class, raw_value) in parse_result.blocked_registries() {
732 diagnostics.push(Diagnostic {
733 range,
734 severity: Some(DiagnosticSeverity::INFORMATION),
735 message: format!(
736 "registry index \"{}\" blocked by registries.workspace_registries policy \
737 (host class: {class})",
738 truncate_for_diagnostic(&raw_value, MAX_BLOCKED_REGISTRY_MESSAGE_VALUE_CHARS)
739 ),
740 source: Some("deps-lsp".into()),
741 ..Default::default()
742 });
743 }
744}
745
746fn apply_vulnerability_rule(
760 diagnostics: &mut Vec<Diagnostic>,
761 ctx: &RuleContext<'_>,
762 vuln_keys: Option<&HashMap<Range, String>>,
763) {
764 if let Some(vulnerabilities) = ctx.versions.vulnerabilities
765 && let Some(ScanOutcome::Vulnerable(dv)) = vuln_keys
766 .and_then(|keys| keys.get(&ctx.dep.name_range()))
767 .and_then(|key| vulnerabilities.get(key))
768 .or_else(|| vulnerabilities.get(ctx.normalized_name))
769 .or_else(|| vulnerabilities.get(ctx.dep.name().as_str()))
770 {
771 push_vulnerability_diagnostics(diagnostics, ctx.dep, dv);
772 }
773}
774
775fn apply_deprecation_rule(diagnostics: &mut Vec<Diagnostic>, ctx: &RuleContext<'_>) -> bool {
796 let deprecation = ctx
797 .formatter
798 .can_resolve_source(&ctx.dep.source())
799 .then(|| {
800 ctx.versions
801 .outcomes
802 .and_then(|o| o.deprecation(ctx.normalized_name))
803 })
804 .flatten();
805 if let Some(dep_info) = deprecation {
806 push_deprecation_diagnostic(
807 diagnostics,
808 ctx.dep,
809 ctx.formatter,
810 dep_info,
811 ctx.severities,
812 );
813 }
814 deprecation.is_some()
815}
816
817fn apply_in_use_yanked_rule(
873 diagnostics: &mut Vec<Diagnostic>,
874 ctx: &RuleContext<'_>,
875 deprecation_found: bool,
876) -> bool {
877 let deprecation_suppresses_yanked = deprecation_found
878 && ctx
879 .versions
880 .outcomes
881 .and_then(|o| o.yanked(ctx.normalized_name))
882 .is_some_and(|(_, status)| *status != RemovalStatus::Yanked);
883
884 if deprecation_suppresses_yanked {
885 false
886 } else if let Some((yanked_version, _status)) = ctx
887 .versions
888 .outcomes
889 .and_then(|o| o.yanked(ctx.normalized_name))
890 && ctx.versions.ecosystem.is_none_or(|ecosystem| {
891 super::in_use_version(
892 ctx.dep,
893 ctx.normalized_name,
894 ctx.versions.resolved,
895 ctx.formatter,
896 ecosystem,
897 )
898 .as_deref()
899 == Some(yanked_version.as_str())
900 })
901 {
902 diagnostics.push(Diagnostic {
903 range: ctx
904 .dep
905 .version_range()
906 .unwrap_or_else(|| ctx.dep.name_range()),
907 severity: Some(ctx.severities.yanked),
908 message: format!("{} ({})", ctx.formatter.yanked_message(), yanked_version),
909 source: Some("deps-lsp".into()),
910 ..Default::default()
911 });
912 true
913 } else {
914 false
915 }
916}
917
918fn apply_unknown_package_rule(
954 diagnostics: &mut Vec<Diagnostic>,
955 fetch_failed: &mut Vec<FetchFailureEntry>,
956 ctx: &RuleContext<'_>,
957) {
958 let dep = ctx.dep;
959 let in_lockfile = ctx.versions.resolved.contains_key(ctx.normalized_name)
960 || ctx.versions.resolved.contains_key(dep.name());
961 if in_lockfile {
962 return;
963 }
964
965 let can_resolve_source = ctx.formatter.can_resolve_source(&dep.source());
966 let fetch_failure: Option<&FetchFailure> = can_resolve_source
967 .then(|| {
968 ctx.versions
969 .outcomes
970 .and_then(|o| o.fetch_failure(ctx.normalized_name))
971 })
972 .flatten();
973 let no_comparable_versions = can_resolve_source
974 && ctx
975 .versions
976 .outcomes
977 .is_some_and(|o| o.no_comparable_versions(ctx.normalized_name));
978 match ctx.formatter.validate_package_name(dep.name().as_str()) {
979 Err(reason) => {
980 diagnostics.push(Diagnostic {
981 range: dep.name_range(),
982 severity: Some(ctx.severities.unknown),
983 message: format!("Invalid package name '{}': {reason}", dep.name()),
984 source: Some("deps-lsp".into()),
985 ..Default::default()
986 });
987 }
988 Ok(()) if fetch_failure.is_some() && ctx.versions.offline => {}
989 Ok(()) if fetch_failure.is_some() => {
990 let message = match fetch_failure {
991 Some(FetchFailure::Actionable(hint)) => {
992 format!("Registry lookup failed for '{}': {hint}", dep.name())
993 }
994 Some(FetchFailure::Transient | FetchFailure::NotAttempted) | None => {
995 format!(
996 "Registry lookup failed for '{}'; package status could not be determined",
997 dep.name()
998 )
999 }
1000 };
1001 fetch_failed.push(FetchFailureEntry {
1002 name: dep.name().to_string(),
1003 diagnostic: Diagnostic {
1004 range: dep.name_range(),
1005 severity: Some(ctx.severities.unknown),
1006 message,
1007 source: Some("deps-lsp".into()),
1008 ..Default::default()
1009 },
1010 failure: fetch_failure.cloned(),
1011 });
1012 }
1013 Ok(()) if no_comparable_versions => {}
1014 Ok(()) if can_resolve_source => {
1015 diagnostics.push(Diagnostic {
1016 range: dep.name_range(),
1017 severity: Some(ctx.severities.unknown),
1018 message: format!("Unknown package '{}'", dep.name()),
1019 source: Some("deps-lsp".into()),
1020 ..Default::default()
1021 });
1022 }
1023 Ok(()) => {}
1024 }
1025}
1026
1027fn apply_unsatisfiable_rule(
1048 diagnostics: &mut Vec<Diagnostic>,
1049 ctx: &RuleContext<'_>,
1050 resolved: &ResolvedData<'_>,
1051) -> RuleFlow {
1052 let dep = ctx.dep;
1053 let package_versions = resolved.package_versions;
1054 let latest = &package_versions.latest;
1055
1056 let unsatisfiable = ctx.formatter.can_resolve_source(&dep.source())
1057 && dep.version_requirement().is_some_and(|version_req| {
1058 requirement_is_unsatisfiable(ctx.formatter, version_req, &package_versions.available)
1059 });
1060
1061 if !unsatisfiable {
1062 return RuleFlow::Continue;
1063 }
1064
1065 let req_str = dep.version_requirement().map_or("", |r| r.as_str());
1066 let mut message =
1067 format!("No published version satisfies requirement '{req_str}'; latest is {latest}");
1068 if let Some(prerelease) = dep.version_requirement().and_then(|version_req| {
1069 matching_prerelease_would_satisfy(
1070 ctx.formatter,
1071 version_req,
1072 &package_versions.available,
1073 &package_versions.yanked,
1074 )
1075 }) {
1076 use std::fmt::Write as _;
1077 let _ = write!(
1078 message,
1079 " (a pre-release, {prerelease}, is excluded by SemVer's default \
1080 pre-release-matching rules; require it explicitly to use it)"
1081 );
1082 }
1083 diagnostics.push(Diagnostic {
1084 range: resolved.version_range,
1085 severity: Some(ctx.severities.unsatisfiable),
1086 message,
1087 source: Some("deps-lsp".into()),
1088 code: Some(NumberOrString::String(UNSATISFIABLE_DIAGNOSTIC_CODE.into())),
1089 ..Default::default()
1090 });
1091 RuleFlow::Stop
1092}
1093
1094fn apply_yanked_only_rule(
1129 diagnostics: &mut Vec<Diagnostic>,
1130 ctx: &RuleContext<'_>,
1131 resolved: &ResolvedData<'_>,
1132 prior: YankedOnlyPrior,
1133) -> RuleFlow {
1134 let YankedOnlyPrior {
1135 deprecation_found,
1136 in_use_yanked_emitted,
1137 } = prior;
1138 let dep = ctx.dep;
1139 let package_versions = resolved.package_versions;
1140 let latest = &package_versions.latest;
1141
1142 let yanked_only_status = (!in_use_yanked_emitted
1143 && ctx.formatter.can_resolve_source(&dep.source()))
1144 .then(|| dep.version_requirement())
1145 .flatten()
1146 .filter(|version_req| ctx.formatter.yanked_diagnostic_applies_to(dep, version_req))
1147 .and_then(|version_req| {
1148 requirement_matches_only_yanked(
1149 ctx.formatter,
1150 version_req,
1151 &package_versions.available,
1152 &package_versions.yanked,
1153 )
1154 });
1155
1156 let yanked_only = yanked_only_status
1157 .is_some_and(|status| status != RemovalStatus::AdvisoryDeprecated || !deprecation_found);
1158
1159 if !yanked_only {
1160 return RuleFlow::Continue;
1161 }
1162
1163 diagnostics.push(Diagnostic {
1164 range: resolved.version_range,
1165 severity: Some(ctx.severities.yanked),
1166 message: format!("{}; latest is {latest}", ctx.formatter.yanked_message()),
1167 source: Some("deps-lsp".into()),
1168 ..Default::default()
1169 });
1170 RuleFlow::Stop
1171}
1172
1173fn apply_outdated_rule(
1187 diagnostics: &mut Vec<Diagnostic>,
1188 ctx: &RuleContext<'_>,
1189 resolved: &ResolvedData<'_>,
1190) {
1191 let dep = ctx.dep;
1192 let package_versions = resolved.package_versions;
1193 let latest = &package_versions.latest;
1194
1195 let status = match dep.version_requirement() {
1196 Some(version_req) if ctx.formatter.can_resolve_source(&dep.source()) => ctx
1197 .formatter
1198 .requirement_status_for(dep, version_req, latest),
1199 _ => RequirementStatus::Unresolved,
1200 };
1201
1202 if status != RequirementStatus::Outdated {
1203 return;
1204 }
1205
1206 let published_at = ctx
1207 .freshness
1208 .enabled
1209 .then_some(package_versions.published_at)
1210 .flatten();
1211 let message = match published_at {
1212 Some(published_at)
1213 if is_within_cooldown(
1214 published_at.age_secs_from(ctx.now),
1215 ctx.freshness.cooldown_secs,
1216 ) =>
1217 {
1218 format!(
1219 "Newer version available: {latest} (published {} — still within the release cooldown window)",
1220 format_relative_age(published_at.age_secs_from(ctx.now))
1221 )
1222 }
1223 _ => format!("Newer version available: {latest}"),
1224 };
1225 diagnostics.push(Diagnostic {
1226 range: resolved.version_range,
1227 severity: Some(ctx.severities.outdated),
1228 message,
1229 source: Some("deps-lsp".into()),
1230 ..Default::default()
1231 });
1232}
1233
1234fn push_collapsed_fetch_failures(
1253 diagnostics: &mut Vec<Diagnostic>,
1254 fetch_failed: Vec<FetchFailureEntry>,
1255 uri: &Uri,
1256) {
1257 match fetch_failed.len() {
1258 0 => {}
1259 1 => diagnostics.extend(fetch_failed.into_iter().map(|entry| entry.diagnostic)),
1260 n => {
1261 let first = &fetch_failed[0].diagnostic;
1262 let range = first.range;
1263 let severity = first.severity;
1264 let shared_hint = fetch_failed.iter().find_map(|entry| match &entry.failure {
1269 Some(FetchFailure::Actionable(hint)) => Some(hint.clone()),
1270 _ => None,
1271 });
1272 let message = match shared_hint {
1273 Some(hint) => format!("Registry lookup failed for {n} packages: {hint}"),
1274 None => format!(
1275 "Registry lookup failed for {n} packages; package status could not be determined"
1276 ),
1277 };
1278 let related_information = fetch_failed[1..]
1279 .iter()
1280 .map(|entry| DiagnosticRelatedInformation {
1281 location: Location {
1282 uri: uri.clone(),
1283 range: entry.diagnostic.range,
1284 },
1285 message: format!("'{}' also failed", entry.name),
1286 })
1287 .collect();
1288 diagnostics.push(Diagnostic {
1289 range,
1290 severity,
1291 message,
1292 source: Some("deps-lsp".into()),
1293 related_information: Some(related_information),
1294 ..Default::default()
1295 });
1296 }
1297 }
1298}
1299
1300fn push_deprecation_diagnostic(
1307 diagnostics: &mut Vec<Diagnostic>,
1308 dep: &dyn Dependency,
1309 formatter: &dyn EcosystemFormatter,
1310 deprecation: &Deprecation,
1311 severities: DiagnosticSeverities,
1312) {
1313 use std::fmt::Write as _;
1314
1315 let range = dep.version_range().unwrap_or_else(|| dep.name_range());
1316
1317 let mut message = formatter.deprecated_message().to_string();
1318 if let Some(reason) = deprecation.reason.as_deref().filter(|r| !r.is_empty()) {
1319 let _ = write!(message, ": {reason}");
1320 }
1321 if let Some(replacement) = deprecation.replacement.as_deref().filter(|r| !r.is_empty()) {
1322 let _ = write!(message, " (replacement: {replacement})");
1323 }
1324
1325 diagnostics.push(Diagnostic {
1326 range,
1327 severity: Some(severities.deprecated),
1328 message,
1329 source: Some("deps-lsp".into()),
1330 code: Some(NumberOrString::String(DEPRECATED_DIAGNOSTIC_CODE.into())),
1331 ..Default::default()
1332 });
1333}
1334
1335fn push_vulnerability_diagnostics(
1343 diagnostics: &mut Vec<Diagnostic>,
1344 dep: &dyn Dependency,
1345 dv: &crate::osv::DependencyVulnerabilities,
1346) {
1347 let range = dep.version_range().unwrap_or_else(|| dep.name_range());
1348
1349 for advisory in dv.advisories.items() {
1350 let code_description = advisory
1351 .url
1352 .parse::<Uri>()
1353 .ok()
1354 .map(|href| CodeDescription { href });
1355
1356 diagnostics.push(Diagnostic {
1357 range,
1358 severity: Some(diagnostic_severity_for(advisory.severity)),
1359 message: format!(
1360 "{}: {}",
1361 advisory.id,
1362 advisory
1363 .summary
1364 .as_deref()
1365 .unwrap_or("(no summary provided)")
1366 ),
1367 code: Some(NumberOrString::String(advisory.id.clone())),
1368 code_description,
1369 source: Some("deps-lsp".into()),
1370 ..Default::default()
1371 });
1372 }
1373
1374 let remaining = dv.advisories.remaining();
1375 if remaining > 0 {
1376 diagnostics.push(Diagnostic {
1377 range,
1378 severity: Some(DiagnosticSeverity::INFORMATION),
1379 message: format!("+{remaining} more advisories"),
1380 source: Some("deps-lsp".into()),
1381 ..Default::default()
1382 });
1383 }
1384}
1385
1386#[cfg(test)]
1387mod tests {
1388 use super::*;
1389 use crate::lsp_helpers::test_support::*;
1390 use crate::lsp_helpers::*;
1391 use crate::{PackageName, VersionReq};
1392
1393 use std::collections::HashMap;
1394 use std::sync::Arc;
1395
1396 #[test]
1397 fn test_generate_diagnostics_from_cache_unknown_package() {
1398 use std::collections::HashMap;
1399 use tower_lsp_server::ls_types::{Position, Range};
1400
1401 let formatter = MockFormatter;
1402
1403 let parse_result = MockParseResult {
1404 deps: vec![MockDep {
1405 name: "unknown-pkg".into(),
1406 version_req: "1.0.0".into(),
1407 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1408 name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
1409 }],
1410 uri: crate::test_util::test_uri("/test/Cargo.toml"),
1411 };
1412
1413 let cached_versions = HashMap::new();
1414 let resolved_versions = HashMap::new();
1415
1416 let diagnostics = generate_diagnostics_from_cache(
1417 &parse_result,
1418 VersionData::new(&cached_versions, &resolved_versions),
1419 &formatter,
1420 parse_result.uri(),
1421 crate::freshness::FreshnessSettings::default(),
1422 DiagnosticSeverities::default(),
1423 PublishTime::now(),
1424 );
1425
1426 assert_eq!(diagnostics.len(), 1);
1427 assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::WARNING));
1428 assert!(diagnostics[0].message.contains("Unknown package"));
1429 assert!(diagnostics[0].message.contains("unknown-pkg"));
1430 }
1431
1432 #[test]
1440 fn test_generate_diagnostics_from_cache_no_comparable_versions_is_not_unknown_package() {
1441 use tower_lsp_server::ls_types::{Position, Range};
1442
1443 let formatter = MockFormatter;
1444
1445 let parse_result = MockParseResult {
1446 deps: vec![MockDep {
1447 name: "dtolnay/rust-toolchain".into(),
1448 version_req: "stable".into(),
1449 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1450 name_range: Range::new(Position::new(0, 0), Position::new(0, 23)),
1451 }],
1452 uri: crate::test_util::test_uri("/repo/.github/workflows/ci.yml"),
1453 };
1454
1455 let cached_versions = HashMap::new();
1456 let resolved_versions = HashMap::new();
1457 let outcomes =
1458 DependencyOutcomes::new().with_no_comparable_versions("dtolnay/rust-toolchain");
1459
1460 let diagnostics = generate_diagnostics_from_cache(
1461 &parse_result,
1462 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
1463 &formatter,
1464 parse_result.uri(),
1465 crate::freshness::FreshnessSettings::default(),
1466 DiagnosticSeverities::default(),
1467 PublishTime::now(),
1468 );
1469
1470 assert!(
1471 diagnostics.is_empty(),
1472 "expected no diagnostic for a package with no comparable versions, got: {diagnostics:?}"
1473 );
1474 }
1475
1476 #[test]
1480 fn test_generate_diagnostics_from_cache_emits_blocked_registry_diagnostic() {
1481 use crate::net_policy::HostClass;
1482 use tower_lsp_server::ls_types::{Position, Range};
1483
1484 struct BlockedRegistryParseResult {
1485 deps: Vec<MockDep>,
1486 uri: Uri,
1487 blocked: Vec<(Range, HostClass, String)>,
1488 }
1489
1490 impl ParseResult for BlockedRegistryParseResult {
1491 fn dependencies(&self) -> Vec<&dyn Dependency> {
1492 self.deps.iter().map(|d| d as &dyn Dependency).collect()
1493 }
1494 fn workspace_root(&self) -> Option<&std::path::Path> {
1495 None
1496 }
1497 fn uri(&self) -> &Uri {
1498 &self.uri
1499 }
1500 fn blocked_registries(&self) -> Vec<(Range, HostClass, String)> {
1501 self.blocked.clone()
1502 }
1503 fn as_any(&self) -> &dyn std::any::Any {
1504 self
1505 }
1506 }
1507
1508 let name_range = Range::new(Position::new(0, 0), Position::new(0, 14));
1509 let formatter = MockFormatter;
1510 let parse_result = BlockedRegistryParseResult {
1511 deps: vec![MockDep {
1512 name: "internal-crate".into(),
1513 version_req: "1.0.0".into(),
1514 version_range: Range::new(Position::new(0, 20), Position::new(0, 25)),
1515 name_range,
1516 }],
1517 uri: crate::test_util::test_uri("/test/Cargo.toml"),
1518 blocked: vec![(
1519 name_range,
1520 HostClass::CloudMetadata,
1521 "https://169.254.169.254/index".to_string(),
1522 )],
1523 };
1524
1525 let cached_versions = HashMap::new();
1526 let resolved_versions = HashMap::new();
1527
1528 let diagnostics = generate_diagnostics_from_cache(
1529 &parse_result,
1530 VersionData::new(&cached_versions, &resolved_versions),
1531 &formatter,
1532 parse_result.uri(),
1533 crate::freshness::FreshnessSettings::default(),
1534 DiagnosticSeverities::default(),
1535 PublishTime::now(),
1536 );
1537
1538 let blocked_diagnostic = diagnostics
1539 .iter()
1540 .find(|d| d.message.contains("blocked"))
1541 .expect("expected a blocked-registry diagnostic");
1542 assert_eq!(blocked_diagnostic.range, name_range);
1543 assert_eq!(
1544 blocked_diagnostic.severity,
1545 Some(DiagnosticSeverity::INFORMATION)
1546 );
1547 assert!(blocked_diagnostic.message.contains("169.254.169.254"));
1548 assert!(blocked_diagnostic.message.contains("cloud metadata"));
1549 assert!(
1550 !blocked_diagnostic.message.contains("CloudMetadata"),
1551 "message must use the Display form, not the Debug identifier"
1552 );
1553 }
1554
1555 #[test]
1556 fn test_truncate_for_diagnostic_leaves_short_value_untouched() {
1557 assert_eq!(truncate_for_diagnostic("my-corp", 128), "my-corp");
1558 }
1559
1560 #[test]
1561 fn test_truncate_for_diagnostic_truncates_and_appends_ellipsis() {
1562 let long_value = "a".repeat(200);
1563 let truncated = truncate_for_diagnostic(&long_value, 128);
1564 assert_eq!(truncated.chars().count(), 129); assert!(truncated.ends_with('…'));
1566 assert!(truncated.starts_with(&"a".repeat(128)));
1567 }
1568
1569 #[test]
1570 fn test_truncate_for_diagnostic_never_splits_a_multibyte_character() {
1571 let long_value = "日".repeat(200);
1574 let truncated = truncate_for_diagnostic(&long_value, 128);
1575 assert_eq!(truncated.chars().count(), 129);
1576 }
1577
1578 #[test]
1582 fn test_generate_diagnostics_from_cache_blocked_registry_message_caps_long_raw_value() {
1583 use crate::net_policy::HostClass;
1584 use tower_lsp_server::ls_types::{Position, Range};
1585
1586 struct BlockedRegistryParseResult {
1587 deps: Vec<MockDep>,
1588 uri: Uri,
1589 blocked: Vec<(Range, HostClass, String)>,
1590 }
1591
1592 impl ParseResult for BlockedRegistryParseResult {
1593 fn dependencies(&self) -> Vec<&dyn Dependency> {
1594 self.deps.iter().map(|d| d as &dyn Dependency).collect()
1595 }
1596 fn workspace_root(&self) -> Option<&std::path::Path> {
1597 None
1598 }
1599 fn uri(&self) -> &Uri {
1600 &self.uri
1601 }
1602 fn blocked_registries(&self) -> Vec<(Range, HostClass, String)> {
1603 self.blocked.clone()
1604 }
1605 fn as_any(&self) -> &dyn std::any::Any {
1606 self
1607 }
1608 }
1609
1610 let name_range = Range::new(Position::new(0, 0), Position::new(0, 14));
1611 let formatter = MockFormatter;
1612 let long_alias = "x".repeat(10_000);
1613 let parse_result = BlockedRegistryParseResult {
1614 deps: vec![MockDep {
1615 name: "internal-crate".into(),
1616 version_req: "1.0.0".into(),
1617 version_range: Range::new(Position::new(0, 20), Position::new(0, 25)),
1618 name_range,
1619 }],
1620 uri: crate::test_util::test_uri("/test/Cargo.toml"),
1621 blocked: vec![(name_range, HostClass::InternalName, long_alias.clone())],
1622 };
1623
1624 let cached_versions = HashMap::new();
1625 let resolved_versions = HashMap::new();
1626
1627 let diagnostics = generate_diagnostics_from_cache(
1628 &parse_result,
1629 VersionData::new(&cached_versions, &resolved_versions),
1630 &formatter,
1631 parse_result.uri(),
1632 crate::freshness::FreshnessSettings::default(),
1633 DiagnosticSeverities::default(),
1634 PublishTime::now(),
1635 );
1636
1637 let blocked_diagnostic = diagnostics
1638 .iter()
1639 .find(|d| d.message.contains("blocked"))
1640 .expect("expected a blocked-registry diagnostic");
1641 assert!(
1642 blocked_diagnostic.message.len() < long_alias.len(),
1643 "a 10,000-char alias must not render in full inside the diagnostic message"
1644 );
1645 assert!(blocked_diagnostic.message.contains('…'));
1646 }
1647
1648 #[test]
1649 fn test_generate_diagnostics_from_cache_fetch_failed_not_reported_as_unknown() {
1650 use std::collections::HashMap;
1651 use tower_lsp_server::ls_types::{Position, Range};
1652
1653 let formatter = MockFormatter;
1658
1659 let parse_result = MockParseResult {
1660 deps: vec![MockDep {
1661 name: "flaky-pkg".into(),
1662 version_req: "1.0.0".into(),
1663 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1664 name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
1665 }],
1666 uri: crate::test_util::test_uri("/test/Cargo.toml"),
1667 };
1668
1669 let cached_versions = HashMap::new();
1670 let resolved_versions = HashMap::new();
1671 let outcomes =
1672 DependencyOutcomes::new().with_fetch_failure("flaky-pkg", FetchFailure::Transient);
1673
1674 let diagnostics = generate_diagnostics_from_cache(
1675 &parse_result,
1676 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
1677 &formatter,
1678 parse_result.uri(),
1679 crate::freshness::FreshnessSettings::default(),
1680 DiagnosticSeverities::default(),
1681 PublishTime::now(),
1682 );
1683
1684 assert_eq!(diagnostics.len(), 1);
1685 assert!(!diagnostics[0].message.contains("Unknown package"));
1686 assert!(diagnostics[0].message.contains("Registry lookup failed"));
1687 assert!(diagnostics[0].message.contains("flaky-pkg"));
1688 }
1689
1690 #[test]
1695 fn test_generate_diagnostics_from_cache_offline_suppresses_per_dependency_warning() {
1696 use std::collections::HashMap;
1697 use tower_lsp_server::ls_types::{Position, Range};
1698
1699 let formatter = MockFormatter;
1700
1701 let parse_result = MockParseResult {
1702 deps: vec![
1703 MockDep {
1704 name: "flaky-pkg-a".into(),
1705 version_req: "1.0.0".into(),
1706 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1707 name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
1708 },
1709 MockDep {
1710 name: "flaky-pkg-b".into(),
1711 version_req: "1.0.0".into(),
1712 version_range: Range::new(Position::new(1, 10), Position::new(1, 20)),
1713 name_range: Range::new(Position::new(1, 0), Position::new(1, 11)),
1714 },
1715 ],
1716 uri: crate::test_util::test_uri("/test/Cargo.toml"),
1717 };
1718
1719 let cached_versions = HashMap::new();
1720 let resolved_versions = HashMap::new();
1721 let outcomes = DependencyOutcomes::new()
1722 .with_fetch_failure("flaky-pkg-a", FetchFailure::Transient)
1723 .with_fetch_failure("flaky-pkg-b", FetchFailure::Transient);
1724
1725 let diagnostics = generate_diagnostics_from_cache(
1726 &parse_result,
1727 VersionData::new(&cached_versions, &resolved_versions)
1728 .with_outcomes(&outcomes)
1729 .with_offline(true),
1730 &formatter,
1731 parse_result.uri(),
1732 crate::freshness::FreshnessSettings::default(),
1733 DiagnosticSeverities::default(),
1734 PublishTime::now(),
1735 );
1736
1737 assert!(
1738 !diagnostics
1739 .iter()
1740 .any(|d| d.message.contains("Registry lookup failed")),
1741 "the per-dependency WARNING must not fire while offline, got: {diagnostics:?}"
1742 );
1743 let offline_diagnostics: Vec<_> = diagnostics
1744 .iter()
1745 .filter(|d| d.severity == Some(DiagnosticSeverity::INFORMATION))
1746 .filter(|d| d.message.to_lowercase().contains("offline"))
1747 .collect();
1748 assert_eq!(
1749 offline_diagnostics.len(),
1750 1,
1751 "expected exactly one file-level offline diagnostic, not one per dependency; \
1752 got: {diagnostics:?}"
1753 );
1754 }
1755
1756 #[test]
1761 fn test_generate_diagnostics_from_cache_offline_signal_present_even_with_no_fetch_failures() {
1762 use std::collections::HashMap;
1763 use tower_lsp_server::ls_types::{Position, Range};
1764
1765 let formatter = MockFormatter;
1766
1767 let parse_result = MockParseResult {
1768 deps: vec![MockDep {
1769 name: "serde".into(),
1770 version_req: "=1.0.0".into(),
1771 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1772 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
1773 }],
1774 uri: crate::test_util::test_uri("/test/Cargo.toml"),
1775 };
1776
1777 let mut cached_versions = HashMap::new();
1778 cached_versions.insert("serde".into(), PackageVersions::latest_only("1.0.0"));
1779 let resolved_versions = HashMap::new();
1780
1781 let diagnostics = generate_diagnostics_from_cache(
1782 &parse_result,
1783 VersionData::new(&cached_versions, &resolved_versions).with_offline(true),
1784 &formatter,
1785 parse_result.uri(),
1786 crate::freshness::FreshnessSettings::default(),
1787 DiagnosticSeverities::default(),
1788 PublishTime::now(),
1789 );
1790
1791 assert!(
1792 diagnostics
1793 .iter()
1794 .any(|d| d.severity == Some(DiagnosticSeverity::INFORMATION)
1795 && d.message.to_lowercase().contains("offline")),
1796 "expected a file-level offline diagnostic even with zero fetch failures; \
1797 got: {diagnostics:?}"
1798 );
1799 }
1800
1801 #[test]
1802 fn test_generate_diagnostics_from_cache_fetch_failed_actionable_shows_hint() {
1803 use std::collections::HashMap;
1804 use tower_lsp_server::ls_types::{Position, Range};
1805
1806 let formatter = MockFormatter;
1809
1810 let parse_result = MockParseResult {
1811 deps: vec![MockDep {
1812 name: "rate-limited-pkg".into(),
1813 version_req: "1.0.0".into(),
1814 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1815 name_range: Range::new(Position::new(0, 0), Position::new(0, 16)),
1816 }],
1817 uri: crate::test_util::test_uri("/test/Cargo.toml"),
1818 };
1819
1820 let cached_versions = HashMap::new();
1821 let resolved_versions = HashMap::new();
1822 let outcomes = DependencyOutcomes::new().with_fetch_failure(
1823 "rate-limited-pkg",
1824 FetchFailure::Actionable("set GITHUB_TOKEN to increase the rate limit".to_string()),
1825 );
1826
1827 let diagnostics = generate_diagnostics_from_cache(
1828 &parse_result,
1829 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
1830 &formatter,
1831 parse_result.uri(),
1832 crate::freshness::FreshnessSettings::default(),
1833 DiagnosticSeverities::default(),
1834 PublishTime::now(),
1835 );
1836
1837 assert_eq!(diagnostics.len(), 1);
1838 assert!(!diagnostics[0].message.contains("Unknown package"));
1839 assert!(
1840 diagnostics[0]
1841 .message
1842 .contains("set GITHUB_TOKEN to increase the rate limit")
1843 );
1844 assert!(diagnostics[0].message.contains("rate-limited-pkg"));
1845 }
1846
1847 #[test]
1848 fn test_generate_diagnostics_from_cache_multiple_fetch_failed_collapse_into_one_diagnostic() {
1849 use std::collections::HashMap;
1850 use tower_lsp_server::ls_types::{Position, Range};
1851
1852 let formatter = MockFormatter;
1858
1859 let name_range_1 = Range::new(Position::new(0, 0), Position::new(0, 8));
1860 let name_range_2 = Range::new(Position::new(1, 0), Position::new(1, 8));
1861 let name_range_3 = Range::new(Position::new(2, 0), Position::new(2, 8));
1862
1863 let parse_result = MockParseResult {
1864 deps: vec![
1865 MockDep {
1866 name: "flaky-1".into(),
1867 version_req: "1.0.0".into(),
1868 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1869 name_range: name_range_1,
1870 },
1871 MockDep {
1872 name: "flaky-2".into(),
1873 version_req: "1.0.0".into(),
1874 version_range: Range::new(Position::new(1, 10), Position::new(1, 20)),
1875 name_range: name_range_2,
1876 },
1877 MockDep {
1878 name: "flaky-3".into(),
1879 version_req: "1.0.0".into(),
1880 version_range: Range::new(Position::new(2, 10), Position::new(2, 20)),
1881 name_range: name_range_3,
1882 },
1883 ],
1884 uri: crate::test_util::test_uri("/test/Cargo.toml"),
1885 };
1886
1887 let cached_versions = HashMap::new();
1888 let resolved_versions = HashMap::new();
1889 let outcomes = DependencyOutcomes::new()
1890 .with_fetch_failure("flaky-1", FetchFailure::Transient)
1891 .with_fetch_failure("flaky-2", FetchFailure::Transient)
1892 .with_fetch_failure("flaky-3", FetchFailure::Transient);
1893
1894 let diagnostics = generate_diagnostics_from_cache(
1895 &parse_result,
1896 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
1897 &formatter,
1898 parse_result.uri(),
1899 crate::freshness::FreshnessSettings::default(),
1900 DiagnosticSeverities::default(),
1901 PublishTime::now(),
1902 );
1903
1904 assert_eq!(
1905 diagnostics.len(),
1906 1,
1907 "3 fetch-failed dependencies must collapse into exactly one diagnostic, got: {diagnostics:?}"
1908 );
1909 assert!(
1910 diagnostics[0]
1911 .message
1912 .contains("Registry lookup failed for 3 packages")
1913 );
1914 assert_eq!(
1915 diagnostics[0].range, name_range_1,
1916 "the combined diagnostic must sit on the first failing dependency's range"
1917 );
1918
1919 let related_information = diagnostics[0]
1923 .related_information
1924 .as_ref()
1925 .expect("collapsed diagnostic must carry related_information for the dropped deps");
1926 assert_eq!(
1927 related_information.len(),
1928 2,
1929 "expected one related_information entry per additional failing dependency (n - 1)"
1930 );
1931 assert_eq!(related_information[0].location.range, name_range_2);
1932 assert_eq!(related_information[0].location.uri, *parse_result.uri());
1933 assert!(related_information[0].message.contains("flaky-2"));
1934 assert_eq!(related_information[1].location.range, name_range_3);
1935 assert_eq!(related_information[1].location.uri, *parse_result.uri());
1936 assert!(related_information[1].message.contains("flaky-3"));
1937 }
1938
1939 #[test]
1940 fn test_generate_diagnostics_from_cache_two_fetch_failed_collapse_boundary() {
1941 use std::collections::HashMap;
1942 use tower_lsp_server::ls_types::{Position, Range};
1943
1944 let formatter = MockFormatter;
1949
1950 let name_range_1 = Range::new(Position::new(0, 0), Position::new(0, 8));
1951 let name_range_2 = Range::new(Position::new(1, 0), Position::new(1, 8));
1952
1953 let parse_result = MockParseResult {
1954 deps: vec![
1955 MockDep {
1956 name: "flaky-1".into(),
1957 version_req: "1.0.0".into(),
1958 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1959 name_range: name_range_1,
1960 },
1961 MockDep {
1962 name: "flaky-2".into(),
1963 version_req: "1.0.0".into(),
1964 version_range: Range::new(Position::new(1, 10), Position::new(1, 20)),
1965 name_range: name_range_2,
1966 },
1967 ],
1968 uri: crate::test_util::test_uri("/test/Cargo.toml"),
1969 };
1970
1971 let cached_versions = HashMap::new();
1972 let resolved_versions = HashMap::new();
1973 let outcomes = DependencyOutcomes::new()
1974 .with_fetch_failure("flaky-1", FetchFailure::Transient)
1975 .with_fetch_failure("flaky-2", FetchFailure::Transient);
1976
1977 let diagnostics = generate_diagnostics_from_cache(
1978 &parse_result,
1979 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
1980 &formatter,
1981 parse_result.uri(),
1982 crate::freshness::FreshnessSettings::default(),
1983 DiagnosticSeverities::default(),
1984 PublishTime::now(),
1985 );
1986
1987 assert_eq!(
1988 diagnostics.len(),
1989 1,
1990 "2 fetch-failed dependencies must already collapse into one diagnostic, got: {diagnostics:?}"
1991 );
1992 assert!(
1993 diagnostics[0]
1994 .message
1995 .contains("Registry lookup failed for 2 packages")
1996 );
1997 let related_information = diagnostics[0]
1998 .related_information
1999 .as_ref()
2000 .expect("the n==2 collapse must already carry related_information");
2001 assert_eq!(
2002 related_information.len(),
2003 1,
2004 "n==2 collapse must carry exactly one related_information entry (n - 1)"
2005 );
2006 assert_eq!(related_information[0].location.range, name_range_2);
2007 assert_eq!(related_information[0].location.uri, *parse_result.uri());
2008 assert!(related_information[0].message.contains("flaky-2"));
2009 }
2010
2011 #[test]
2012 fn test_generate_diagnostics_from_cache_multiple_fetch_failed_shared_actionable_hint() {
2013 use std::collections::HashMap;
2014 use tower_lsp_server::ls_types::{Position, Range};
2015
2016 let formatter = MockFormatter;
2021
2022 let name_range_1 = Range::new(Position::new(0, 0), Position::new(0, 8));
2023 let name_range_2 = Range::new(Position::new(1, 0), Position::new(1, 8));
2024 let name_range_3 = Range::new(Position::new(2, 0), Position::new(2, 8));
2025
2026 let parse_result = MockParseResult {
2027 deps: vec![
2028 MockDep {
2029 name: "flaky-1".into(),
2030 version_req: "1.0.0".into(),
2031 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2032 name_range: name_range_1,
2033 },
2034 MockDep {
2035 name: "flaky-2".into(),
2036 version_req: "1.0.0".into(),
2037 version_range: Range::new(Position::new(1, 10), Position::new(1, 20)),
2038 name_range: name_range_2,
2039 },
2040 MockDep {
2041 name: "flaky-3".into(),
2042 version_req: "1.0.0".into(),
2043 version_range: Range::new(Position::new(2, 10), Position::new(2, 20)),
2044 name_range: name_range_3,
2045 },
2046 ],
2047 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2048 };
2049
2050 let cached_versions = HashMap::new();
2051 let resolved_versions = HashMap::new();
2052 let shared_hint = "set GITHUB_TOKEN to increase the rate limit".to_string();
2053 let outcomes = DependencyOutcomes::new()
2054 .with_fetch_failure("flaky-1", FetchFailure::Actionable(shared_hint.clone()))
2055 .with_fetch_failure("flaky-2", FetchFailure::Actionable(shared_hint.clone()))
2056 .with_fetch_failure("flaky-3", FetchFailure::Actionable(shared_hint.clone()));
2057
2058 let diagnostics = generate_diagnostics_from_cache(
2059 &parse_result,
2060 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
2061 &formatter,
2062 parse_result.uri(),
2063 crate::freshness::FreshnessSettings::default(),
2064 DiagnosticSeverities::default(),
2065 PublishTime::now(),
2066 );
2067
2068 assert_eq!(
2069 diagnostics.len(),
2070 1,
2071 "3 fetch-failed dependencies must still collapse into exactly one diagnostic, got: {diagnostics:?}"
2072 );
2073 assert!(
2074 diagnostics[0].message.contains(&shared_hint),
2075 "collapsed diagnostic must surface the shared actionable hint, got: {}",
2076 diagnostics[0].message
2077 );
2078 assert!(
2079 !diagnostics[0]
2080 .message
2081 .contains("package status could not be determined"),
2082 "the actionable hint must replace, not accompany, the generic fallback message"
2083 );
2084 let related_information = diagnostics[0]
2085 .related_information
2086 .as_ref()
2087 .expect("collapsed diagnostic must carry related_information for the dropped deps");
2088 assert_eq!(
2089 related_information.len(),
2090 2,
2091 "expected one related_information entry per additional failing dependency (n - 1)"
2092 );
2093 }
2094
2095 #[test]
2096 fn test_generate_diagnostics_from_cache_fetch_failed_transient_shows_generic_message() {
2097 use std::collections::HashMap;
2098 use tower_lsp_server::ls_types::{Position, Range};
2099
2100 let formatter = MockFormatter;
2104
2105 let parse_result = MockParseResult {
2106 deps: vec![MockDep {
2107 name: "transient-pkg".into(),
2108 version_req: "1.0.0".into(),
2109 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2110 name_range: Range::new(Position::new(0, 0), Position::new(0, 13)),
2111 }],
2112 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2113 };
2114
2115 let cached_versions = HashMap::new();
2116 let resolved_versions = HashMap::new();
2117 let outcomes =
2118 DependencyOutcomes::new().with_fetch_failure("transient-pkg", FetchFailure::Transient);
2119
2120 let diagnostics = generate_diagnostics_from_cache(
2121 &parse_result,
2122 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
2123 &formatter,
2124 parse_result.uri(),
2125 crate::freshness::FreshnessSettings::default(),
2126 DiagnosticSeverities::default(),
2127 PublishTime::now(),
2128 );
2129
2130 assert_eq!(diagnostics.len(), 1);
2131 assert!(!diagnostics[0].message.contains("Unknown package"));
2132 assert!(
2133 diagnostics[0]
2134 .message
2135 .contains("package status could not be determined")
2136 );
2137 }
2138
2139 #[test]
2140 fn test_generate_diagnostics_from_cache_fetch_failed_not_attempted_shows_generic_message() {
2141 use std::collections::HashMap;
2142 use tower_lsp_server::ls_types::{Position, Range};
2143
2144 let formatter = MockFormatter;
2149
2150 let parse_result = MockParseResult {
2151 deps: vec![MockDep {
2152 name: "collided-pkg".into(),
2153 version_req: "1.0.0".into(),
2154 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2155 name_range: Range::new(Position::new(0, 0), Position::new(0, 12)),
2156 }],
2157 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2158 };
2159
2160 let cached_versions = HashMap::new();
2161 let resolved_versions = HashMap::new();
2162 let outcomes = DependencyOutcomes::new()
2163 .with_fetch_failure("collided-pkg", FetchFailure::NotAttempted);
2164
2165 let diagnostics = generate_diagnostics_from_cache(
2166 &parse_result,
2167 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
2168 &formatter,
2169 parse_result.uri(),
2170 crate::freshness::FreshnessSettings::default(),
2171 DiagnosticSeverities::default(),
2172 PublishTime::now(),
2173 );
2174
2175 assert_eq!(diagnostics.len(), 1);
2176 assert!(!diagnostics[0].message.contains("Unknown package"));
2177 assert!(
2178 diagnostics[0]
2179 .message
2180 .contains("package status could not be determined")
2181 );
2182 }
2183
2184 #[test]
2185 fn test_generate_diagnostics_from_cache_fetch_failed_does_not_mask_invalid_name() {
2186 use std::collections::HashMap;
2187 use tower_lsp_server::ls_types::{Position, Range};
2188
2189 let formatter = RejectingFormatter;
2193
2194 let parse_result = MockParseResult {
2195 deps: vec![MockDep {
2196 name: "bad name".into(),
2197 version_req: "1.0.0".into(),
2198 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2199 name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
2200 }],
2201 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2202 };
2203
2204 let cached_versions = HashMap::new();
2205 let resolved_versions = HashMap::new();
2206 let outcomes =
2207 DependencyOutcomes::new().with_fetch_failure("bad name", FetchFailure::Transient);
2208
2209 let diagnostics = generate_diagnostics_from_cache(
2210 &parse_result,
2211 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
2212 &formatter,
2213 parse_result.uri(),
2214 crate::freshness::FreshnessSettings::default(),
2215 DiagnosticSeverities::default(),
2216 PublishTime::now(),
2217 );
2218
2219 assert_eq!(diagnostics.len(), 1);
2220 assert!(diagnostics[0].message.contains("Invalid package name"));
2221 }
2222
2223 #[test]
2224 fn test_generate_diagnostics_from_cache_invalid_package_name() {
2225 use std::collections::HashMap;
2226 use tower_lsp_server::ls_types::{Position, Range};
2227
2228 let formatter = RejectingFormatter;
2232
2233 let parse_result = MockParseResult {
2234 deps: vec![MockDep {
2235 name: "bad-pkg".into(),
2236 version_req: "1.0.0".into(),
2237 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2238 name_range: Range::new(Position::new(0, 0), Position::new(0, 7)),
2239 }],
2240 uri: crate::test_util::test_uri("/test/package.json"),
2241 };
2242
2243 let cached_versions = HashMap::new();
2244 let resolved_versions = HashMap::new();
2245
2246 let diagnostics = generate_diagnostics_from_cache(
2247 &parse_result,
2248 VersionData::new(&cached_versions, &resolved_versions),
2249 &formatter,
2250 parse_result.uri(),
2251 crate::FreshnessSettings::default(),
2252 DiagnosticSeverities::default(),
2253 PublishTime::now(),
2254 );
2255
2256 assert_eq!(diagnostics.len(), 1);
2257 assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::WARNING));
2258 assert!(diagnostics[0].message.starts_with("Invalid package name"));
2259 assert!(!diagnostics[0].message.contains("Unknown package"));
2260 }
2261
2262 #[test]
2263 fn test_generate_diagnostics_from_cache_outdated_version() {
2264 use std::collections::HashMap;
2265 use tower_lsp_server::ls_types::{Position, Range};
2266
2267 let formatter = MockFormatter;
2268
2269 let parse_result = MockParseResult {
2270 deps: vec![MockDep {
2271 name: "serde".into(),
2272 version_req: "1.0".into(),
2273 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2274 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2275 }],
2276 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2277 };
2278
2279 let mut cached_versions = HashMap::new();
2280 cached_versions.insert("serde".into(), PackageVersions::latest_only("2.0.0"));
2281
2282 let resolved_versions = HashMap::new();
2283
2284 let diagnostics = generate_diagnostics_from_cache(
2285 &parse_result,
2286 VersionData::new(&cached_versions, &resolved_versions),
2287 &formatter,
2288 parse_result.uri(),
2289 crate::freshness::FreshnessSettings::default(),
2290 DiagnosticSeverities::default(),
2291 PublishTime::now(),
2292 );
2293
2294 assert_eq!(diagnostics.len(), 1);
2295 assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::HINT));
2296 assert!(diagnostics[0].message.contains("Newer version available"));
2297 assert!(diagnostics[0].message.contains("2.0.0"));
2298 }
2299
2300 #[test]
2303 fn test_generate_diagnostics_from_cache_outdated_within_cooldown_appends_context() {
2304 use std::collections::HashMap;
2305 use tower_lsp_server::ls_types::{Position, Range};
2306
2307 let formatter = MockFormatter;
2308
2309 let parse_result = MockParseResult {
2310 deps: vec![MockDep {
2311 name: "serde".into(),
2312 version_req: "1.0".into(),
2313 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2314 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2315 }],
2316 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2317 };
2318
2319 let mut cached_versions = HashMap::new();
2320 cached_versions.insert(
2321 "serde".into(),
2322 PackageVersions {
2323 latest: "2.0.0".into(),
2324 available: Arc::from(vec!["2.0.0".into()]),
2325 yanked: Arc::from(Vec::new()),
2326 published_at: Some(PublishTime::from_unix_secs(
2328 PublishTime::now().as_unix_secs() - 60 * 60,
2329 )),
2330 },
2331 );
2332 let resolved_versions = HashMap::new();
2333
2334 let diagnostics = generate_diagnostics_from_cache(
2335 &parse_result,
2336 VersionData::new(&cached_versions, &resolved_versions),
2337 &formatter,
2338 parse_result.uri(),
2339 crate::freshness::FreshnessSettings::default(),
2340 DiagnosticSeverities::default(),
2341 PublishTime::now(),
2342 );
2343
2344 assert_eq!(diagnostics.len(), 1);
2345 assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::HINT));
2346 assert_eq!(
2347 diagnostics[0].message,
2348 "Newer version available: 2.0.0 (published 1 hour ago — still within the release cooldown window)"
2349 );
2350 assert!(!diagnostics[0].message.contains("ago ago"));
2353 }
2354
2355 #[test]
2358 fn test_generate_diagnostics_from_cache_outdated_outside_cooldown_plain_message() {
2359 use std::collections::HashMap;
2360 use tower_lsp_server::ls_types::{Position, Range};
2361
2362 let formatter = MockFormatter;
2363
2364 let parse_result = MockParseResult {
2365 deps: vec![MockDep {
2366 name: "serde".into(),
2367 version_req: "1.0".into(),
2368 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2369 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2370 }],
2371 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2372 };
2373
2374 let mut cached_versions = HashMap::new();
2375 cached_versions.insert(
2376 "serde".into(),
2377 PackageVersions {
2378 latest: "2.0.0".into(),
2379 available: Arc::from(vec!["2.0.0".into()]),
2380 yanked: Arc::from(Vec::new()),
2381 published_at: Some(PublishTime::from_unix_secs(
2383 PublishTime::now().as_unix_secs() - 10 * 24 * 60 * 60,
2384 )),
2385 },
2386 );
2387 let resolved_versions = HashMap::new();
2388
2389 let diagnostics = generate_diagnostics_from_cache(
2390 &parse_result,
2391 VersionData::new(&cached_versions, &resolved_versions),
2392 &formatter,
2393 parse_result.uri(),
2394 crate::freshness::FreshnessSettings::default(),
2395 DiagnosticSeverities::default(),
2396 PublishTime::now(),
2397 );
2398
2399 assert_eq!(diagnostics.len(), 1);
2400 assert_eq!(diagnostics[0].message, "Newer version available: 2.0.0");
2401 }
2402
2403 #[test]
2406 fn test_generate_diagnostics_from_cache_outdated_freshness_disabled_plain_message() {
2407 use std::collections::HashMap;
2408 use tower_lsp_server::ls_types::{Position, Range};
2409
2410 let formatter = MockFormatter;
2411
2412 let parse_result = MockParseResult {
2413 deps: vec![MockDep {
2414 name: "serde".into(),
2415 version_req: "1.0".into(),
2416 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2417 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2418 }],
2419 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2420 };
2421
2422 let mut cached_versions = HashMap::new();
2423 cached_versions.insert(
2424 "serde".into(),
2425 PackageVersions {
2426 latest: "2.0.0".into(),
2427 available: Arc::from(vec!["2.0.0".into()]),
2428 yanked: Arc::from(Vec::new()),
2429 published_at: Some(PublishTime::from_unix_secs(
2430 PublishTime::now().as_unix_secs() - 60 * 60,
2431 )),
2432 },
2433 );
2434 let resolved_versions = HashMap::new();
2435
2436 let diagnostics = generate_diagnostics_from_cache(
2437 &parse_result,
2438 VersionData::new(&cached_versions, &resolved_versions),
2439 &formatter,
2440 parse_result.uri(),
2441 crate::freshness::FreshnessSettings {
2442 enabled: false,
2443 ..crate::freshness::FreshnessSettings::default()
2444 },
2445 DiagnosticSeverities::default(),
2446 PublishTime::now(),
2447 );
2448
2449 assert_eq!(diagnostics.len(), 1);
2450 assert_eq!(diagnostics[0].message, "Newer version available: 2.0.0");
2451 }
2452
2453 #[test]
2458 fn test_generate_diagnostics_from_cache_outdated_cooldown_boundary_is_exclusive() {
2459 use std::collections::HashMap;
2460 use tower_lsp_server::ls_types::{Position, Range};
2461
2462 const COOLDOWN_SECS: u64 = 100;
2463 let now = PublishTime::from_unix_secs(10_000);
2464 let published_at_at_boundary =
2465 PublishTime::from_unix_secs(10_000 - COOLDOWN_SECS.cast_signed());
2466
2467 let formatter = MockFormatter;
2468 let parse_result = MockParseResult {
2469 deps: vec![MockDep {
2470 name: "serde".into(),
2471 version_req: "1.0".into(),
2472 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2473 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2474 }],
2475 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2476 };
2477
2478 let mut cached_versions = HashMap::new();
2479 cached_versions.insert(
2480 "serde".into(),
2481 PackageVersions {
2482 latest: "2.0.0".into(),
2483 available: Arc::from(vec!["2.0.0".into()]),
2484 yanked: Arc::from(Vec::new()),
2485 published_at: Some(published_at_at_boundary),
2486 },
2487 );
2488 let resolved_versions = HashMap::new();
2489
2490 let diagnostics = generate_diagnostics_from_cache(
2491 &parse_result,
2492 VersionData::new(&cached_versions, &resolved_versions),
2493 &formatter,
2494 parse_result.uri(),
2495 crate::freshness::FreshnessSettings {
2496 enabled: true,
2497 cooldown_secs: COOLDOWN_SECS,
2498 },
2499 DiagnosticSeverities::default(),
2500 now,
2501 );
2502
2503 assert_eq!(diagnostics.len(), 1);
2504 assert_eq!(
2505 diagnostics[0].message, "Newer version available: 2.0.0",
2506 "age exactly equal to cooldown_secs must not be within cooldown"
2507 );
2508 }
2509
2510 #[test]
2512 fn test_generate_diagnostics_from_cache_outdated_cooldown_boundary_one_second_inside() {
2513 use std::collections::HashMap;
2514 use tower_lsp_server::ls_types::{Position, Range};
2515
2516 const COOLDOWN_SECS: u64 = 100;
2517 let now = PublishTime::from_unix_secs(10_000);
2518 let published_at_just_inside =
2519 PublishTime::from_unix_secs(10_000 - (COOLDOWN_SECS.cast_signed() - 1));
2520
2521 let formatter = MockFormatter;
2522 let parse_result = MockParseResult {
2523 deps: vec![MockDep {
2524 name: "serde".into(),
2525 version_req: "1.0".into(),
2526 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2527 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2528 }],
2529 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2530 };
2531
2532 let mut cached_versions = HashMap::new();
2533 cached_versions.insert(
2534 "serde".into(),
2535 PackageVersions {
2536 latest: "2.0.0".into(),
2537 available: Arc::from(vec!["2.0.0".into()]),
2538 yanked: Arc::from(Vec::new()),
2539 published_at: Some(published_at_just_inside),
2540 },
2541 );
2542 let resolved_versions = HashMap::new();
2543
2544 let diagnostics = generate_diagnostics_from_cache(
2545 &parse_result,
2546 VersionData::new(&cached_versions, &resolved_versions),
2547 &formatter,
2548 parse_result.uri(),
2549 crate::freshness::FreshnessSettings {
2550 enabled: true,
2551 cooldown_secs: COOLDOWN_SECS,
2552 },
2553 DiagnosticSeverities::default(),
2554 now,
2555 );
2556
2557 assert_eq!(diagnostics.len(), 1);
2558 assert_eq!(
2559 diagnostics[0].message,
2560 "Newer version available: 2.0.0 (published 1 minute ago — still within the release cooldown window)",
2561 "age == cooldown_secs - 1 must be within cooldown"
2562 );
2563 }
2564
2565 #[test]
2566 fn test_generate_diagnostics_from_cache_up_to_date() {
2567 use std::collections::HashMap;
2568 use tower_lsp_server::ls_types::{Position, Range};
2569
2570 let formatter = MockFormatter;
2571
2572 let parse_result = MockParseResult {
2573 deps: vec![MockDep {
2574 name: "serde".into(),
2575 version_req: "^1.0".into(),
2576 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2577 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2578 }],
2579 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2580 };
2581
2582 let mut cached_versions = HashMap::new();
2583 cached_versions.insert("serde".into(), PackageVersions::latest_only("1.0.214"));
2584
2585 let resolved_versions = HashMap::new();
2586
2587 let diagnostics = generate_diagnostics_from_cache(
2588 &parse_result,
2589 VersionData::new(&cached_versions, &resolved_versions),
2590 &formatter,
2591 parse_result.uri(),
2592 crate::freshness::FreshnessSettings::default(),
2593 DiagnosticSeverities::default(),
2594 PublishTime::now(),
2595 );
2596
2597 assert!(
2598 diagnostics.is_empty(),
2599 "Expected no diagnostics for up-to-date dependency"
2600 );
2601 }
2602
2603 #[test]
2604 fn test_generate_diagnostics_from_cache_multiple_deps() {
2605 use std::collections::HashMap;
2606 use tower_lsp_server::ls_types::{Position, Range};
2607
2608 let formatter = MockFormatter;
2609
2610 let parse_result = MockParseResult {
2611 deps: vec![
2612 MockDep {
2613 name: "serde".into(),
2614 version_req: "^1.0".into(),
2615 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2616 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2617 },
2618 MockDep {
2619 name: "tokio".into(),
2620 version_req: "1.0".into(),
2621 version_range: Range::new(Position::new(1, 10), Position::new(1, 20)),
2622 name_range: Range::new(Position::new(1, 0), Position::new(1, 5)),
2623 },
2624 MockDep {
2625 name: "unknown".into(),
2626 version_req: "1.0".into(),
2627 version_range: Range::new(Position::new(2, 10), Position::new(2, 20)),
2628 name_range: Range::new(Position::new(2, 0), Position::new(2, 7)),
2629 },
2630 ],
2631 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2632 };
2633
2634 let mut cached_versions = HashMap::new();
2635 cached_versions.insert("serde".into(), PackageVersions::latest_only("1.0.214"));
2636 cached_versions.insert("tokio".into(), PackageVersions::latest_only("2.0.0"));
2637
2638 let resolved_versions = HashMap::new();
2639
2640 let diagnostics = generate_diagnostics_from_cache(
2641 &parse_result,
2642 VersionData::new(&cached_versions, &resolved_versions),
2643 &formatter,
2644 parse_result.uri(),
2645 crate::freshness::FreshnessSettings::default(),
2646 DiagnosticSeverities::default(),
2647 PublishTime::now(),
2648 );
2649
2650 assert_eq!(diagnostics.len(), 2);
2651
2652 assert_eq!(diagnostics[0].message, "Newer version available: 2.0.0");
2656 assert_eq!(diagnostics[0].code, None);
2657 assert_eq!(diagnostics[1].message, "Unknown package 'unknown'");
2658 assert_eq!(diagnostics[1].code, None);
2659 }
2660
2661 #[test]
2662 fn test_generate_diagnostics_from_cache_unresolved_emits_no_diagnostic() {
2663 use std::collections::HashMap;
2664 use tower_lsp_server::ls_types::{Position, Range};
2665
2666 let formatter = MockUnresolvedFormatter;
2667
2668 let parse_result = MockParseResult {
2669 deps: vec![MockDep {
2670 name: "spring-boot-starter".into(),
2671 version_req: "$missing".into(),
2672 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2673 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2674 }],
2675 uri: crate::test_util::test_uri("/test/libs.versions.toml"),
2676 };
2677
2678 let mut cached_versions = HashMap::new();
2679 cached_versions.insert(
2680 "spring-boot-starter".into(),
2681 PackageVersions::latest_only("3.2.0"),
2682 );
2683
2684 let resolved_versions = HashMap::new();
2685
2686 let diagnostics = generate_diagnostics_from_cache(
2687 &parse_result,
2688 VersionData::new(&cached_versions, &resolved_versions),
2689 &formatter,
2690 parse_result.uri(),
2691 crate::freshness::FreshnessSettings::default(),
2692 DiagnosticSeverities::default(),
2693 PublishTime::now(),
2694 );
2695
2696 assert!(
2697 diagnostics.is_empty(),
2698 "Expected no diagnostics for an unresolved requirement, got: {diagnostics:?}"
2699 );
2700 }
2701
2702 #[test]
2703 fn test_generate_diagnostics_from_cache_yanked_uses_configured_severity() {
2704 use std::collections::HashMap;
2705 use tower_lsp_server::ls_types::{Position, Range};
2706
2707 let formatter = MockFormatter;
2708
2709 let parse_result = MockParseResult {
2710 deps: vec![MockDep {
2711 name: "serde".into(),
2712 version_req: "1.0.5".into(),
2713 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2714 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2715 }],
2716 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2717 };
2718
2719 let cached_versions = HashMap::new();
2720 let resolved_versions = HashMap::new();
2721 let outcomes =
2722 DependencyOutcomes::new().with_yanked("serde", ("1.0.5".into(), RemovalStatus::Yanked));
2723
2724 let severities = DiagnosticSeverities {
2725 yanked: DiagnosticSeverity::ERROR,
2726 ..DiagnosticSeverities::default()
2727 };
2728
2729 let diagnostics = generate_diagnostics_from_cache(
2730 &parse_result,
2731 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
2732 &formatter,
2733 parse_result.uri(),
2734 crate::freshness::FreshnessSettings::default(),
2735 severities,
2736 PublishTime::now(),
2737 );
2738
2739 let yanked_diag = diagnostics
2740 .iter()
2741 .find(|d| d.message.starts_with(formatter.yanked_message()))
2742 .expect("expected a yanked diagnostic");
2743 assert_eq!(yanked_diag.severity, Some(DiagnosticSeverity::ERROR));
2744 assert!(yanked_diag.message.contains("1.0.5"));
2745 }
2746
2747 #[test]
2748 fn test_generate_diagnostics_from_cache_yanked_default_severity_unchanged() {
2749 use std::collections::HashMap;
2750 use tower_lsp_server::ls_types::{Position, Range};
2751
2752 let formatter = MockFormatter;
2753
2754 let parse_result = MockParseResult {
2755 deps: vec![MockDep {
2756 name: "serde".into(),
2757 version_req: "1.0.5".into(),
2758 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2759 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2760 }],
2761 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2762 };
2763
2764 let cached_versions = HashMap::new();
2765 let resolved_versions = HashMap::new();
2766 let outcomes =
2767 DependencyOutcomes::new().with_yanked("serde", ("1.0.5".into(), RemovalStatus::Yanked));
2768
2769 let diagnostics = generate_diagnostics_from_cache(
2770 &parse_result,
2771 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
2772 &formatter,
2773 parse_result.uri(),
2774 crate::freshness::FreshnessSettings::default(),
2775 DiagnosticSeverities::default(),
2776 PublishTime::now(),
2777 );
2778
2779 let yanked_diag = diagnostics
2780 .iter()
2781 .find(|d| d.message.starts_with(formatter.yanked_message()))
2782 .expect("expected a yanked diagnostic");
2783 assert_eq!(yanked_diag.severity, Some(DiagnosticSeverity::WARNING));
2784 }
2785
2786 #[test]
2787 fn test_generate_diagnostics_from_cache_no_yanked_map_emits_no_yanked_diagnostic() {
2788 use std::collections::HashMap;
2789 use tower_lsp_server::ls_types::{Position, Range};
2790
2791 let formatter = MockFormatter;
2795
2796 let parse_result = MockParseResult {
2797 deps: vec![MockDep {
2798 name: "serde".into(),
2799 version_req: "1.0.5".into(),
2800 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2801 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2802 }],
2803 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2804 };
2805
2806 let mut cached_versions = HashMap::new();
2807 cached_versions.insert("serde".into(), PackageVersions::latest_only("1.0.5"));
2808 let resolved_versions = HashMap::new();
2809
2810 let diagnostics = generate_diagnostics_from_cache(
2811 &parse_result,
2812 VersionData::new(&cached_versions, &resolved_versions),
2813 &formatter,
2814 parse_result.uri(),
2815 crate::freshness::FreshnessSettings::default(),
2816 DiagnosticSeverities::default(),
2817 PublishTime::now(),
2818 );
2819
2820 assert!(
2821 !diagnostics
2822 .iter()
2823 .any(|d| d.message.starts_with(formatter.yanked_message())),
2824 "Expected no yanked diagnostic when `yanked` is None, got: {diagnostics:?}"
2825 );
2826 }
2827
2828 #[test]
2829 fn test_generate_diagnostics_from_cache_yanked_and_outdated_both_emitted() {
2830 use std::collections::HashMap;
2831 use tower_lsp_server::ls_types::{Position, Range};
2832
2833 let formatter = MockFormatter;
2837
2838 let parse_result = MockParseResult {
2839 deps: vec![MockDep {
2840 name: "serde".into(),
2841 version_req: "1.0.5".into(),
2842 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2843 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2844 }],
2845 uri: crate::test_util::test_uri("/test/Cargo.toml"),
2846 };
2847
2848 let mut cached_versions = HashMap::new();
2849 cached_versions.insert("serde".into(), PackageVersions::latest_only("2.0.0"));
2850 let resolved_versions = HashMap::new();
2851 let outcomes =
2852 DependencyOutcomes::new().with_yanked("serde", ("1.0.5".into(), RemovalStatus::Yanked));
2853
2854 let diagnostics = generate_diagnostics_from_cache(
2855 &parse_result,
2856 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
2857 &formatter,
2858 parse_result.uri(),
2859 crate::freshness::FreshnessSettings::default(),
2860 DiagnosticSeverities::default(),
2861 PublishTime::now(),
2862 );
2863
2864 assert_eq!(
2865 diagnostics.len(),
2866 2,
2867 "expected both diagnostics: {diagnostics:?}"
2868 );
2869 assert_eq!(
2873 diagnostics[0].message,
2874 format!("{} (1.0.5)", formatter.yanked_message())
2875 );
2876 assert_eq!(diagnostics[0].code, None);
2877 assert_eq!(diagnostics[1].message, "Newer version available: 2.0.0");
2878 assert_eq!(diagnostics[1].code, None);
2879 }
2880
2881 #[test]
2887 fn test_generate_diagnostics_from_cache_deprecation_suppresses_advisory_deprecated_yanked_finding()
2888 {
2889 use std::collections::HashMap;
2890 use tower_lsp_server::ls_types::{Position, Range};
2891
2892 let formatter = MockFormatter;
2893 let parse_result = MockParseResult {
2894 deps: vec![MockDep {
2895 name: "left-pad".into(),
2896 version_req: "1.3.0".into(),
2897 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2898 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2899 }],
2900 uri: crate::test_util::test_uri("/test/package.json"),
2901 };
2902
2903 let mut cached_versions = HashMap::new();
2904 cached_versions.insert("left-pad".into(), PackageVersions::latest_only("1.3.0"));
2905 let resolved_versions = HashMap::new();
2906 let outcomes = DependencyOutcomes::new()
2907 .with_yanked(
2908 "left-pad",
2909 ("1.3.0".into(), RemovalStatus::AdvisoryDeprecated),
2910 )
2911 .with_deprecation(
2912 "left-pad",
2913 Deprecation {
2914 reason: Some("use String.prototype.padStart()".to_string()),
2915 replacement: None,
2916 },
2917 );
2918
2919 let diagnostics = generate_diagnostics_from_cache(
2920 &parse_result,
2921 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
2922 &formatter,
2923 parse_result.uri(),
2924 crate::freshness::FreshnessSettings::default(),
2925 DiagnosticSeverities::default(),
2926 PublishTime::now(),
2927 );
2928
2929 assert_eq!(
2930 diagnostics.len(),
2931 1,
2932 "the deprecation diagnostic must suppress the AdvisoryDeprecated yanked \
2933 finding, not double-report: {diagnostics:?}"
2934 );
2935 assert!(
2936 diagnostics[0]
2937 .message
2938 .starts_with(formatter.deprecated_message())
2939 );
2940 }
2941
2942 #[test]
2947 fn test_generate_diagnostics_from_cache_deprecation_never_suppresses_real_yanked_finding() {
2948 use std::collections::HashMap;
2949 use tower_lsp_server::ls_types::{Position, Range};
2950
2951 let formatter = MockFormatter;
2952 let parse_result = MockParseResult {
2953 deps: vec![MockDep {
2954 name: "pkg".into(),
2955 version_req: "1.0.0".into(),
2956 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2957 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2958 }],
2959 uri: crate::test_util::test_uri("/test/pyproject.toml"),
2960 };
2961
2962 let mut cached_versions = HashMap::new();
2963 cached_versions.insert("pkg".into(), PackageVersions::latest_only("1.0.0"));
2964 let resolved_versions = HashMap::new();
2965 let outcomes = DependencyOutcomes::new()
2966 .with_yanked("pkg", ("1.0.0".into(), RemovalStatus::Yanked))
2967 .with_deprecation(
2968 "pkg",
2969 Deprecation {
2970 reason: Some("project archived".to_string()),
2971 replacement: None,
2972 },
2973 );
2974
2975 let diagnostics = generate_diagnostics_from_cache(
2976 &parse_result,
2977 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
2978 &formatter,
2979 parse_result.uri(),
2980 crate::freshness::FreshnessSettings::default(),
2981 DiagnosticSeverities::default(),
2982 PublishTime::now(),
2983 );
2984
2985 assert_eq!(
2986 diagnostics.len(),
2987 2,
2988 "a genuine Yanked finding must never be hidden behind a package-level \
2989 deprecation notice: {diagnostics:?}"
2990 );
2991 assert_eq!(
2994 diagnostics[0].message,
2995 format!("{}: project archived", formatter.deprecated_message())
2996 );
2997 assert_eq!(
2998 diagnostics[0].code,
2999 Some(NumberOrString::String(DEPRECATED_DIAGNOSTIC_CODE.into()))
3000 );
3001 assert_eq!(
3002 diagnostics[1].message,
3003 format!("{} (1.0.0)", formatter.yanked_message())
3004 );
3005 assert_eq!(diagnostics[1].code, None);
3006 }
3007
3008 #[test]
3014 fn test_generate_diagnostics_from_cache_deprecation_suppression_is_severity_independent() {
3015 use std::collections::HashMap;
3016 use tower_lsp_server::ls_types::{Position, Range};
3017
3018 let formatter = MockFormatter;
3019 let parse_result = MockParseResult {
3020 deps: vec![MockDep {
3021 name: "left-pad".into(),
3022 version_req: "1.3.0".into(),
3023 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
3024 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
3025 }],
3026 uri: crate::test_util::test_uri("/test/package.json"),
3027 };
3028
3029 let mut cached_versions = HashMap::new();
3030 cached_versions.insert("left-pad".into(), PackageVersions::latest_only("1.3.0"));
3031 let resolved_versions = HashMap::new();
3032 let outcomes = DependencyOutcomes::new()
3033 .with_yanked(
3034 "left-pad",
3035 ("1.3.0".into(), RemovalStatus::AdvisoryDeprecated),
3036 )
3037 .with_deprecation(
3038 "left-pad",
3039 Deprecation {
3040 reason: Some("use String.prototype.padStart()".to_string()),
3041 replacement: None,
3042 },
3043 );
3044 let versions =
3045 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes);
3046
3047 let default_severities = DiagnosticSeverities::default();
3048 let inverted_severities = DiagnosticSeverities {
3049 deprecated: DiagnosticSeverity::HINT,
3050 yanked: DiagnosticSeverity::ERROR,
3051 ..DiagnosticSeverities::default()
3052 };
3053
3054 for severities in [default_severities, inverted_severities] {
3055 let diagnostics = generate_diagnostics_from_cache(
3056 &parse_result,
3057 versions,
3058 &formatter,
3059 parse_result.uri(),
3060 crate::freshness::FreshnessSettings::default(),
3061 severities,
3062 PublishTime::now(),
3063 );
3064 assert_eq!(
3065 diagnostics.len(),
3066 1,
3067 "suppression must not depend on severity configuration: {diagnostics:?}"
3068 );
3069 assert!(
3070 diagnostics[0]
3071 .message
3072 .starts_with(formatter.deprecated_message())
3073 );
3074 }
3075 }
3076
3077 #[test]
3082 fn test_generate_diagnostics_from_cache_deprecation_skipped_for_non_registry_sources() {
3083 use crate::parser::DependencySource;
3084
3085 let mut cached_versions = HashMap::new();
3086 cached_versions.insert("dep".into(), PackageVersions::latest_only("1.0.0"));
3087 let resolved_versions = HashMap::new();
3088 let outcomes = DependencyOutcomes::new().with_deprecation(
3089 "dep",
3090 Deprecation {
3091 reason: Some("archived".to_string()),
3092 replacement: None,
3093 },
3094 );
3095 let uri = crate::test_util::test_uri("/test/Cargo.toml");
3096
3097 for source in [
3098 DependencySource::Path {
3099 path: "../local".into(),
3100 },
3101 DependencySource::Git {
3102 url: "https://example.com/repo.git".into(),
3103 rev: None,
3104 },
3105 DependencySource::Sdk {
3106 sdk: "flutter".into(),
3107 },
3108 DependencySource::Workspace,
3109 ] {
3110 let parse_result = SingleDepParseResult {
3111 dep: NonRegistryDep(dep_at("dep"), source.clone()),
3112 uri: uri.clone(),
3113 };
3114 let diagnostics = generate_diagnostics_from_cache(
3115 &parse_result,
3116 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
3117 &MockFormatter,
3118 parse_result.uri(),
3119 crate::freshness::FreshnessSettings::default(),
3120 DiagnosticSeverities::default(),
3121 PublishTime::now(),
3122 );
3123 assert!(
3124 diagnostics
3125 .iter()
3126 .all(|d| !d.message.starts_with(MockFormatter.deprecated_message())),
3127 "source {source:?} must never surface the deprecation diagnostic for a \
3128 coincidentally-named registry package: {diagnostics:?}"
3129 );
3130 }
3131
3132 let registry_parse_result = SingleDepParseResult {
3135 dep: dep_at("dep"),
3136 uri,
3137 };
3138 let diagnostics = generate_diagnostics_from_cache(
3139 ®istry_parse_result,
3140 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
3141 &MockFormatter,
3142 registry_parse_result.uri(),
3143 crate::freshness::FreshnessSettings::default(),
3144 DiagnosticSeverities::default(),
3145 PublishTime::now(),
3146 );
3147 assert!(
3148 diagnostics
3149 .iter()
3150 .any(|d| d.message.starts_with(MockFormatter.deprecated_message())),
3151 "control case: a Registry-source dependency must still produce the diagnostic"
3152 );
3153 }
3154
3155 #[test]
3156 fn test_generate_diagnostics_from_cache_yanked_no_version_range_uses_name_range() {
3157 use std::collections::HashMap;
3158 use tower_lsp_server::ls_types::{Position, Range};
3159
3160 let formatter = MockFormatter;
3161 let name_range = Range::new(Position::new(0, 0), Position::new(0, 5));
3162
3163 let parse_result = MockMarkedParseResult {
3164 dep: MockMarkedDep {
3165 name: "serde".into(),
3166 name_range,
3167 markers: None,
3168 },
3169 uri: crate::test_util::test_uri("/test/pyproject.toml"),
3170 };
3171
3172 let cached_versions = HashMap::new();
3173 let resolved_versions = HashMap::new();
3174 let outcomes =
3175 DependencyOutcomes::new().with_yanked("serde", ("1.0.5".into(), RemovalStatus::Yanked));
3176
3177 let diagnostics = generate_diagnostics_from_cache(
3178 &parse_result,
3179 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
3180 &formatter,
3181 parse_result.uri(),
3182 crate::freshness::FreshnessSettings::default(),
3183 DiagnosticSeverities::default(),
3184 PublishTime::now(),
3185 );
3186
3187 let yanked_diag = diagnostics
3188 .iter()
3189 .find(|d| d.message.starts_with(formatter.yanked_message()))
3190 .expect("expected a yanked diagnostic even without a version_range");
3191 assert_eq!(yanked_diag.range, name_range);
3192 }
3193
3194 #[test]
3195 fn test_generate_diagnostics_from_cache_yanked_normalized_name_keying() {
3196 use std::collections::HashMap;
3197 use tower_lsp_server::ls_types::{Position, Range};
3198
3199 struct MockLowercaseFormatter;
3202 impl PackageNaming for MockLowercaseFormatter {
3203 fn normalize_package_name(&self, name: &PackageName) -> String {
3204 name.to_string().to_lowercase()
3205 }
3206 }
3207
3208 impl PackageRendering for MockLowercaseFormatter {
3209 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
3210 version.to_string()
3211 }
3212
3213 fn package_url(&self, name: &PackageName) -> String {
3214 format!("https://example.com/{name}")
3215 }
3216 }
3217
3218 impl RequirementResolution for MockLowercaseFormatter {}
3219
3220 impl DiagnosticMessages for MockLowercaseFormatter {}
3221
3222 impl DiagnosticPolicy for MockLowercaseFormatter {}
3223
3224 impl SourcePolicy for MockLowercaseFormatter {}
3225
3226 impl OsvNaming for MockLowercaseFormatter {}
3227
3228 let formatter = MockLowercaseFormatter;
3229
3230 let parse_result = MockParseResult {
3231 deps: vec![MockDep {
3232 name: "Newtonsoft.Json".into(),
3233 version_req: "13.0.1".into(),
3234 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
3235 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
3236 }],
3237 uri: crate::test_util::test_uri("/test/project.csproj"),
3238 };
3239
3240 let cached_versions = HashMap::new();
3241 let resolved_versions = HashMap::new();
3242 let outcomes = DependencyOutcomes::new()
3244 .with_yanked("newtonsoft.json", ("13.0.1".into(), RemovalStatus::Yanked));
3245
3246 let diagnostics = generate_diagnostics_from_cache(
3247 &parse_result,
3248 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
3249 &formatter,
3250 parse_result.uri(),
3251 crate::freshness::FreshnessSettings::default(),
3252 DiagnosticSeverities::default(),
3253 PublishTime::now(),
3254 );
3255
3256 assert!(
3257 diagnostics
3258 .iter()
3259 .any(|d| d.message.starts_with(formatter.yanked_message())),
3260 "expected normalized-name lookup to resolve the yanked entry, got: {diagnostics:?}"
3261 );
3262 }
3263
3264 #[test]
3265 fn test_generate_diagnostics_from_cache_yanked_not_shared_across_duplicate_name_occurrences() {
3266 use std::collections::HashMap;
3273 use tower_lsp_server::ls_types::{Position, Range};
3274
3275 let formatter = MockFormatter;
3276
3277 let parse_result = MockParseResult {
3278 deps: vec![
3279 MockDep {
3280 name: "time".into(),
3281 version_req: "=0.1.43".into(),
3282 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
3283 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
3284 },
3285 MockDep {
3286 name: "time".into(),
3287 version_req: "=0.1.44".into(),
3288 version_range: Range::new(Position::new(3, 10), Position::new(3, 20)),
3289 name_range: Range::new(Position::new(3, 0), Position::new(3, 5)),
3290 },
3291 ],
3292 uri: crate::test_util::test_uri("/test/Cargo.toml"),
3293 };
3294
3295 let cached_versions = HashMap::new();
3296 let resolved_versions = HashMap::new();
3297 let outcomes =
3298 DependencyOutcomes::new().with_yanked("time", ("0.1.43".into(), RemovalStatus::Yanked));
3299
3300 let diagnostics = generate_diagnostics_from_cache(
3301 &parse_result,
3302 VersionData::new(&cached_versions, &resolved_versions)
3303 .with_outcomes(&outcomes)
3304 .with_ecosystem(crate::EcosystemId::Cargo),
3305 &formatter,
3306 parse_result.uri(),
3307 crate::freshness::FreshnessSettings::default(),
3308 DiagnosticSeverities::default(),
3309 PublishTime::now(),
3310 );
3311
3312 let yanked_diags: Vec<_> = diagnostics
3313 .iter()
3314 .filter(|d| d.message.starts_with(formatter.yanked_message()))
3315 .collect();
3316 assert_eq!(
3317 yanked_diags.len(),
3318 1,
3319 "exactly one occurrence must get the yanked diagnostic, got: {diagnostics:?}"
3320 );
3321 assert_eq!(
3322 yanked_diags[0].range.start.line, 0,
3323 "must land on the yanked occurrence's own line"
3324 );
3325 assert!(yanked_diags[0].message.contains("0.1.43"));
3326 }
3327
3328 #[test]
3329 fn test_generate_diagnostics_from_cache_yanked_no_ecosystem_keeps_pre_394_behavior() {
3330 use std::collections::HashMap;
3336 use tower_lsp_server::ls_types::{Position, Range};
3337
3338 let formatter = MockFormatter;
3339
3340 let parse_result = MockParseResult {
3341 deps: vec![
3342 MockDep {
3343 name: "time".into(),
3344 version_req: "=0.1.43".into(),
3345 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
3346 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
3347 },
3348 MockDep {
3349 name: "time".into(),
3350 version_req: "=0.1.44".into(),
3351 version_range: Range::new(Position::new(3, 10), Position::new(3, 20)),
3352 name_range: Range::new(Position::new(3, 0), Position::new(3, 5)),
3353 },
3354 ],
3355 uri: crate::test_util::test_uri("/test/Cargo.toml"),
3356 };
3357
3358 let cached_versions = HashMap::new();
3359 let resolved_versions = HashMap::new();
3360 let outcomes =
3361 DependencyOutcomes::new().with_yanked("time", ("0.1.43".into(), RemovalStatus::Yanked));
3362
3363 let diagnostics = generate_diagnostics_from_cache(
3364 &parse_result,
3365 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
3366 &formatter,
3367 parse_result.uri(),
3368 crate::freshness::FreshnessSettings::default(),
3369 DiagnosticSeverities::default(),
3370 PublishTime::now(),
3371 );
3372
3373 let yanked_diags = diagnostics
3374 .iter()
3375 .filter(|d| d.message.starts_with(formatter.yanked_message()))
3376 .count();
3377 assert_eq!(
3378 yanked_diags, 2,
3379 "no `ecosystem` set: both occurrences share the finding as before #394"
3380 );
3381 }
3382
3383 #[test]
3384 fn test_generate_diagnostics_unsatisfiable_enriched_with_matching_prerelease() {
3385 let cached_versions = {
3386 let mut m = HashMap::new();
3387 m.insert(
3388 "dep".into(),
3389 PackageVersions {
3390 latest: "1.5.0".into(),
3391 available: Arc::from(vec!["2.0.0-rc.1".into(), "1.5.0".into(), "1.4.0".into()]),
3392 yanked: Arc::from(Vec::new()),
3393 published_at: None,
3394 },
3395 );
3396 m
3397 };
3398 let resolved_versions = HashMap::new();
3399 let uri = crate::test_util::test_uri("/test/Cargo.toml");
3400 let mut dependency = dep_at("dep");
3401 dependency.version_req = VersionReq::new("^2.0.0");
3402 let parse_result = SingleDepParseResult {
3403 dep: dependency,
3404 uri,
3405 };
3406
3407 let diagnostics = generate_diagnostics_from_cache(
3408 &parse_result,
3409 VersionData::new(&cached_versions, &resolved_versions),
3410 &StrictSemverFormatter,
3411 parse_result.uri(),
3412 crate::freshness::FreshnessSettings::default(),
3413 DiagnosticSeverities::default(),
3414 PublishTime::now(),
3415 );
3416
3417 let message = diagnostics
3418 .iter()
3419 .find(|d| d.message.contains("No published version satisfies"))
3420 .map(|d| d.message.as_str())
3421 .expect("unsatisfiable WARNING must fire");
3422 assert!(
3423 message.contains("2.0.0-rc.1") && message.contains("pre-release"),
3424 "message must mention the matching pre-release, got: {message}"
3425 );
3426 }
3427
3428 #[test]
3429 fn test_generate_diagnostics_unsatisfiable_no_enrichment_without_matching_prerelease() {
3430 let cached_versions = {
3431 let mut m = HashMap::new();
3432 m.insert(
3433 "dep".into(),
3434 PackageVersions {
3435 latest: "1.5.0".into(),
3436 available: Arc::from(vec!["1.5.0".into(), "1.4.0".into()]),
3437 yanked: Arc::from(Vec::new()),
3438 published_at: None,
3439 },
3440 );
3441 m
3442 };
3443 let resolved_versions = HashMap::new();
3444 let uri = crate::test_util::test_uri("/test/Cargo.toml");
3445 let mut dependency = dep_at("dep");
3446 dependency.version_req = VersionReq::new("^2.0.0");
3447 let parse_result = SingleDepParseResult {
3448 dep: dependency,
3449 uri,
3450 };
3451
3452 let diagnostics = generate_diagnostics_from_cache(
3453 &parse_result,
3454 VersionData::new(&cached_versions, &resolved_versions),
3455 &StrictSemverFormatter,
3456 parse_result.uri(),
3457 crate::freshness::FreshnessSettings::default(),
3458 DiagnosticSeverities::default(),
3459 PublishTime::now(),
3460 );
3461
3462 let message = diagnostics
3463 .iter()
3464 .find(|d| d.message.contains("No published version satisfies"))
3465 .map(|d| d.message.as_str())
3466 .expect("unsatisfiable WARNING must fire");
3467 assert!(
3468 !message.contains("pre-release"),
3469 "message must not mention a pre-release when none satisfies, got: {message}"
3470 );
3471 }
3472
3473 #[test]
3474 fn test_generate_diagnostics_unsatisfiable_skipped_for_non_registry_sources() {
3475 use crate::parser::DependencySource;
3476
3477 let cached_versions = {
3478 let mut m = HashMap::new();
3479 m.insert("dep".into(), PackageVersions::latest_only("9.9.9"));
3480 m
3481 };
3482 let resolved_versions = HashMap::new();
3483 let uri = crate::test_util::test_uri("/test/Cargo.toml");
3484
3485 for source in [
3488 DependencySource::Path {
3489 path: "../local".into(),
3490 },
3491 DependencySource::Git {
3492 url: "https://example.com/repo.git".into(),
3493 rev: None,
3494 },
3495 DependencySource::Url {
3496 url: "https://example.com/pkg.tar.gz".into(),
3497 },
3498 DependencySource::Sdk {
3499 sdk: "flutter".into(),
3500 },
3501 DependencySource::Workspace,
3502 DependencySource::CustomRegistry {
3503 url: "my-corp".into(),
3504 },
3505 ] {
3506 let parse_result = SingleDepParseResult {
3507 dep: NonRegistryDep(dep_at("dep"), source.clone()),
3508 uri: uri.clone(),
3509 };
3510 let diagnostics = generate_diagnostics_from_cache(
3511 &parse_result,
3512 VersionData::new(&cached_versions, &resolved_versions),
3513 &ExactMatchFormatter,
3514 parse_result.uri(),
3515 crate::freshness::FreshnessSettings::default(),
3516 DiagnosticSeverities::default(),
3517 PublishTime::now(),
3518 );
3519 assert!(
3520 diagnostics
3521 .iter()
3522 .all(|d| !d.message.contains("No published version satisfies")),
3523 "source {source:?} must never produce the unsatisfiable-requirement WARNING"
3524 );
3525 }
3526
3527 let registry_parse_result = SingleDepParseResult {
3531 dep: dep_at("dep"),
3532 uri,
3533 };
3534 let diagnostics = generate_diagnostics_from_cache(
3535 ®istry_parse_result,
3536 VersionData::new(&cached_versions, &resolved_versions),
3537 &ExactMatchFormatter,
3538 registry_parse_result.uri(),
3539 crate::freshness::FreshnessSettings::default(),
3540 DiagnosticSeverities::default(),
3541 PublishTime::now(),
3542 );
3543 assert!(
3544 diagnostics
3545 .iter()
3546 .any(|d| d.message.contains("No published version satisfies")),
3547 "control case: a Registry-source dependency must still produce the WARNING"
3548 );
3549 }
3550
3551 #[test]
3552 fn test_generate_diagnostics_unknown_package_skipped_for_non_registry_sources() {
3553 use crate::parser::DependencySource;
3554
3555 let cached_versions = HashMap::new();
3558 let resolved_versions = HashMap::new();
3559 let uri = crate::test_util::test_uri("/test/Cargo.toml");
3560
3561 let parse_result = SingleDepParseResult {
3562 dep: NonRegistryDep(
3563 dep_at("dep"),
3564 DependencySource::CustomRegistry {
3565 url: "my-corp".into(),
3566 },
3567 ),
3568 uri: uri.clone(),
3569 };
3570 let diagnostics = generate_diagnostics_from_cache(
3571 &parse_result,
3572 VersionData::new(&cached_versions, &resolved_versions),
3573 &MockFormatter,
3574 parse_result.uri(),
3575 crate::freshness::FreshnessSettings::default(),
3576 DiagnosticSeverities::default(),
3577 PublishTime::now(),
3578 );
3579 assert!(
3580 diagnostics
3581 .iter()
3582 .all(|d| !d.message.contains("Unknown package")),
3583 "a CustomRegistry-sourced dependency must never produce the \"Unknown package\" WARNING"
3584 );
3585
3586 let registry_parse_result = SingleDepParseResult {
3589 dep: dep_at("dep"),
3590 uri,
3591 };
3592 let diagnostics = generate_diagnostics_from_cache(
3593 ®istry_parse_result,
3594 VersionData::new(&cached_versions, &resolved_versions),
3595 &MockFormatter,
3596 registry_parse_result.uri(),
3597 crate::freshness::FreshnessSettings::default(),
3598 DiagnosticSeverities::default(),
3599 PublishTime::now(),
3600 );
3601 assert!(
3602 diagnostics
3603 .iter()
3604 .any(|d| d.message.contains("Unknown package")),
3605 "control case: a Registry-source dependency must still produce the WARNING"
3606 );
3607 }
3608
3609 #[test]
3610 fn test_generate_diagnostics_invalid_name_still_reported_for_non_registry_sources() {
3611 use crate::parser::DependencySource;
3612
3613 let cached_versions = HashMap::new();
3617 let resolved_versions = HashMap::new();
3618 let uri = crate::test_util::test_uri("/test/package.json");
3619
3620 let parse_result = SingleDepParseResult {
3621 dep: NonRegistryDep(
3622 dep_at("dep"),
3623 DependencySource::CustomRegistry {
3624 url: "my-corp".into(),
3625 },
3626 ),
3627 uri,
3628 };
3629 let diagnostics = generate_diagnostics_from_cache(
3630 &parse_result,
3631 VersionData::new(&cached_versions, &resolved_versions),
3632 &RejectingFormatter,
3633 parse_result.uri(),
3634 crate::freshness::FreshnessSettings::default(),
3635 DiagnosticSeverities::default(),
3636 PublishTime::now(),
3637 );
3638 assert_eq!(diagnostics.len(), 1);
3639 assert!(diagnostics[0].message.starts_with("Invalid package name"));
3640 }
3641
3642 #[test]
3643 fn test_generate_diagnostics_outdated_skipped_for_non_registry_sources() {
3644 use crate::parser::DependencySource;
3645
3646 let cached_versions = {
3650 let mut m = HashMap::new();
3651 m.insert("dep".into(), PackageVersions::latest_only("9.9.9"));
3652 m
3653 };
3654 let resolved_versions = HashMap::new();
3655 let uri = crate::test_util::test_uri("/test/Cargo.toml");
3656
3657 let parse_result = SingleDepParseResult {
3658 dep: NonRegistryDep(
3659 dep_at("dep"),
3660 DependencySource::CustomRegistry {
3661 url: "my-corp".into(),
3662 },
3663 ),
3664 uri: uri.clone(),
3665 };
3666 let diagnostics = generate_diagnostics_from_cache(
3667 &parse_result,
3668 VersionData::new(&cached_versions, &resolved_versions),
3669 &MockFormatter,
3670 parse_result.uri(),
3671 crate::freshness::FreshnessSettings::default(),
3672 DiagnosticSeverities::default(),
3673 PublishTime::now(),
3674 );
3675 assert!(
3676 diagnostics
3677 .iter()
3678 .all(|d| !d.message.contains("Newer version available")),
3679 "a CustomRegistry-sourced dependency must never produce the \"Outdated\" WARNING"
3680 );
3681
3682 let registry_parse_result = SingleDepParseResult {
3685 dep: dep_at("dep"),
3686 uri,
3687 };
3688 let diagnostics = generate_diagnostics_from_cache(
3689 ®istry_parse_result,
3690 VersionData::new(&cached_versions, &resolved_versions),
3691 &MockFormatter,
3692 registry_parse_result.uri(),
3693 crate::freshness::FreshnessSettings::default(),
3694 DiagnosticSeverities::default(),
3695 PublishTime::now(),
3696 );
3697 assert!(
3698 diagnostics
3699 .iter()
3700 .any(|d| d.message.contains("Newer version available")),
3701 "control case: a Registry-source dependency must still produce the WARNING"
3702 );
3703 }
3704
3705 #[test]
3706 fn test_generate_diagnostics_vulnerable_dependency_emits_advisory_diagnostic_even_without_registry_data()
3707 {
3708 use crate::osv::{
3709 Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity,
3710 VulnerabilityMap,
3711 };
3712
3713 let formatter = MockFormatter;
3714 let parse_result = MockParseResult {
3715 deps: vec![dep_at("vulnerable-pkg")],
3716 uri: crate::test_util::test_uri("/test/Cargo.toml"),
3717 };
3718
3719 let cached_versions = HashMap::new();
3722 let resolved_versions = HashMap::new();
3723
3724 let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
3725 vulns.insert(
3726 "vulnerable-pkg".to_string(),
3727 ScanOutcome::Vulnerable(DependencyVulnerabilities {
3728 advisories: Capped::new(
3729 vec![sample_advisory("RUSTSEC-2020-0071", VulnSeverity::High)],
3730 1,
3731 ),
3732 fix_target_status: UpgradeStatus::NotChecked,
3733 upgrade_status: UpgradeStatus::NotChecked,
3734 }),
3735 );
3736
3737 let diagnostics = generate_diagnostics_from_cache(
3738 &parse_result,
3739 VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
3740 &formatter,
3741 parse_result.uri(),
3742 crate::FreshnessSettings::default(),
3743 DiagnosticSeverities::default(),
3744 PublishTime::now(),
3745 );
3746
3747 let vuln_diag = diagnostics
3748 .iter()
3749 .find(|d| d.message.contains("RUSTSEC-2020-0071"))
3750 .expect("vulnerability diagnostic must be emitted even without registry data");
3751 assert_eq!(vuln_diag.severity, Some(DiagnosticSeverity::WARNING));
3752 assert_eq!(
3753 vuln_diag.code,
3754 Some(NumberOrString::String("RUSTSEC-2020-0071".to_string()))
3755 );
3756 }
3757
3758 #[test]
3759 fn test_generate_diagnostics_advisory_cap_emits_more_count_from_total_known() {
3760 use crate::osv::{
3761 ADVISORY_DISPLAY_CAP, Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus,
3762 VulnSeverity, VulnerabilityMap,
3763 };
3764
3765 let formatter = MockFormatter;
3766 let parse_result = MockParseResult {
3767 deps: vec![dep_at("noisy-pkg")],
3768 uri: crate::test_util::test_uri("/test/Cargo.toml"),
3769 };
3770 let cached_versions = HashMap::new();
3771 let resolved_versions = HashMap::new();
3772
3773 let advisories: Vec<_> = (0..ADVISORY_DISPLAY_CAP)
3774 .map(|i| sample_advisory(&format!("ADV-{i}"), VulnSeverity::Low))
3775 .collect();
3776
3777 let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
3778 vulns.insert(
3779 "noisy-pkg".to_string(),
3780 ScanOutcome::Vulnerable(DependencyVulnerabilities {
3781 advisories: Capped::new(advisories, 40),
3782 fix_target_status: UpgradeStatus::NotChecked,
3783 upgrade_status: UpgradeStatus::NotChecked,
3784 }),
3785 );
3786
3787 let diagnostics = generate_diagnostics_from_cache(
3788 &parse_result,
3789 VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
3790 &formatter,
3791 parse_result.uri(),
3792 crate::FreshnessSettings::default(),
3793 DiagnosticSeverities::default(),
3794 PublishTime::now(),
3795 );
3796
3797 let more_diag = diagnostics
3798 .iter()
3799 .find(|d| d.message.contains("more advisories"))
3800 .expect("expected a trailing +N more advisories diagnostic");
3801 assert!(
3802 more_diag.message.contains("+35"),
3803 "got: {}",
3804 more_diag.message
3805 );
3806 }
3807
3808 #[test]
3809 fn test_generate_diagnostics_vulnerability_not_shared_across_duplicate_name_occurrences() {
3810 use crate::osv::{
3817 Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity,
3818 VulnerabilityMap, vulnerability_keys,
3819 };
3820 use tower_lsp_server::ls_types::{Position, Range};
3821
3822 let formatter = MockFormatter;
3823
3824 let vulnerable_dep = MockDep {
3825 name: "pkg".into(),
3826 version_req: "=1.0.0".into(),
3827 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
3828 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
3829 };
3830 let patched_dep = MockDep {
3831 name: "pkg".into(),
3832 version_req: "=2.0.0".into(),
3833 version_range: Range::new(Position::new(3, 10), Position::new(3, 20)),
3834 name_range: Range::new(Position::new(3, 0), Position::new(3, 5)),
3835 };
3836 let parse_result = MockParseResult {
3837 deps: vec![vulnerable_dep, patched_dep],
3838 uri: crate::test_util::test_uri("/test/Cargo.toml"),
3839 };
3840
3841 let cached_versions = HashMap::new();
3842 let resolved_versions = HashMap::new();
3843
3844 let keys = vulnerability_keys(
3845 &parse_result,
3846 &resolved_versions,
3847 &formatter,
3848 crate::EcosystemId::Cargo,
3849 );
3850 let deps = parse_result.dependencies();
3851 let vulnerable_key = keys.get(&deps[0].name_range()).unwrap().clone();
3852 let patched_key = keys.get(&deps[1].name_range()).unwrap().clone();
3853 assert_ne!(
3854 vulnerable_key, patched_key,
3855 "differently-versioned occurrences of one name must get distinct keys"
3856 );
3857
3858 let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
3859 vulns.insert(
3860 vulnerable_key,
3861 ScanOutcome::Vulnerable(DependencyVulnerabilities {
3862 advisories: Capped::new(
3863 vec![sample_advisory("RUSTSEC-2020-0071", VulnSeverity::High)],
3864 1,
3865 ),
3866 fix_target_status: UpgradeStatus::NotChecked,
3867 upgrade_status: UpgradeStatus::NotChecked,
3868 }),
3869 );
3870 vulns.insert(patched_key, ScanOutcome::Clean);
3871
3872 let diagnostics = generate_diagnostics_from_cache(
3873 &parse_result,
3874 VersionData::new(&cached_versions, &resolved_versions)
3875 .with_vulnerabilities(&vulns)
3876 .with_ecosystem(crate::EcosystemId::Cargo),
3877 &formatter,
3878 parse_result.uri(),
3879 crate::FreshnessSettings::default(),
3880 DiagnosticSeverities::default(),
3881 PublishTime::now(),
3882 );
3883
3884 let advisory_diags: Vec<_> = diagnostics
3885 .iter()
3886 .filter(|d| d.message.contains("RUSTSEC-2020-0071"))
3887 .collect();
3888 assert_eq!(
3889 advisory_diags.len(),
3890 1,
3891 "exactly one occurrence must get the advisory diagnostic, got: {diagnostics:?}"
3892 );
3893 assert_eq!(
3894 advisory_diags[0].range.start.line, 0,
3895 "must land on the vulnerable occurrence's own line, not the patched one"
3896 );
3897 }
3898
3899 #[test]
3900 fn test_generate_diagnostics_skipped_outcome_emits_no_vulnerability_diagnostic() {
3901 use crate::osv::{ScanOutcome, SkipReason, VulnerabilityMap};
3902
3903 let formatter = MockFormatter;
3904 let parse_result = MockParseResult {
3905 deps: vec![dep_at("git-pkg")],
3906 uri: crate::test_util::test_uri("/test/Cargo.toml"),
3907 };
3908 let mut cached_versions = HashMap::new();
3909 cached_versions.insert("git-pkg".into(), PackageVersions::latest_only("1.0.0"));
3910 let resolved_versions = HashMap::new();
3911
3912 let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
3913 vulns.insert(
3914 "git-pkg".to_string(),
3915 ScanOutcome::Skipped(SkipReason::NonRegistrySource),
3916 );
3917
3918 let diagnostics = generate_diagnostics_from_cache(
3919 &parse_result,
3920 VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
3921 &formatter,
3922 parse_result.uri(),
3923 crate::FreshnessSettings::default(),
3924 DiagnosticSeverities::default(),
3925 PublishTime::now(),
3926 );
3927
3928 assert!(
3929 diagnostics.iter().all(|d| d.code.is_none()),
3930 "a Skipped outcome must never render an advisory diagnostic"
3931 );
3932 }
3933
3934 mod requirement_is_unsatisfiable_tests {
3938 use super::*;
3939
3940 type Decide = Arc<dyn Fn(&str) -> Option<bool> + Send + Sync>;
3941
3942 struct ClosureMatcher(Decide);
3945
3946 impl RequirementMatcher for ClosureMatcher {
3947 fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
3948 (self.0)(version.as_str())
3949 }
3950 }
3951
3952 struct TableFormatter {
3957 decide: Decide,
3958 }
3959
3960 impl TableFormatter {
3961 fn new(decide: impl Fn(&str) -> Option<bool> + Send + Sync + 'static) -> Self {
3962 Self {
3963 decide: Arc::new(decide),
3964 }
3965 }
3966 }
3967
3968 impl PackageNaming for TableFormatter {}
3969
3970 impl PackageRendering for TableFormatter {
3971 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
3972 version.to_string()
3973 }
3974
3975 fn package_url(&self, name: &PackageName) -> String {
3976 name.to_string()
3977 }
3978 }
3979
3980 impl RequirementResolution for TableFormatter {
3981 fn requirement_is_unresolved(&self, requirement: &VersionReq) -> bool {
3982 requirement.as_str() == "unresolved"
3983 }
3984
3985 fn compile_requirement(
3986 &self,
3987 requirement: &VersionReq,
3988 ) -> Option<Box<dyn RequirementMatcher>> {
3989 if requirement.as_str() != "modelled" {
3990 return None;
3991 }
3992 Some(Box::new(ClosureMatcher(Arc::clone(&self.decide)))
3993 as Box<dyn RequirementMatcher>)
3994 }
3995 }
3996
3997 impl DiagnosticMessages for TableFormatter {}
3998
3999 impl DiagnosticPolicy for TableFormatter {}
4000
4001 impl SourcePolicy for TableFormatter {}
4002
4003 impl OsvNaming for TableFormatter {}
4004
4005 fn versions(strs: &[&str]) -> Vec<ConcreteVersion> {
4006 strs.iter().map(|s| (*s).into()).collect()
4007 }
4008
4009 #[test]
4010 fn test_empty_available_list_is_false() {
4011 let formatter = TableFormatter::new(|_v| Some(true));
4012 assert!(!requirement_is_unsatisfiable(
4013 &formatter,
4014 &VersionReq::new("modelled"),
4015 &[],
4016 ));
4017 }
4018
4019 #[test]
4020 fn test_empty_requirement_string_is_false() {
4021 let formatter = TableFormatter::new(|_v| Some(false));
4022 assert!(!requirement_is_unsatisfiable(
4023 &formatter,
4024 &VersionReq::new(""),
4025 &versions(&["1.0.0"]),
4026 ));
4027 }
4028
4029 #[test]
4033 fn test_oversized_requirement_is_false_without_compiling() {
4034 let formatter =
4035 TableFormatter::new(|_v| panic!("must not compile/scan an oversized requirement"));
4036 let oversized = "1".repeat(MAX_REQUIREMENT_LEN + 1);
4037 assert!(!requirement_is_unsatisfiable(
4038 &formatter,
4039 &VersionReq::new(oversized),
4040 &versions(&["1.0.0"]),
4041 ));
4042 }
4043
4044 #[test]
4045 fn test_unresolved_requirement_is_false() {
4046 let formatter = TableFormatter::new(|_v| Some(false));
4047 assert!(!requirement_is_unsatisfiable(
4048 &formatter,
4049 &VersionReq::new("unresolved"),
4050 &versions(&["1.0.0"]),
4051 ));
4052 }
4053
4054 #[test]
4055 fn test_compile_requirement_none_is_false() {
4056 let formatter = TableFormatter::new(|_v| Some(false));
4057 assert!(!requirement_is_unsatisfiable(
4058 &formatter,
4059 &VersionReq::new("not-modelled"),
4060 &versions(&["1.0.0"]),
4061 ));
4062 }
4063
4064 #[test]
4065 fn test_all_candidates_decided_false_is_true() {
4066 let formatter = TableFormatter::new(|_v| Some(false));
4067 assert!(requirement_is_unsatisfiable(
4068 &formatter,
4069 &VersionReq::new("modelled"),
4070 &versions(&["1.0.0", "2.0.0", "3.0.0"]),
4071 ));
4072 }
4073
4074 #[test]
4075 fn test_one_match_among_many_non_matches_is_false() {
4076 let formatter = TableFormatter::new(|v| Some(v == "2.0.0"));
4077 assert!(!requirement_is_unsatisfiable(
4078 &formatter,
4079 &VersionReq::new("modelled"),
4080 &versions(&["1.0.0", "2.0.0", "3.0.0"]),
4081 ));
4082 }
4083
4084 #[test]
4087 fn test_all_candidates_unparseable_is_false() {
4088 let formatter = TableFormatter::new(|_v| None);
4089 assert!(!requirement_is_unsatisfiable(
4090 &formatter,
4091 &VersionReq::new("modelled"),
4092 &versions(&["1.0.0", "2.0.0"]),
4093 ));
4094 }
4095
4096 #[test]
4099 fn test_one_unparseable_candidate_among_false_is_still_true() {
4100 let formatter = TableFormatter::new(|v| if v == "junk" { None } else { Some(false) });
4101 assert!(requirement_is_unsatisfiable(
4102 &formatter,
4103 &VersionReq::new("modelled"),
4104 &versions(&["1.0.0", "junk", "2.0.0"]),
4105 ));
4106 }
4107
4108 #[test]
4112 fn test_match_on_yanked_only_candidate_is_false() {
4113 let formatter = TableFormatter::new(|v| Some(v == "1.0.0-yanked"));
4114 assert!(!requirement_is_unsatisfiable(
4115 &formatter,
4116 &VersionReq::new("modelled"),
4117 &versions(&["1.0.0-yanked"]),
4118 ));
4119 }
4120
4121 #[test]
4123 fn test_match_on_prerelease_only_candidate_is_false() {
4124 let formatter = TableFormatter::new(|v| Some(v == "2.0.0-beta.1"));
4125 assert!(!requirement_is_unsatisfiable(
4126 &formatter,
4127 &VersionReq::new("modelled"),
4128 &versions(&["2.0.0-beta.1"]),
4129 ));
4130 }
4131
4132 #[test]
4133 fn test_scan_short_circuits_on_first_match() {
4134 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4135 let calls_clone = Arc::clone(&calls);
4136 let formatter = TableFormatter::new(move |v| {
4137 calls_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4138 Some(v == "1.0.0")
4139 });
4140 let result = requirement_is_unsatisfiable(
4141 &formatter,
4142 &VersionReq::new("modelled"),
4143 &versions(&["1.0.0", "0.9.0", "0.8.0"]),
4144 );
4145 assert!(!result);
4146 assert_eq!(
4147 calls.load(std::sync::atomic::Ordering::SeqCst),
4148 1,
4149 "must stop scanning at the first Some(true)"
4150 );
4151 }
4152 }
4153
4154 mod matching_prerelease_would_satisfy_tests {
4158 use super::*;
4159
4160 struct NonStrictFormatter;
4165 impl PackageNaming for NonStrictFormatter {}
4166
4167 impl PackageRendering for NonStrictFormatter {
4168 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
4169 version.to_string()
4170 }
4171
4172 fn package_url(&self, name: &PackageName) -> String {
4173 name.to_string()
4174 }
4175 }
4176
4177 impl RequirementResolution for NonStrictFormatter {
4178 fn compile_requirement(
4179 &self,
4180 requirement: &VersionReq,
4181 ) -> Option<Box<dyn RequirementMatcher>> {
4182 requirement
4183 .as_str()
4184 .parse::<semver::VersionReq>()
4185 .ok()
4186 .map(|req| Box::new(RealSemverMatcher(req)) as Box<dyn RequirementMatcher>)
4187 }
4188 }
4189
4190 impl DiagnosticMessages for NonStrictFormatter {}
4191
4192 impl DiagnosticPolicy for NonStrictFormatter {}
4193
4194 impl SourcePolicy for NonStrictFormatter {}
4195
4196 impl OsvNaming for NonStrictFormatter {}
4197
4198 fn versions(strs: &[&str]) -> Vec<ConcreteVersion> {
4199 strs.iter().map(|s| (*s).into()).collect()
4200 }
4201
4202 fn yanked_versions(strs: &[&str]) -> Vec<(ConcreteVersion, RemovalStatus)> {
4203 strs.iter()
4204 .map(|s| ((*s).into(), RemovalStatus::Yanked))
4205 .collect()
4206 }
4207
4208 #[test]
4209 fn test_semver_prerelease_base() {
4210 assert_eq!(semver_prerelease_base("2.0.0-rc.1"), Some("2.0.0"));
4211 assert_eq!(semver_prerelease_base("2.0.0-rc.1+build.5"), Some("2.0.0"));
4212 assert_eq!(semver_prerelease_base("2.0.0"), None);
4213 assert_eq!(semver_prerelease_base("2.0.0+build.5"), None);
4214 }
4215
4216 #[test]
4217 fn test_requirement_names_prerelease() {
4218 assert!(requirement_names_prerelease("2.0.0-rc.5"));
4219 assert!(requirement_names_prerelease("^2.0.0-rc.5"));
4220 assert!(requirement_names_prerelease("~2.0.0-rc.5"));
4221 assert!(requirement_names_prerelease(">=2.0.0-rc.5"));
4222 assert!(requirement_names_prerelease("=2.0.0-rc.5"));
4223 assert!(!requirement_names_prerelease("^2.0.0"));
4224 assert!(!requirement_names_prerelease(">=1.0.0, <2.0.0"));
4225 assert!(!requirement_names_prerelease("1.2.3 - 2.3.4"));
4228 }
4229
4230 #[test]
4232 fn test_no_prerelease_available_returns_none() {
4233 assert_eq!(
4234 matching_prerelease_would_satisfy(
4235 &StrictSemverFormatter,
4236 &VersionReq::new("^2.0.0"),
4237 &versions(&["1.5.0", "1.4.0"]),
4238 &[],
4239 ),
4240 None
4241 );
4242 }
4243
4244 #[test]
4247 fn test_matching_prerelease_is_found() {
4248 assert_eq!(
4249 matching_prerelease_would_satisfy(
4250 &StrictSemverFormatter,
4251 &VersionReq::new("^2.0.0"),
4252 &versions(&["2.0.0-rc.1", "1.5.0"]),
4253 &[],
4254 ),
4255 Some("2.0.0-rc.1".to_string())
4256 );
4257 }
4258
4259 #[test]
4262 fn test_returns_newest_matching_prerelease_first() {
4263 assert_eq!(
4264 matching_prerelease_would_satisfy(
4265 &StrictSemverFormatter,
4266 &VersionReq::new("^2.0.0"),
4267 &versions(&["2.0.0-rc.2", "2.0.0-rc.1", "1.5.0"]),
4268 &[],
4269 ),
4270 Some("2.0.0-rc.2".to_string())
4271 );
4272 }
4273
4274 #[test]
4277 fn test_non_matching_prerelease_returns_none() {
4278 assert_eq!(
4279 matching_prerelease_would_satisfy(
4280 &StrictSemverFormatter,
4281 &VersionReq::new("^2.0.0"),
4282 &versions(&["3.0.0-rc.1", "1.5.0"]),
4283 &[],
4284 ),
4285 None
4286 );
4287 }
4288
4289 #[test]
4294 fn test_exact_prerelease_pin_does_not_misfire_on_unrelated_prerelease() {
4295 assert_eq!(
4296 matching_prerelease_would_satisfy(
4297 &StrictSemverFormatter,
4298 &VersionReq::new("=2.0.0-rc.5"),
4299 &versions(&["2.0.0-rc.1", "1.5.0"]),
4300 &[],
4301 ),
4302 None
4303 );
4304 }
4305
4306 #[test]
4312 fn test_caret_requirement_naming_prerelease_returns_none() {
4313 assert_eq!(
4314 matching_prerelease_would_satisfy(
4315 &StrictSemverFormatter,
4316 &VersionReq::new("^2.0.0-rc.5"),
4317 &versions(&["2.0.0-rc.1", "1.5.0"]),
4318 &[],
4319 ),
4320 None
4321 );
4322 }
4323
4324 #[test]
4326 fn test_tilde_requirement_naming_prerelease_returns_none() {
4327 assert_eq!(
4328 matching_prerelease_would_satisfy(
4329 &StrictSemverFormatter,
4330 &VersionReq::new("~2.0.0-rc.5"),
4331 &versions(&["2.0.0-rc.1", "1.5.0"]),
4332 &[],
4333 ),
4334 None
4335 );
4336 }
4337
4338 #[test]
4340 fn test_gte_requirement_naming_prerelease_returns_none() {
4341 assert_eq!(
4342 matching_prerelease_would_satisfy(
4343 &StrictSemverFormatter,
4344 &VersionReq::new(">=2.0.0-rc.5"),
4345 &versions(&["2.0.0-rc.1", "1.5.0"]),
4346 &[],
4347 ),
4348 None
4349 );
4350 }
4351
4352 #[test]
4355 fn test_yanked_matching_prerelease_is_skipped() {
4356 assert_eq!(
4357 matching_prerelease_would_satisfy(
4358 &StrictSemverFormatter,
4359 &VersionReq::new("^2.0.0"),
4360 &versions(&["2.0.0-rc.1", "1.5.0"]),
4361 &yanked_versions(&["2.0.0-rc.1"]),
4362 ),
4363 None
4364 );
4365 }
4366
4367 #[test]
4369 fn test_yanked_matching_prerelease_falls_back_to_non_yanked() {
4370 assert_eq!(
4371 matching_prerelease_would_satisfy(
4372 &StrictSemverFormatter,
4373 &VersionReq::new("^2.0.0"),
4374 &versions(&["2.0.0-rc.2", "2.0.0-rc.1", "1.5.0"]),
4375 &yanked_versions(&["2.0.0-rc.2"]),
4376 ),
4377 Some("2.0.0-rc.1".to_string())
4378 );
4379 }
4380
4381 #[test]
4384 fn test_non_opted_in_ecosystem_returns_none() {
4385 assert_eq!(
4386 matching_prerelease_would_satisfy(
4387 &NonStrictFormatter,
4388 &VersionReq::new("^2.0.0"),
4389 &versions(&["2.0.0-rc.1", "1.5.0"]),
4390 &[],
4391 ),
4392 None
4393 );
4394 }
4395 }
4396
4397 mod requirement_matches_only_yanked_tests {
4405 use super::*;
4406
4407 type Decide = Arc<dyn Fn(&str) -> Option<bool> + Send + Sync>;
4408
4409 struct ClosureMatcher(Decide);
4410
4411 impl RequirementMatcher for ClosureMatcher {
4412 fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
4413 (self.0)(version.as_str())
4414 }
4415 }
4416
4417 struct TableFormatter {
4420 decide: Decide,
4421 }
4422
4423 impl TableFormatter {
4424 fn new(decide: impl Fn(&str) -> Option<bool> + Send + Sync + 'static) -> Self {
4425 Self {
4426 decide: Arc::new(decide),
4427 }
4428 }
4429 }
4430
4431 impl PackageNaming for TableFormatter {}
4432
4433 impl PackageRendering for TableFormatter {
4434 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
4435 version.to_string()
4436 }
4437
4438 fn package_url(&self, name: &PackageName) -> String {
4439 name.to_string()
4440 }
4441 }
4442
4443 impl RequirementResolution for TableFormatter {
4444 fn requirement_is_unresolved(&self, requirement: &VersionReq) -> bool {
4445 requirement.as_str() == "unresolved"
4446 }
4447
4448 fn compile_requirement(
4449 &self,
4450 requirement: &VersionReq,
4451 ) -> Option<Box<dyn RequirementMatcher>> {
4452 if requirement.as_str() != "modelled" {
4453 return None;
4454 }
4455 Some(Box::new(ClosureMatcher(Arc::clone(&self.decide)))
4456 as Box<dyn RequirementMatcher>)
4457 }
4458 }
4459
4460 impl DiagnosticMessages for TableFormatter {}
4461
4462 impl DiagnosticPolicy for TableFormatter {}
4463
4464 impl SourcePolicy for TableFormatter {}
4465
4466 impl OsvNaming for TableFormatter {}
4467
4468 fn versions(strs: &[&str]) -> Vec<ConcreteVersion> {
4469 strs.iter().map(|s| (*s).into()).collect()
4470 }
4471
4472 fn yanked_versions(strs: &[&str]) -> Vec<(ConcreteVersion, RemovalStatus)> {
4473 strs.iter()
4474 .map(|s| ((*s).into(), RemovalStatus::Yanked))
4475 .collect()
4476 }
4477
4478 #[test]
4479 fn test_yanked_only_match_is_true() {
4480 let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
4481 assert_eq!(
4482 requirement_matches_only_yanked(
4483 &formatter,
4484 &VersionReq::new("modelled"),
4485 &versions(&["1.2.1"]),
4486 &yanked_versions(&["1.2.1"]),
4487 ),
4488 Some(RemovalStatus::Yanked)
4489 );
4490 }
4491
4492 #[test]
4497 fn test_duplicate_version_prefers_yanked_status_regardless_of_order() {
4498 let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
4499
4500 let advisory_then_yanked = vec![
4501 ("1.2.1".into(), RemovalStatus::AdvisoryDeprecated),
4502 ("1.2.1".into(), RemovalStatus::Yanked),
4503 ];
4504 assert_eq!(
4505 requirement_matches_only_yanked(
4506 &formatter,
4507 &VersionReq::new("modelled"),
4508 &versions(&["1.2.1"]),
4509 &advisory_then_yanked,
4510 ),
4511 Some(RemovalStatus::Yanked),
4512 "Yanked must win when it comes after AdvisoryDeprecated in the duplicate list"
4513 );
4514
4515 let yanked_then_advisory = vec![
4516 ("1.2.1".into(), RemovalStatus::Yanked),
4517 ("1.2.1".into(), RemovalStatus::AdvisoryDeprecated),
4518 ];
4519 assert_eq!(
4520 requirement_matches_only_yanked(
4521 &formatter,
4522 &VersionReq::new("modelled"),
4523 &versions(&["1.2.1"]),
4524 &yanked_then_advisory,
4525 ),
4526 Some(RemovalStatus::Yanked),
4527 "Yanked must win when it comes before AdvisoryDeprecated in the duplicate list"
4528 );
4529 }
4530
4531 #[test]
4532 fn test_no_match_is_false() {
4533 let formatter = TableFormatter::new(|_v| Some(false));
4534 assert!(
4535 requirement_matches_only_yanked(
4536 &formatter,
4537 &VersionReq::new("modelled"),
4538 &versions(&["1.2.1"]),
4539 &yanked_versions(&["1.2.1"]),
4540 )
4541 .is_none()
4542 );
4543 }
4544
4545 #[test]
4546 fn test_match_on_non_yanked_alongside_yanked_is_false() {
4547 let formatter = TableFormatter::new(|v| Some(v == "1.0.0" || v == "1.0.1"));
4550 assert!(
4551 requirement_matches_only_yanked(
4552 &formatter,
4553 &VersionReq::new("modelled"),
4554 &versions(&["1.0.1", "1.0.0"]),
4555 &yanked_versions(&["1.0.0"]),
4556 )
4557 .is_none()
4558 );
4559 }
4560
4561 #[test]
4562 fn test_scan_continues_past_a_yanked_match_to_find_a_non_yanked_alternative() {
4563 let formatter = TableFormatter::new(|v| Some(v == "1.0.0" || v == "1.0.1"));
4567 assert!(
4568 requirement_matches_only_yanked(
4569 &formatter,
4570 &VersionReq::new("modelled"),
4571 &versions(&["1.0.0", "1.0.1"]),
4572 &yanked_versions(&["1.0.0"]),
4573 )
4574 .is_none()
4575 );
4576 }
4577
4578 #[test]
4579 fn test_empty_yanked_list_is_false_without_compiling() {
4580 let formatter =
4581 TableFormatter::new(|_v| panic!("must not compile/scan when yanked is empty"));
4582 assert!(
4583 requirement_matches_only_yanked(
4584 &formatter,
4585 &VersionReq::new("modelled"),
4586 &versions(&["1.0.0"]),
4587 &[],
4588 )
4589 .is_none()
4590 );
4591 }
4592
4593 #[test]
4594 fn test_empty_available_list_is_false() {
4595 let formatter = TableFormatter::new(|_v| Some(true));
4596 assert!(
4597 requirement_matches_only_yanked(
4598 &formatter,
4599 &VersionReq::new("modelled"),
4600 &[],
4601 &yanked_versions(&["1.0.0"]),
4602 )
4603 .is_none()
4604 );
4605 }
4606
4607 #[test]
4608 fn test_unresolved_requirement_is_false() {
4609 let formatter = TableFormatter::new(|_v| Some(true));
4610 assert!(
4611 requirement_matches_only_yanked(
4612 &formatter,
4613 &VersionReq::new("unresolved"),
4614 &versions(&["1.0.0"]),
4615 &yanked_versions(&["1.0.0"]),
4616 )
4617 .is_none()
4618 );
4619 }
4620
4621 #[test]
4622 fn test_compile_requirement_none_is_false() {
4623 let formatter = TableFormatter::new(|_v| Some(true));
4624 assert!(
4625 requirement_matches_only_yanked(
4626 &formatter,
4627 &VersionReq::new("not-modelled"),
4628 &versions(&["1.0.0"]),
4629 &yanked_versions(&["1.0.0"]),
4630 )
4631 .is_none()
4632 );
4633 }
4634
4635 #[test]
4640 fn test_generate_diagnostics_from_cache_yanked_only_match_fires_yanked_diagnostic() {
4641 let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
4642
4643 let parse_result = MockParseResult {
4644 deps: vec![MockDep {
4645 name: "serde".into(),
4646 version_req: "modelled".into(),
4647 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
4648 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
4649 }],
4650 uri: crate::test_util::test_uri("/test/Cargo.toml"),
4651 };
4652
4653 let mut cached_versions = HashMap::new();
4654 cached_versions.insert(
4655 "serde".into(),
4656 PackageVersions {
4657 latest: "2.0.0".into(),
4658 available: Arc::from(vec!["2.0.0".into(), "1.2.1".into()]),
4659 yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::Yanked)]),
4660 published_at: None,
4661 },
4662 );
4663 let resolved_versions = HashMap::new();
4664
4665 let diagnostics = generate_diagnostics_from_cache(
4666 &parse_result,
4667 VersionData::new(&cached_versions, &resolved_versions),
4668 &formatter,
4669 parse_result.uri(),
4670 crate::freshness::FreshnessSettings::default(),
4671 DiagnosticSeverities::default(),
4672 PublishTime::now(),
4673 );
4674
4675 assert_eq!(diagnostics.len(), 1, "expected exactly one diagnostic");
4676 assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::WARNING));
4677 assert_eq!(
4678 diagnostics[0].message,
4679 format!("{}; latest is 2.0.0", formatter.yanked_message())
4680 );
4681 }
4682
4683 #[test]
4693 fn test_generate_diagnostics_from_cache_deprecation_never_suppresses_yanked_only_match_without_263_entry()
4694 {
4695 let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
4696
4697 let parse_result = MockParseResult {
4698 deps: vec![MockDep {
4699 name: "serde".into(),
4700 version_req: "modelled".into(),
4701 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
4702 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
4703 }],
4704 uri: crate::test_util::test_uri("/test/Cargo.toml"),
4705 };
4706
4707 let mut cached_versions = HashMap::new();
4708 cached_versions.insert(
4709 "serde".into(),
4710 PackageVersions {
4711 latest: "2.0.0".into(),
4712 available: Arc::from(vec!["2.0.0".into(), "1.2.1".into()]),
4713 yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::Yanked)]),
4714 published_at: None,
4715 },
4716 );
4717 let resolved_versions = HashMap::new();
4718 let outcomes = DependencyOutcomes::new().with_deprecation(
4722 "serde",
4723 Deprecation {
4724 reason: Some("archived".to_string()),
4725 replacement: None,
4726 },
4727 );
4728
4729 let diagnostics = generate_diagnostics_from_cache(
4730 &parse_result,
4731 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
4732 &formatter,
4733 parse_result.uri(),
4734 crate::freshness::FreshnessSettings::default(),
4735 DiagnosticSeverities::default(),
4736 PublishTime::now(),
4737 );
4738
4739 assert_eq!(
4740 diagnostics.len(),
4741 2,
4742 "expected exactly the deprecation diagnostic plus the #247 yanked-only \
4743 match, got: {diagnostics:?}"
4744 );
4745 assert_eq!(
4750 diagnostics[0].message,
4751 format!("{}: archived", formatter.deprecated_message()),
4752 "a genuine Yanked #247 match must still fire, without a #263 entry: {diagnostics:?}"
4753 );
4754 assert_eq!(
4755 diagnostics[0].code,
4756 Some(NumberOrString::String(DEPRECATED_DIAGNOSTIC_CODE.into()))
4757 );
4758 assert_eq!(
4759 diagnostics[1].message,
4760 format!("{}; latest is 2.0.0", formatter.yanked_message())
4761 );
4762 assert_eq!(diagnostics[1].code, None);
4763 }
4764
4765 #[test]
4770 fn test_generate_diagnostics_from_cache_deprecation_suppresses_yanked_only_match_without_263_entry()
4771 {
4772 let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
4773
4774 let parse_result = MockParseResult {
4775 deps: vec![MockDep {
4776 name: "serde".into(),
4777 version_req: "modelled".into(),
4778 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
4779 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
4780 }],
4781 uri: crate::test_util::test_uri("/test/Cargo.toml"),
4782 };
4783
4784 let mut cached_versions = HashMap::new();
4785 cached_versions.insert(
4786 "serde".into(),
4787 PackageVersions {
4788 latest: "2.0.0".into(),
4789 available: Arc::from(vec!["2.0.0".into(), "1.2.1".into()]),
4790 yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::AdvisoryDeprecated)]),
4791 published_at: None,
4792 },
4793 );
4794 let resolved_versions = HashMap::new();
4795 let outcomes = DependencyOutcomes::new().with_deprecation(
4799 "serde",
4800 Deprecation {
4801 reason: Some("archived".to_string()),
4802 replacement: None,
4803 },
4804 );
4805
4806 let diagnostics = generate_diagnostics_from_cache(
4807 &parse_result,
4808 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
4809 &formatter,
4810 parse_result.uri(),
4811 crate::freshness::FreshnessSettings::default(),
4812 DiagnosticSeverities::default(),
4813 PublishTime::now(),
4814 );
4815
4816 assert!(
4817 diagnostics
4818 .iter()
4819 .all(|d| !d.message.starts_with(formatter.yanked_message())),
4820 "an AdvisoryDeprecated #247 match must yield to the co-occurring deprecation \
4821 finding, got: {diagnostics:?}"
4822 );
4823 assert!(
4824 diagnostics
4825 .iter()
4826 .any(|d| d.message.starts_with(formatter.deprecated_message())),
4827 "the deprecation finding must still fire: {diagnostics:?}"
4828 );
4829 }
4830
4831 #[test]
4841 fn test_generate_diagnostics_from_cache_suppressed_263_entry_does_not_gate_off_yanked_247_match()
4842 {
4843 let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
4844
4845 let parse_result = MockParseResult {
4846 deps: vec![MockDep {
4847 name: "serde".into(),
4848 version_req: "modelled".into(),
4849 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
4850 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
4851 }],
4852 uri: crate::test_util::test_uri("/test/Cargo.toml"),
4853 };
4854
4855 let mut cached_versions = HashMap::new();
4856 cached_versions.insert(
4857 "serde".into(),
4858 PackageVersions {
4859 latest: "2.0.0".into(),
4860 available: Arc::from(vec!["2.0.0".into(), "1.2.1".into()]),
4861 yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::Yanked)]),
4862 published_at: None,
4863 },
4864 );
4865 let resolved_versions = HashMap::new();
4866 let outcomes = DependencyOutcomes::new()
4869 .with_yanked("serde", ("2.0.0".into(), RemovalStatus::AdvisoryDeprecated))
4870 .with_deprecation(
4871 "serde",
4872 Deprecation {
4873 reason: Some("archived".to_string()),
4874 replacement: None,
4875 },
4876 );
4877
4878 let diagnostics = generate_diagnostics_from_cache(
4879 &parse_result,
4880 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
4881 &formatter,
4882 parse_result.uri(),
4883 crate::freshness::FreshnessSettings::default(),
4884 DiagnosticSeverities::default(),
4885 PublishTime::now(),
4886 );
4887
4888 assert_eq!(
4889 diagnostics.len(),
4890 2,
4891 "expected exactly the deprecation diagnostic plus the #247 yanked-only \
4892 match — the D5-suppressed #263 in-use-version diagnostic (for the \
4893 deprecated 2.0.0) must stay suppressed and contribute nothing, got: \
4894 {diagnostics:?}"
4895 );
4896 assert_eq!(
4901 diagnostics[0].message,
4902 format!("{}: archived", formatter.deprecated_message()),
4903 "the package-level deprecation finding must still fire: {diagnostics:?}"
4904 );
4905 assert_eq!(
4906 diagnostics[0].code,
4907 Some(NumberOrString::String(DEPRECATED_DIAGNOSTIC_CODE.into()))
4908 );
4909 assert_eq!(
4910 diagnostics[1].message,
4911 format!("{}; latest is 2.0.0", formatter.yanked_message()),
4912 "the #247 match against the genuinely Yanked 1.2.1 must still fire even though \
4913 the unrelated #263 in-use-version finding was D5-suppressed, got: {diagnostics:?}"
4914 );
4915 assert_eq!(diagnostics[1].code, None);
4916 }
4917
4918 #[test]
4924 fn test_generate_diagnostics_from_cache_yanked_dedup_in_use_and_requirement_only_match() {
4925 let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
4926
4927 let parse_result = MockParseResult {
4928 deps: vec![MockDep {
4929 name: "serde".into(),
4930 version_req: "modelled".into(),
4931 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
4932 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
4933 }],
4934 uri: crate::test_util::test_uri("/test/Cargo.toml"),
4935 };
4936
4937 let mut cached_versions = HashMap::new();
4938 cached_versions.insert(
4939 "serde".into(),
4940 PackageVersions {
4941 latest: "2.0.0".into(),
4942 available: Arc::from(vec!["2.0.0".into(), "1.2.1".into()]),
4943 yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::Yanked)]),
4944 published_at: None,
4945 },
4946 );
4947 let resolved_versions = HashMap::new();
4948 let outcomes = DependencyOutcomes::new()
4949 .with_yanked("serde", ("1.2.1".into(), RemovalStatus::Yanked));
4950
4951 let diagnostics = generate_diagnostics_from_cache(
4952 &parse_result,
4953 VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
4954 &formatter,
4955 parse_result.uri(),
4956 crate::freshness::FreshnessSettings::default(),
4957 DiagnosticSeverities::default(),
4958 PublishTime::now(),
4959 );
4960
4961 assert_eq!(
4970 diagnostics.len(),
4971 2,
4972 "expected exactly the yanked diagnostic plus the co-emitted outdated \
4973 diagnostic (#263's policy), got: {diagnostics:?}"
4974 );
4975 assert_eq!(
4980 diagnostics[0].message,
4981 format!("{} (1.2.1)", formatter.yanked_message()),
4982 "expected the in-use-version check (#263) to run first and win, got: {diagnostics:?}"
4983 );
4984 assert_eq!(diagnostics[0].code, None);
4985 assert_eq!(
4986 diagnostics[1].message, "Newer version available: 2.0.0",
4987 "expected the co-emitted outdated diagnostic, got: {diagnostics:?}"
4988 );
4989 assert_eq!(diagnostics[1].code, None);
4990 }
4991
4992 #[test]
4995 fn test_generate_diagnostics_from_cache_yanked_only_match_uses_configured_severity() {
4996 let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
4997
4998 let parse_result = MockParseResult {
4999 deps: vec![MockDep {
5000 name: "serde".into(),
5001 version_req: "modelled".into(),
5002 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
5003 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
5004 }],
5005 uri: crate::test_util::test_uri("/test/Cargo.toml"),
5006 };
5007
5008 let mut cached_versions = HashMap::new();
5009 cached_versions.insert(
5010 "serde".into(),
5011 PackageVersions {
5012 latest: "1.2.1".into(),
5013 available: Arc::from(vec!["1.2.1".into()]),
5014 yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::Yanked)]),
5015 published_at: None,
5016 },
5017 );
5018 let resolved_versions = HashMap::new();
5019
5020 let severities = DiagnosticSeverities {
5021 yanked: DiagnosticSeverity::ERROR,
5022 ..DiagnosticSeverities::default()
5023 };
5024
5025 let diagnostics = generate_diagnostics_from_cache(
5026 &parse_result,
5027 VersionData::new(&cached_versions, &resolved_versions),
5028 &formatter,
5029 parse_result.uri(),
5030 crate::freshness::FreshnessSettings::default(),
5031 severities,
5032 PublishTime::now(),
5033 );
5034
5035 assert_eq!(diagnostics.len(), 1);
5036 assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::ERROR));
5037 }
5038
5039 #[test]
5042 fn test_generate_diagnostics_from_cache_match_with_non_yanked_alternative_skips_yanked_diagnostic()
5043 {
5044 let formatter = TableFormatter::new(|v| Some(v == "1.0.0" || v == "1.0.1"));
5045
5046 let parse_result = MockParseResult {
5047 deps: vec![MockDep {
5048 name: "serde".into(),
5049 version_req: "modelled".into(),
5050 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
5051 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
5052 }],
5053 uri: crate::test_util::test_uri("/test/Cargo.toml"),
5054 };
5055
5056 let mut cached_versions = HashMap::new();
5057 cached_versions.insert(
5058 "serde".into(),
5059 PackageVersions {
5060 latest: "2.0.0".into(),
5061 available: Arc::from(vec!["2.0.0".into(), "1.0.1".into(), "1.0.0".into()]),
5062 yanked: Arc::from(vec![("1.0.0".into(), RemovalStatus::Yanked)]),
5063 published_at: None,
5064 },
5065 );
5066 let resolved_versions = HashMap::new();
5067
5068 let diagnostics = generate_diagnostics_from_cache(
5069 &parse_result,
5070 VersionData::new(&cached_versions, &resolved_versions),
5071 &formatter,
5072 parse_result.uri(),
5073 crate::freshness::FreshnessSettings::default(),
5074 DiagnosticSeverities::default(),
5075 PublishTime::now(),
5076 );
5077
5078 assert!(
5079 !diagnostics
5080 .iter()
5081 .any(|d| d.message.starts_with(formatter.yanked_message())),
5082 "a non-yanked match exists, so no yanked diagnostic should fire, got: {diagnostics:?}"
5083 );
5084 }
5085
5086 #[test]
5090 fn test_undecided_candidate_prevents_true_verdict() {
5091 let formatter = TableFormatter::new(|v| match v {
5092 "1.2.1" => Some(true),
5093 "unparseable" => None,
5094 _ => Some(false),
5095 });
5096 assert!(
5097 requirement_matches_only_yanked(
5098 &formatter,
5099 &VersionReq::new("modelled"),
5100 &versions(&["1.2.1", "unparseable"]),
5101 &yanked_versions(&["1.2.1"]),
5102 )
5103 .is_none()
5104 );
5105 }
5106
5107 #[test]
5111 fn test_undecided_candidate_before_match_still_prevents_true_verdict() {
5112 let formatter = TableFormatter::new(|v| match v {
5113 "1.2.1" => Some(true),
5114 "unparseable" => None,
5115 _ => Some(false),
5116 });
5117 assert!(
5118 requirement_matches_only_yanked(
5119 &formatter,
5120 &VersionReq::new("modelled"),
5121 &versions(&["unparseable", "1.2.1"]),
5122 &yanked_versions(&["1.2.1"]),
5123 )
5124 .is_none()
5125 );
5126 }
5127
5128 #[test]
5129 fn test_oversized_requirement_is_false_without_compiling() {
5130 let formatter =
5131 TableFormatter::new(|_v| panic!("must not compile/scan an oversized requirement"));
5132 let oversized = "1".repeat(MAX_REQUIREMENT_LEN + 1);
5133 assert!(
5134 requirement_matches_only_yanked(
5135 &formatter,
5136 &VersionReq::new(oversized),
5137 &versions(&["1.0.0"]),
5138 &yanked_versions(&["1.0.0"]),
5139 )
5140 .is_none()
5141 );
5142 }
5143
5144 #[test]
5149 fn test_yanked_only_match_skipped_for_non_registry_sources() {
5150 let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
5151 let uri = crate::test_util::test_uri("/test/Cargo.toml");
5152
5153 let mut cached_versions = HashMap::new();
5154 cached_versions.insert(
5155 "dep".into(),
5156 PackageVersions {
5157 latest: "2.0.0".into(),
5158 available: Arc::from(vec!["2.0.0".into(), "1.2.1".into()]),
5159 yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::Yanked)]),
5160 published_at: None,
5161 },
5162 );
5163 let resolved_versions = HashMap::new();
5164
5165 let dep = MockDep {
5166 name: "dep".into(),
5167 version_req: "modelled".into(),
5168 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
5169 name_range: Range::new(Position::new(0, 0), Position::new(0, 3)),
5170 };
5171 let parse_result = SingleDepParseResult {
5172 dep: NonRegistryDep(
5173 dep,
5174 crate::parser::DependencySource::Path {
5175 path: "../local".into(),
5176 },
5177 ),
5178 uri,
5179 };
5180
5181 let diagnostics = generate_diagnostics_from_cache(
5182 &parse_result,
5183 VersionData::new(&cached_versions, &resolved_versions),
5184 &formatter,
5185 parse_result.uri(),
5186 crate::freshness::FreshnessSettings::default(),
5187 DiagnosticSeverities::default(),
5188 PublishTime::now(),
5189 );
5190
5191 assert!(
5192 diagnostics
5193 .iter()
5194 .all(|d| !d.message.starts_with(formatter.yanked_message())),
5195 "a path dependency must never produce the yanked diagnostic, got: {diagnostics:?}"
5196 );
5197 }
5198
5199 #[test]
5203 fn test_yanked_only_match_suppresses_outdated_diagnostic() {
5204 let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
5205
5206 let parse_result = MockParseResult {
5207 deps: vec![MockDep {
5208 name: "serde".into(),
5209 version_req: "modelled".into(),
5210 version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
5211 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
5212 }],
5213 uri: crate::test_util::test_uri("/test/Cargo.toml"),
5214 };
5215
5216 let mut cached_versions = HashMap::new();
5217 cached_versions.insert(
5218 "serde".into(),
5219 PackageVersions {
5220 latest: "2.0.0".into(),
5221 available: Arc::from(vec!["2.0.0".into(), "1.2.1".into()]),
5222 yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::Yanked)]),
5223 published_at: None,
5224 },
5225 );
5226 let resolved_versions = HashMap::new();
5227
5228 let diagnostics = generate_diagnostics_from_cache(
5229 &parse_result,
5230 VersionData::new(&cached_versions, &resolved_versions),
5231 &formatter,
5232 parse_result.uri(),
5233 crate::freshness::FreshnessSettings::default(),
5234 DiagnosticSeverities::default(),
5235 PublishTime::now(),
5236 );
5237
5238 assert!(
5239 !diagnostics
5240 .iter()
5241 .any(|d| d.message.contains("Newer version available")),
5242 "the yanked diagnostic must suppress the outdated hint, not add to it, got: {diagnostics:?}"
5243 );
5244 }
5245 }
5246}